diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5ace4600 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..44c6f147 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: ci + +on: + push: + branches: ["main"] + pull_request: + +permissions: + contents: read + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - name: Harden runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Python + uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + with: + python-version: "3.10" + + - name: Install backend deps + working-directory: backend + run: | + python -m pip install -U pip + python -m pip install -e ".[dev]" + + - name: Typecheck (mypy) + working-directory: backend + run: mypy app + + - name: Tests (pytest) + working-directory: backend + run: pytest -q + + frontend: + runs-on: ubuntu-latest + steps: + - name: Harden runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install + working-directory: frontend + run: npm ci + + - name: Typecheck + working-directory: frontend + run: npm run typecheck + + - name: Build + working-directory: frontend + run: npm run build diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..2bb35a98 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: codeql + +on: + push: + branches: ["main"] + pull_request: + schedule: + - cron: "0 2 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: ["javascript-typescript", "python"] + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..f5ec5a44 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,16 @@ +name: dependency-review + +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Dependency Review + uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..0b810b8d --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,36 @@ +name: scorecard + +on: + branch_protection_rule: + schedule: + - cron: "30 1 * * 6" + push: + branches: ["main"] + +permissions: + contents: read + +jobs: + analysis: + permissions: + contents: read + security-events: write + id-token: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index e3ba6099..3f799b85 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ __pycache__/ node_modules/ dist/ .vite/ + +# opencode tooling artifacts +registered_agents.json +task_agent_mapping.json diff --git a/README.md b/README.md index 6c617bae..a58e33ad 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,19 @@ PostgreSQL 전문 클라우드 ERD(협업/공유) 소프트웨어의 **실행 - **ERD UI**: React Flow(MIT)로 PK/FK를 그래픽으로 렌더링 - **Forward engineering(포워드)**: MVP 단계에서는 “스냅샷 기반 DDL(export)” 중심 (diff/변경 SQL 생성은 로드맵) + - SQL export: `GET /api/snapshots/{snapshot_uuid}/export.sql` + +## 공유(동료에게 링크 공유) + +프로젝트 오너는 공유 링크를 생성할 수 있습니다. + +```bash +curl -X POST "http://localhost:8000/api/projects//share-links" \ + -H "X-Dev-User: alice" +``` + +반환된 `url_path`로 동료가 최신 스냅샷 목록/스냅샷 JSON/DDL export를 조회할 수 있습니다. + ## 인덱스 타입 지원 원칙 PostgreSQL은 `CREATE INDEX ... USING `의 ``가 기본(btree/hash/gist/gin/spgist/brin)뿐 아니라 **확장(extension)으로 추가된 access method**도 될 수 있습니다. @@ -40,6 +53,17 @@ docker compose up -d --build - Frontend: http://localhost:5173 - Backend: http://localhost:8000 (health: /healthz) +## 실행(프로덕션 스타일, Docker) + +프론트는 정적 빌드 + nginx로 서빙하며, `/api/*`는 백엔드로 프록시합니다. + +```bash +cp .env.example .env +docker compose -f compose.prod.yaml up -d --build +``` + +- Frontend: http://localhost:8080 + ## 개발(비-Docker) ### Backend diff --git a/backend/alembic/versions/0002_auth_share.py b/backend/alembic/versions/0002_auth_share.py new file mode 100644 index 00000000..bd7aaf38 --- /dev/null +++ b/backend/alembic/versions/0002_auth_share.py @@ -0,0 +1,141 @@ +"""auth + share links + foreign keys + +Revision ID: 0002_auth_share +Revises: 0001_init +Create Date: 2026-01-31 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0002_auth_share" +down_revision = "0001_init" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add FK constraints (MVP: best-effort for fresh DB) + op.create_foreign_key( + "fk_project_space__created_by_user", + "project_space", + "user_account", + ["created_by_user_uuid"], + ["user_account_uuid"], + ) + op.create_foreign_key( + "fk_project_member__project_space", + "project_member", + "project_space", + ["project_space_uuid"], + ["project_space_uuid"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_project_member__user_account", + "project_member", + "user_account", + ["user_account_uuid"], + ["user_account_uuid"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_db_connection__project_space", + "db_connection", + "project_space", + ["project_space_uuid"], + ["project_space_uuid"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_schema_snapshot__project_space", + "schema_snapshot", + "project_space", + ["project_space_uuid"], + ["project_space_uuid"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_schema_snapshot__db_connection", + "schema_snapshot", + "db_connection", + ["db_connection_uuid"], + ["db_connection_uuid"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_schema_snapshot_data__schema_snapshot", + "schema_snapshot_data", + "schema_snapshot", + ["schema_snapshot_uuid"], + ["schema_snapshot_uuid"], + ondelete="CASCADE", + ) + + # Share links + op.create_table( + "share_link", + sa.Column("share_link_uuid", sa.Uuid(), primary_key=True, nullable=False), + sa.Column("project_space_uuid", sa.Uuid(), nullable=False), + sa.Column("created_by_user_uuid", sa.Uuid(), nullable=False), + sa.Column("permission_kind", sa.Text(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index( + "ix_share_link__project_space_uuid", + "share_link", + ["project_space_uuid"], + ) + op.create_foreign_key( + "fk_share_link__project_space", + "share_link", + "project_space", + ["project_space_uuid"], + ["project_space_uuid"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_share_link__created_by_user", + "share_link", + "user_account", + ["created_by_user_uuid"], + ["user_account_uuid"], + ondelete="CASCADE", + ) + + +def downgrade() -> None: + op.drop_constraint( + "fk_share_link__created_by_user", "share_link", type_="foreignkey" + ) + op.drop_constraint("fk_share_link__project_space", "share_link", type_="foreignkey") + op.drop_index("ix_share_link__project_space_uuid", table_name="share_link") + op.drop_table("share_link") + + op.drop_constraint( + "fk_schema_snapshot_data__schema_snapshot", + "schema_snapshot_data", + type_="foreignkey", + ) + op.drop_constraint( + "fk_schema_snapshot__db_connection", "schema_snapshot", type_="foreignkey" + ) + op.drop_constraint( + "fk_schema_snapshot__project_space", "schema_snapshot", type_="foreignkey" + ) + op.drop_constraint( + "fk_db_connection__project_space", "db_connection", type_="foreignkey" + ) + op.drop_constraint( + "fk_project_member__user_account", "project_member", type_="foreignkey" + ) + op.drop_constraint( + "fk_project_member__project_space", "project_member", type_="foreignkey" + ) + op.drop_constraint( + "fk_project_space__created_by_user", "project_space", type_="foreignkey" + ) diff --git a/backend/app/api/connections.py b/backend/app/api/connections.py index 07264944..2851e503 100644 --- a/backend/app/api/connections.py +++ b/backend/app/api/connections.py @@ -7,8 +7,10 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import CurrentUser, get_current_user from app.db import get_session from app.models import DbConnection +from app.permissions import require_project_member from app.schemas import ConnectionCreateIn, ConnectionOut from app.security import encrypt_text from app.sanitize import sanitize_for_storage @@ -19,8 +21,11 @@ @router.get("/by-project/{project_space_uuid}", response_model=list[ConnectionOut]) async def list_connections( - project_space_uuid: uuid.UUID, session: AsyncSession = Depends(get_session) + project_space_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), ) -> list[ConnectionOut]: + await require_project_member(session, project_space_uuid, user.user_account_uuid) rows = await session.execute( select(DbConnection) .where(DbConnection.project_space_uuid == project_space_uuid) @@ -37,8 +42,10 @@ async def list_connections( async def create_connection( project_space_uuid: uuid.UUID, body: ConnectionCreateIn, + user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> ConnectionOut: + await require_project_member(session, project_space_uuid, user.user_account_uuid) encrypted = encrypt_text(str(sanitize_for_storage(body.dsn))) c = DbConnection( db_connection_uuid=uuid.uuid4(), diff --git a/backend/app/api/me.py b/backend/app/api/me.py new file mode 100644 index 00000000..841abbba --- /dev/null +++ b/backend/app/api/me.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.auth import CurrentUser, get_current_user +from app.schemas import MeOut + + +router = APIRouter(prefix="/api", tags=["me"]) + + +@router.get("/me", response_model=MeOut) +async def get_me(user: CurrentUser = Depends(get_current_user)) -> MeOut: + return MeOut( + user_account_uuid=user.user_account_uuid, + subject=user.subject, + display_name=user.display_name, + ) diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index a165ca35..71bfe657 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -3,13 +3,19 @@ import datetime as dt import uuid -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import CurrentUser, get_current_user from app.db import get_session -from app.models import ProjectSpace -from app.schemas import ProjectCreateIn, ProjectOut +from app.models import ProjectMember, ProjectSpace, UserAccount +from app.schemas import ( + ProjectCreateIn, + ProjectMemberAddIn, + ProjectMemberOut, + ProjectOut, +) from app.sanitize import sanitize_for_storage @@ -18,10 +24,17 @@ @router.get("", response_model=list[ProjectOut]) async def list_projects( + user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> list[ProjectOut]: rows = await session.execute( - select(ProjectSpace).order_by(ProjectSpace.created_at.desc()) + select(ProjectSpace) + .join( + ProjectMember, + ProjectMember.project_space_uuid == ProjectSpace.project_space_uuid, + ) + .where(ProjectMember.user_account_uuid == user.user_account_uuid) + .order_by(ProjectSpace.created_at.desc()) ) projects = rows.scalars().all() return [ @@ -32,18 +45,117 @@ async def list_projects( @router.post("", response_model=ProjectOut) async def create_project( - body: ProjectCreateIn, session: AsyncSession = Depends(get_session) + body: ProjectCreateIn, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), ) -> ProjectOut: - # MVP: no auth yet; created_by is random placeholder. - created_by = uuid.uuid4() p = ProjectSpace( project_space_uuid=uuid.uuid4(), project_name=str(sanitize_for_storage(body.project_name)), - created_by_user_uuid=created_by, + created_by_user_uuid=user.user_account_uuid, created_at=dt.datetime.now(dt.timezone.utc), ) session.add(p) + await session.flush() # ensure project_space row exists before FK insert + + m = ProjectMember( + project_space_uuid=p.project_space_uuid, + user_account_uuid=user.user_account_uuid, + project_role="owner", + created_at=dt.datetime.now(dt.timezone.utc), + ) + session.add(m) await session.commit() return ProjectOut( project_space_uuid=p.project_space_uuid, project_name=p.project_name ) + + +@router.get("/{project_space_uuid}/members", response_model=list[ProjectMemberOut]) +async def list_project_members( + project_space_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> list[ProjectMemberOut]: + # owner/editor/viewer 모두 멤버 조회 가능(MVP) + row = await session.execute( + select(ProjectMember).where( + ProjectMember.project_space_uuid == project_space_uuid, + ProjectMember.user_account_uuid == user.user_account_uuid, + ) + ) + if row.scalars().first() is None: + raise HTTPException(status_code=403, detail="project access denied") + + rows = await session.execute( + select(ProjectMember, UserAccount) + .join( + UserAccount, + UserAccount.user_account_uuid == ProjectMember.user_account_uuid, + ) + .where(ProjectMember.project_space_uuid == project_space_uuid) + .order_by(ProjectMember.created_at.asc()) + ) + out: list[ProjectMemberOut] = [] + for m, u in rows.all(): + out.append( + ProjectMemberOut( + user_account_uuid=u.user_account_uuid, + member_subject=u.oidc_subject, + project_role=m.project_role, + ) + ) + return out + + +@router.post("/{project_space_uuid}/members", response_model=ProjectMemberOut) +async def add_project_member( + project_space_uuid: uuid.UUID, + body: ProjectMemberAddIn, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> ProjectMemberOut: + # MVP 권한: owner만 초대 가능 + row = await session.execute( + select(ProjectMember.project_role).where( + ProjectMember.project_space_uuid == project_space_uuid, + ProjectMember.user_account_uuid == user.user_account_uuid, + ) + ) + role = row.scalar_one_or_none() + if role != "owner": + raise HTTPException(status_code=403, detail="owner role required") + + subject = str(sanitize_for_storage(body.member_subject)).strip() + if not subject: + raise HTTPException(status_code=400, detail="member_subject required") + + # Ensure user exists. + row2 = await session.execute( + select(UserAccount).where(UserAccount.oidc_subject == subject) + ) + u = row2.scalars().first() + if u is None: + u = UserAccount( + user_account_uuid=uuid.uuid4(), + oidc_subject=subject, + display_name=None, + created_at=dt.datetime.now(dt.timezone.utc), + ) + session.add(u) + await session.flush() + + m = ProjectMember( + project_space_uuid=project_space_uuid, + user_account_uuid=u.user_account_uuid, + project_role=body.project_role, + created_at=dt.datetime.now(dt.timezone.utc), + ) + session.add(m) + await session.commit() + + return ProjectMemberOut( + user_account_uuid=u.user_account_uuid, + member_subject=u.oidc_subject, + project_role=m.project_role, + ) diff --git a/backend/app/api/share.py b/backend/app/api/share.py new file mode 100644 index 00000000..b0c6b31f --- /dev/null +++ b/backend/app/api/share.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import datetime as dt +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import PlainTextResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import CurrentUser, get_current_user +from app.db import get_session +from app.ddl.export import snapshot_json_to_sql +from app.models import ProjectMember, SchemaSnapshot, SchemaSnapshotData, ShareLink + + +router = APIRouter(prefix="/api", tags=["share"]) + + +@router.post("/projects/{project_space_uuid}/share-links") +async def create_share_link( + project_space_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> dict: + # owner only + row = await session.execute( + select(ProjectMember.project_role).where( + ProjectMember.project_space_uuid == project_space_uuid, + ProjectMember.user_account_uuid == user.user_account_uuid, + ) + ) + if row.scalar_one_or_none() != "owner": + raise HTTPException(status_code=403, detail="owner role required") + + link = ShareLink( + share_link_uuid=uuid.uuid4(), + project_space_uuid=project_space_uuid, + created_by_user_uuid=user.user_account_uuid, + permission_kind="viewer", + expires_at=None, + created_at=dt.datetime.now(dt.timezone.utc), + ) + session.add(link) + await session.commit() + return { + "share_link_uuid": str(link.share_link_uuid), + "permission_kind": link.permission_kind, + "url_path": f"/api/share/{link.share_link_uuid}", + } + + +@router.get("/share/{share_link_uuid}") +async def get_share_link_info( + share_link_uuid: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> dict: + link = await session.get(ShareLink, share_link_uuid) + if link is None: + raise HTTPException(status_code=404, detail="share link not found") + if link.expires_at is not None and link.expires_at <= dt.datetime.now( + dt.timezone.utc + ): + raise HTTPException(status_code=410, detail="share link expired") + + rows = await session.execute( + select(SchemaSnapshot) + .where(SchemaSnapshot.project_space_uuid == link.project_space_uuid) + .order_by(SchemaSnapshot.created_at.desc()) + .limit(20) + ) + snaps = rows.scalars().all() + return { + "project_space_uuid": str(link.project_space_uuid), + "permission_kind": link.permission_kind, + "snapshots": [ + { + "schema_snapshot_uuid": str(s.schema_snapshot_uuid), + "status": s.status, + "schema_filter": s.schema_filter, + "created_at": s.created_at.isoformat(), + } + for s in snaps + ], + } + + +@router.get("/share/{share_link_uuid}/snapshots/{schema_snapshot_uuid}") +async def get_shared_snapshot( + share_link_uuid: uuid.UUID, + schema_snapshot_uuid: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> dict: + link = await session.get(ShareLink, share_link_uuid) + if link is None: + raise HTTPException(status_code=404, detail="share link not found") + if link.expires_at is not None and link.expires_at <= dt.datetime.now( + dt.timezone.utc + ): + raise HTTPException(status_code=410, detail="share link expired") + + snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) + if snap is None or snap.project_space_uuid != link.project_space_uuid: + raise HTTPException(status_code=404, detail="snapshot not found") + + data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) + return { + "schema_snapshot_uuid": str(snap.schema_snapshot_uuid), + "status": snap.status, + "schema_filter": snap.schema_filter, + "error_message": snap.error_message, + "snapshot_json": data.snapshot_json if data else None, + } + + +@router.get( + "/share/{share_link_uuid}/snapshots/{schema_snapshot_uuid}/export.sql", + response_class=PlainTextResponse, +) +async def export_shared_snapshot_sql( + share_link_uuid: uuid.UUID, + schema_snapshot_uuid: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> str: + link = await session.get(ShareLink, share_link_uuid) + if link is None: + raise HTTPException(status_code=404, detail="share link not found") + if link.expires_at is not None and link.expires_at <= dt.datetime.now( + dt.timezone.utc + ): + raise HTTPException(status_code=410, detail="share link expired") + + snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) + if snap is None or snap.project_space_uuid != link.project_space_uuid: + raise HTTPException(status_code=404, detail="snapshot not found") + + data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) + if data is None: + return "-- snapshot data not found\n" + return snapshot_json_to_sql(data.snapshot_json) diff --git a/backend/app/api/snapshots.py b/backend/app/api/snapshots.py index 38c1b8eb..8d5cd617 100644 --- a/backend/app/api/snapshots.py +++ b/backend/app/api/snapshots.py @@ -4,12 +4,16 @@ import uuid from fastapi import APIRouter, Depends +from fastapi.responses import PlainTextResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.db import SessionLocal, get_session -from app.models import JobQueue, SchemaSnapshot, SchemaSnapshotData +from app.auth import CurrentUser, get_current_user +from app.db import get_session +from app.models import DbConnection, JobQueue, SchemaSnapshot, SchemaSnapshotData +from app.permissions import require_project_member from app.schemas import SnapshotCreateIn, SnapshotDetailOut, SnapshotOut +from app.ddl.export import snapshot_json_to_sql router = APIRouter(prefix="/api/snapshots", tags=["snapshots"]) @@ -19,8 +23,20 @@ async def create_snapshot( project_space_uuid: uuid.UUID, body: SnapshotCreateIn, + user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> SnapshotOut: + await require_project_member(session, project_space_uuid, user.user_account_uuid) + + # Ensure connection belongs to this project + conn = await session.get(DbConnection, body.db_connection_uuid) + if conn is None or conn.project_space_uuid != project_space_uuid: + return SnapshotOut( + schema_snapshot_uuid=uuid.uuid4(), + status="failed", + schema_filter=body.schema_filter, + ) + snap = SchemaSnapshot( schema_snapshot_uuid=uuid.uuid4(), project_space_uuid=project_space_uuid, @@ -58,7 +74,9 @@ async def create_snapshot( @router.get("/{schema_snapshot_uuid}", response_model=SnapshotDetailOut) async def get_snapshot( - schema_snapshot_uuid: uuid.UUID, session: AsyncSession = Depends(get_session) + schema_snapshot_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), ) -> SnapshotDetailOut: snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) if snap is None: @@ -69,6 +87,9 @@ async def get_snapshot( error_message="snapshot not found", snapshot_json=None, ) + await require_project_member( + session, snap.project_space_uuid, user.user_account_uuid + ) data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) return SnapshotDetailOut( schema_snapshot_uuid=snap.schema_snapshot_uuid, @@ -79,10 +100,31 @@ async def get_snapshot( ) +@router.get("/{schema_snapshot_uuid}/export.sql", response_class=PlainTextResponse) +async def export_snapshot_sql( + schema_snapshot_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> str: + snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) + if snap is None: + return "-- snapshot not found\n" + await require_project_member( + session, snap.project_space_uuid, user.user_account_uuid + ) + data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) + if data is None: + return "-- snapshot data not found\n" + return snapshot_json_to_sql(data.snapshot_json) + + @router.get("/by-project/{project_space_uuid}", response_model=list[SnapshotOut]) async def list_snapshots( - project_space_uuid: uuid.UUID, session: AsyncSession = Depends(get_session) + project_space_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), ) -> list[SnapshotOut]: + await require_project_member(session, project_space_uuid, user.user_account_uuid) rows = await session.execute( select(SchemaSnapshot) .where(SchemaSnapshot.project_space_uuid == project_space_uuid) diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 00000000..4857dd63 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import datetime as dt +import uuid +from dataclasses import dataclass +from typing import Any, cast + +import httpx +from fastapi import Depends, HTTPException, Request +from jose import jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_session +from app.models import UserAccount +from app.settings import settings + + +@dataclass(frozen=True) +class CurrentUser: + user_account_uuid: uuid.UUID + subject: str + display_name: str | None + + +_oidc_config: dict[str, Any] | None = None +_oidc_jwks: dict[str, Any] | None = None +_oidc_expires_at: dt.datetime = dt.datetime.fromtimestamp(0, tz=dt.timezone.utc) + + +async def _get_oidc_config() -> dict: + if not settings.oidc_issuer: + raise RuntimeError("OIDC is disabled") + + global _oidc_config, _oidc_expires_at + now = dt.datetime.now(dt.timezone.utc) + if _oidc_config is not None and now < _oidc_expires_at: + return cast(dict, _oidc_config) + + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get( + f"{settings.oidc_issuer.rstrip('/')}/.well-known/openid-configuration" + ) + r.raise_for_status() + config = cast(dict[str, Any], r.json()) + + _oidc_config = config + _oidc_expires_at = now + dt.timedelta(minutes=10) + return cast(dict, config) + + +async def _get_jwks() -> dict: + config = await _get_oidc_config() + jwks_uri = config.get("jwks_uri") + if not isinstance(jwks_uri, str): + raise RuntimeError("OIDC jwks_uri missing") + + global _oidc_jwks, _oidc_expires_at + # Share same TTL as config. + now = dt.datetime.now(dt.timezone.utc) + if _oidc_jwks is not None and now < _oidc_expires_at: + return cast(dict, _oidc_jwks) + + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(jwks_uri) + r.raise_for_status() + jwks = cast(dict[str, Any], r.json()) + _oidc_jwks = jwks + return cast(dict, jwks) + + +def _pick_jwk(jwks: dict, kid: str | None) -> dict | None: + keys = jwks.get("keys") + if not isinstance(keys, list): + return None + for k in keys: + if not isinstance(k, dict): + continue + if kid is None or k.get("kid") == kid: + return k + return None + + +async def _get_subject_from_request(request: Request) -> tuple[str, str | None]: + # OIDC mode (Casdoor etc.) + if settings.oidc_issuer: + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException(status_code=401, detail="missing bearer token") + token = auth.split(" ", 1)[1].strip() + try: + header = jwt.get_unverified_header(token) + except Exception: # noqa: BLE001 + raise HTTPException(status_code=401, detail="invalid token header") + + jwks = await _get_jwks() + jwk = _pick_jwk(jwks, header.get("kid")) + if jwk is None: + raise HTTPException(status_code=401, detail="unknown signing key") + + try: + claims = jwt.decode( + token, + jwk, + algorithms=[str(header.get("alg"))], + audience=settings.oidc_audience, + issuer=settings.oidc_issuer, + options={"verify_aud": bool(settings.oidc_audience)}, + ) + except Exception: # noqa: BLE001 + raise HTTPException(status_code=401, detail="token verification failed") + + sub = claims.get("sub") + name = claims.get("name") or claims.get("preferred_username") + if not isinstance(sub, str): + raise HTTPException(status_code=401, detail="token missing sub") + return sub, str(name) if isinstance(name, str) else None + + # Dev fallback (no OIDC configured) + dev_user = request.headers.get("X-Dev-User") or "local" + dev_user = dev_user.strip()[:128] + return f"dev:{dev_user}", dev_user + + +async def _ensure_user( + session: AsyncSession, subject: str, display_name: str | None +) -> CurrentUser: + row = await session.execute( + select(UserAccount).where(UserAccount.oidc_subject == subject) + ) + existing = row.scalars().first() + if existing is not None: + return CurrentUser( + user_account_uuid=existing.user_account_uuid, + subject=existing.oidc_subject, + display_name=existing.display_name, + ) + + user = UserAccount( + user_account_uuid=uuid.uuid4(), + oidc_subject=subject, + display_name=display_name, + created_at=dt.datetime.now(dt.timezone.utc), + ) + session.add(user) + await session.flush() + return CurrentUser( + user_account_uuid=user.user_account_uuid, + subject=user.oidc_subject, + display_name=user.display_name, + ) + + +async def get_current_user( + request: Request, + session: AsyncSession = Depends(get_session), +) -> CurrentUser: + subject, display_name = await _get_subject_from_request(request) + async with session.begin(): + return await _ensure_user(session, subject, display_name) diff --git a/backend/app/db.py b/backend/app/db.py index d73b6250..bfe7067a 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -15,7 +15,8 @@ def get_sync_database_url() -> str: # Alembic uses sync engine; convert async URL. url = settings.database_url if url.startswith("postgresql+asyncpg://"): - return url.replace("postgresql+asyncpg://", "postgresql://", 1) + # Prefer psycopg (v3) for sync migrations. + return url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1) return url diff --git a/backend/app/ddl/__init__.py b/backend/app/ddl/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/ddl/export.py b/backend/app/ddl/export.py new file mode 100644 index 00000000..98805fd0 --- /dev/null +++ b/backend/app/ddl/export.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from collections.abc import Iterable + + +def _q(ident: str) -> str: + # Quote identifier with double-quotes, escaping internal quotes. + return '"' + ident.replace('"', '""') + '"' + + +def _qname(schema: str, name: str) -> str: + return f"{_q(schema)}.{_q(name)}" + + +def snapshot_json_to_sql(snapshot: dict) -> str: + """Generate PostgreSQL DDL from a captured snapshot. + + This is MVP-grade forward engineering (export): + - Creates schemas and tables (columns only) + - Adds PK/UNIQUE/CHECK inside CREATE TABLE + - Adds FKs after all tables (order-safe) + - Adds indexes using saved pg_get_indexdef output + + Limitations (intentional, MVP): partitioning clauses and some table options are not reconstructed. + """ + + relations = snapshot.get("relations", []) + columns = snapshot.get("columns", []) + constraints = snapshot.get("constraints", []) + indexes = snapshot.get("indexes", []) + + tables = [r for r in relations if r.get("relation_kind") in ("r", "p")] + + cols_by_oid: dict[int, list[dict]] = {} + for c in columns: + oid = c.get("relation_oid") + if isinstance(oid, int): + cols_by_oid.setdefault(oid, []).append(c) + + constraints_by_oid: dict[int, list[dict]] = {} + for con in constraints: + oid = con.get("relation_oid") + if isinstance(oid, int): + constraints_by_oid.setdefault(oid, []).append(con) + + schemas: set[str] = { + t.get("schema_name") for t in tables if isinstance(t.get("schema_name"), str) + } + + lines: list[str] = [] + lines.append("-- Generated by pg-erd-cloud (MVP)\n") + + for s in sorted(schemas): + lines.append(f"CREATE SCHEMA IF NOT EXISTS {_q(s)};") + if schemas: + lines.append("") + + # CREATE TABLE + inline constraints (PK/UNIQUE/CHECK) + for t in tables: + schema = t.get("schema_name") + name = t.get("relation_name") + oid = t.get("relation_oid") + kind = t.get("relation_kind") + if not ( + isinstance(schema, str) and isinstance(name, str) and isinstance(oid, int) + ): + continue + + if kind == "p": + lines.append( + f"-- NOTE: {_qname(schema, name)} is partitioned; partition definition not included in MVP export" + ) + + col_defs: list[str] = [] + for c in sorted( + cols_by_oid.get(oid, []), key=lambda x: int(x.get("column_position") or 0) + ): + col_name = c.get("column_name") + data_type = c.get("data_type") + if not (isinstance(col_name, str) and isinstance(data_type, str)): + continue + parts = [f"{_q(col_name)} {data_type}"] + if c.get("has_default") and isinstance(c.get("default_expr"), str): + parts.append(f"DEFAULT {c.get('default_expr')}") + if c.get("is_not_null") is True: + parts.append("NOT NULL") + col_defs.append(" ".join(parts)) + + table_cons: list[str] = [] + for con in constraints_by_oid.get(oid, []): + ctype = con.get("constraint_type") + if ctype not in ("p", "u", "c"): + continue + cname = con.get("constraint_name") + cdef = con.get("constraint_def") + if isinstance(cname, str) and isinstance(cdef, str): + table_cons.append(f"CONSTRAINT {_q(cname)} {cdef}") + + all_defs = col_defs + table_cons + lines.append(f"CREATE TABLE IF NOT EXISTS {_qname(schema, name)} (") + for i, d in enumerate(all_defs): + comma = "," if i < len(all_defs) - 1 else "" + lines.append(f" {d}{comma}") + lines.append(");") + lines.append("") + + # FKs after tables + fk_cons = [c for c in constraints if c.get("constraint_type") == "f"] + if fk_cons: + lines.append("-- Foreign keys") + for con in fk_cons: + schema = con.get("schema_name") + table = con.get("relation_name") + cname = con.get("constraint_name") + cdef = con.get("constraint_def") + if not ( + isinstance(schema, str) + and isinstance(table, str) + and isinstance(cname, str) + and isinstance(cdef, str) + ): + continue + lines.append( + f"ALTER TABLE {_qname(schema, table)} ADD CONSTRAINT {_q(cname)} {cdef};" + ) + if fk_cons: + lines.append("") + + # Indexes (use saved pg_get_indexdef output) + if indexes: + lines.append("-- Indexes") + for ix in indexes: + ix_def = ix.get("index_def") + if not isinstance(ix_def, str): + continue + if not ix_def.strip().endswith(";"): + ix_def = ix_def.strip() + ";" + lines.append(ix_def) + + lines.append("") + return "\n".join(lines) diff --git a/backend/app/main.py b/backend/app/main.py index 461c595e..c9f64330 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,9 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.connections import router as connections_router +from app.api.me import router as me_router from app.api.projects import router as projects_router +from app.api.share import router as share_router from app.api.snapshots import router as snapshots_router from app.db import SessionLocal from app.jobs.snapshot_job import handle_snapshot_job @@ -33,6 +35,8 @@ async def healthz() -> dict: app.include_router(projects_router) app.include_router(connections_router) app.include_router(snapshots_router) +app.include_router(me_router) +app.include_router(share_router) @app.on_event("startup") diff --git a/backend/app/models.py b/backend/app/models.py index 7867cd30..527c5190 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,7 +3,7 @@ import datetime as dt import uuid -from sqlalchemy import DateTime, Index, Integer, LargeBinary, Text +from sqlalchemy import DateTime, ForeignKey, Index, Integer, LargeBinary, Text from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -36,7 +36,9 @@ class ProjectSpace(Base): UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) project_name: Mapped[str] = mapped_column(Text()) - created_by_user_uuid: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True)) + created_by_user_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("user_account.user_account_uuid") + ) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), default=utcnow ) @@ -46,10 +48,14 @@ class ProjectMember(Base): __tablename__ = "project_member" project_space_uuid: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), primary_key=True + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid"), + primary_key=True, ) user_account_uuid: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), primary_key=True + UUID(as_uuid=True), + ForeignKey("user_account.user_account_uuid"), + primary_key=True, ) project_role: Mapped[str] = mapped_column(Text()) created_at: Mapped[dt.datetime] = mapped_column( @@ -68,7 +74,7 @@ class DbConnection(Base): UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) project_space_uuid: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), index=True + UUID(as_uuid=True), ForeignKey("project_space.project_space_uuid"), index=True ) conn_name: Mapped[str] = mapped_column(Text()) dsn_ciphertext: Mapped[bytes] = mapped_column(LargeBinary()) @@ -88,9 +94,11 @@ class SchemaSnapshot(Base): UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) project_space_uuid: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), index=True + UUID(as_uuid=True), ForeignKey("project_space.project_space_uuid"), index=True + ) + db_connection_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("db_connection.db_connection_uuid") ) - db_connection_uuid: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True)) status: Mapped[str] = mapped_column(Text()) schema_filter: Mapped[str | None] = mapped_column(Text(), nullable=True) started_at: Mapped[dt.datetime | None] = mapped_column( @@ -109,7 +117,9 @@ class SchemaSnapshotData(Base): __tablename__ = "schema_snapshot_data" schema_snapshot_uuid: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), primary_key=True + UUID(as_uuid=True), + ForeignKey("schema_snapshot.schema_snapshot_uuid"), + primary_key=True, ) snapshot_json: Mapped[dict] = mapped_column(JSONB()) created_at: Mapped[dt.datetime] = mapped_column( @@ -142,3 +152,24 @@ class JobQueue(Base): ) __table_args__ = (Index("ix_job_queue__status_run_after", "status", "run_after"),) + + +class ShareLink(Base): + __tablename__ = "share_link" + + share_link_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + project_space_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("project_space.project_space_uuid"), index=True + ) + created_by_user_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("user_account.user_account_uuid") + ) + permission_kind: Mapped[str] = mapped_column(Text()) # viewer/editor (MVP: viewer) + expires_at: Mapped[dt.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) diff --git a/backend/app/permissions.py b/backend/app/permissions.py new file mode 100644 index 00000000..3e274cd3 --- /dev/null +++ b/backend/app/permissions.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import uuid + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import ProjectMember + + +async def require_project_member( + session: AsyncSession, + project_space_uuid: uuid.UUID, + user_account_uuid: uuid.UUID, +) -> str: + row = await session.execute( + select(ProjectMember.project_role).where( + ProjectMember.project_space_uuid == project_space_uuid, + ProjectMember.user_account_uuid == user_account_uuid, + ) + ) + role = row.scalar_one_or_none() + if role is None: + raise HTTPException(status_code=403, detail="project access denied") + return str(role) diff --git a/backend/app/pg_introspect/introspect.py b/backend/app/pg_introspect/introspect.py index 299a276a..9f6b8525 100644 --- a/backend/app/pg_introspect/introspect.py +++ b/backend/app/pg_introspect/introspect.py @@ -23,6 +23,10 @@ async def introspect_postgres(dsn: str, schema_filter: str | None) -> dict: queries.CONSTRAINTS_SQL, schema_name, include_system ) indexes = await conn.fetch(queries.INDEXES_SQL, schema_name, include_system) + pk_columns = await conn.fetch( + queries.PK_COLUMNS_SQL, schema_name, include_system + ) + fk_edges = await conn.fetch(queries.FK_EDGES_SQL, schema_name, include_system) snapshot = { "captured_at": dt.datetime.now(dt.timezone.utc).isoformat(), @@ -33,6 +37,8 @@ async def introspect_postgres(dsn: str, schema_filter: str | None) -> dict: "columns": [dict(r) for r in columns], "constraints": [dict(r) for r in constraints], "indexes": [dict(r) for r in indexes], + "pk_columns": [dict(r) for r in pk_columns], + "fk_edges": [dict(r) for r in fk_edges], } return sanitize_for_storage(snapshot) # type: ignore[return-value] diff --git a/backend/app/pg_introspect/queries.py b/backend/app/pg_introspect/queries.py index 8a2c9674..47cdf58c 100644 --- a/backend/app/pg_introspect/queries.py +++ b/backend/app/pg_introspect/queries.py @@ -33,7 +33,7 @@ n.nspname AS schema_name, c.oid AS relation_oid, c.relname AS relation_name, - c.relkind AS relation_kind, + c.relkind::text AS relation_kind, c.relispartition AS is_partition FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace @@ -62,7 +62,7 @@ n.nspname AS schema_name, c.oid AS relation_oid, c.relname AS relation_name, - c.relkind AS relation_kind, + c.relkind::text AS relation_kind, a.attnum AS column_position, a.attname AS column_name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, @@ -100,7 +100,7 @@ SELECT con.oid AS constraint_oid, con.conname AS constraint_name, - con.contype AS constraint_type, + con.contype::text AS constraint_type, n.nspname AS schema_name, rel.oid AS relation_oid, rel.relname AS relation_name, @@ -109,9 +109,9 @@ frel.relname AS foreign_relation_name, con.conkey AS constrained_attnums, con.confkey AS referenced_attnums, - con.confupdtype AS fk_on_update, - con.confdeltype AS fk_on_delete, - con.confmatchtype AS fk_match_type, + con.confupdtype::text AS fk_on_update, + con.confdeltype::text AS fk_on_delete, + con.confmatchtype::text AS fk_match_type, pg_catalog.pg_get_constraintdef(con.oid, true) AS constraint_def, pg_catalog.pg_get_expr(con.conbin, con.conrelid) AS check_expr FROM pg_catalog.pg_constraint con @@ -177,3 +177,101 @@ ) ORDER BY tbl_ns.nspname, tbl.relname, idx.relname; """ + + +PK_COLUMNS_SQL = """ +WITH params AS ( + SELECT $1::text AS schema_name, COALESCE($2::boolean, false) AS include_system +), pk AS ( + SELECT con.* + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid + JOIN pg_catalog.pg_namespace n ON n.oid = rel.relnamespace + CROSS JOIN params p + WHERE + con.contype = 'p' + AND (p.schema_name IS NULL OR n.nspname = p.schema_name) + AND ( + p.include_system + OR ( + n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg_toast%' + AND n.nspname NOT LIKE 'pg_temp_%' + AND n.nspname NOT LIKE 'pg_toast_temp_%' + ) + ) +) +SELECT + con.oid AS constraint_oid, + con.conname AS constraint_name, + n.nspname AS schema_name, + rel.oid AS relation_oid, + rel.relname AS relation_name, + k.ordinality AS column_ordinal, + a.attname AS column_name +FROM pk con +JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid +JOIN pg_catalog.pg_namespace n ON n.oid = rel.relnamespace +JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS k(attnum, ordinality) ON true +JOIN pg_catalog.pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum +ORDER BY n.nspname, rel.relname, con.conname, k.ordinality; +""" + + +FK_EDGES_SQL = """ +WITH params AS ( + SELECT $1::text AS schema_name, COALESCE($2::boolean, false) AS include_system +), fk AS ( + SELECT con.* + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class child_rel ON child_rel.oid = con.conrelid + JOIN pg_catalog.pg_namespace child_ns ON child_ns.oid = child_rel.relnamespace + CROSS JOIN params p + WHERE + con.contype = 'f' + AND (p.schema_name IS NULL OR child_ns.nspname = p.schema_name) + AND ( + p.include_system + OR ( + child_ns.nspname NOT IN ('pg_catalog', 'information_schema') + AND child_ns.nspname NOT LIKE 'pg_toast%' + AND child_ns.nspname NOT LIKE 'pg_temp_%' + AND child_ns.nspname NOT LIKE 'pg_toast_temp_%' + ) + ) +) +SELECT + con.oid AS fk_constraint_oid, + con.conname AS fk_constraint_name, + + child_ns.nspname AS child_schema_name, + child_rel.oid AS child_relation_oid, + child_rel.relname AS child_relation_name, + + parent_ns.nspname AS parent_schema_name, + parent_rel.oid AS parent_relation_oid, + parent_rel.relname AS parent_relation_name, + + map.ordinality AS column_ordinal, + child_att.attname AS child_column_name, + parent_att.attname AS parent_column_name, + + con.confupdtype::text AS fk_on_update, + con.confdeltype::text AS fk_on_delete, + con.confmatchtype::text AS fk_match_type +FROM fk con +JOIN pg_catalog.pg_class child_rel ON child_rel.oid = con.conrelid +JOIN pg_catalog.pg_namespace child_ns ON child_ns.oid = child_rel.relnamespace +JOIN pg_catalog.pg_class parent_rel ON parent_rel.oid = con.confrelid +JOIN pg_catalog.pg_namespace parent_ns ON parent_ns.oid = parent_rel.relnamespace +JOIN LATERAL ( + SELECT ck.attnum AS child_attnum, fk.attnum AS parent_attnum, ck.ord AS ordinality + FROM unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) + JOIN unnest(con.confkey) WITH ORDINALITY AS fk(attnum, ord) USING (ord) +) AS map ON true +JOIN pg_catalog.pg_attribute child_att + ON child_att.attrelid = con.conrelid AND child_att.attnum = map.child_attnum +JOIN pg_catalog.pg_attribute parent_att + ON parent_att.attrelid = con.confrelid AND parent_att.attnum = map.parent_attnum +ORDER BY child_schema_name, child_relation_name, fk_constraint_name, column_ordinal; +""" diff --git a/backend/app/sanitize.py b/backend/app/sanitize.py index ff977f0f..9f66254b 100644 --- a/backend/app/sanitize.py +++ b/backend/app/sanitize.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from collections.abc import Mapping @@ -12,15 +13,20 @@ def sanitize_for_storage(obj: object) -> object: """Recursively sanitize strings for DB storage. - Removes NUL chars from *all* strings. - - Leaves bytes/bytearray unchanged (binary data should not be stored in text). + - Converts bytes/memoryview to safe text (best-effort UTF-8; fallback base64). """ if obj is None: return obj if isinstance(obj, str): return strip_nul(obj) + if isinstance(obj, memoryview): + obj = obj.tobytes() if isinstance(obj, (bytes, bytearray)): - return obj + try: + return strip_nul(bytes(obj).decode("utf-8")) + except Exception: # noqa: BLE001 + return base64.b64encode(bytes(obj)).decode("ascii") if isinstance(obj, list): return [sanitize_for_storage(v) for v in obj] if isinstance(obj, tuple): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 06c84e25..56b9fff3 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -14,6 +14,19 @@ class ProjectOut(BaseModel): project_name: str +class ProjectMemberAddIn(BaseModel): + member_subject: str = Field( + min_length=1, description="OIDC sub, or dev: in dev mode" + ) + project_role: str = Field(default="viewer") + + +class ProjectMemberOut(BaseModel): + user_account_uuid: uuid.UUID + member_subject: str + project_role: str + + class ConnectionCreateIn(BaseModel): conn_name: str = Field(min_length=1) dsn: str = Field( @@ -45,3 +58,9 @@ class SnapshotDetailOut(BaseModel): schema_filter: str | None error_message: str | None snapshot_json: dict | None + + +class MeOut(BaseModel): + user_account_uuid: uuid.UUID + subject: str + display_name: str | None diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 36d161cc..948dba82 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -2,8 +2,9 @@ requires = ["setuptools>=69", "wheel"] build-backend = "setuptools.build_meta" -[tool.setuptools] -packages = ["app"] +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] [project] name = "pg-erd-cloud-backend" @@ -29,6 +30,7 @@ dev = [ "pytest-asyncio>=0.23.0", "mypy>=1.10.0", "asyncpg-stubs>=0.31.0", + "types-python-jose>=3.5.0", "types-requests", ] diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 00000000..209a462a --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,53 @@ +services: + postgres: + image: postgres:16-alpine + container_name: erd_postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-erd} + POSTGRES_USER: ${POSTGRES_USER:-erd} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env} + ports: + - "127.0.0.1:${POSTGRES_PORT:-54321}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1 || exit 1"] + interval: 5s + timeout: 3s + retries: 20 + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: erd_backend + env_file: ./.env + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-erd}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-erd} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:${FRONTEND_PORT:-8080}} + depends_on: + postgres: + condition: service_healthy + expose: + - "8000" + command: > + sh -lc " + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8000 + " + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + container_name: erd_frontend + depends_on: + - backend + ports: + - "127.0.0.1:${FRONTEND_PORT:-8080}:80" + restart: unless-stopped + +volumes: + pgdata: diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 00000000..30f0d4db --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,15 @@ +FROM node:20-alpine AS build + +WORKDIR /app +COPY package.json package-lock.json /app/ +RUN npm ci + +COPY . /app +RUN npm run build + +FROM nginx:1.27-alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..d4cd64e5 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 80; + + # SPA: serve index.html for client-side routes + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + # Proxy API to backend + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 989d9c13..fdb98f5a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Background, Controls, ReactFlow, type NodeTypes } from '@xyflow/react' import { useEffect, useMemo, useState } from 'react' import { + getMe, createConnection, createProject, createSnapshot, @@ -14,6 +15,8 @@ import { snapshotToGraph } from './erd/convert' import type { Connection, Project, SnapshotDetail } from './types' export default function App() { + const [devUser, setDevUser] = useState(() => localStorage.getItem('devUser') || 'local') + const [me, setMe] = useState<{ subject: string; display_name: string | null } | null>(null) const [projects, setProjects] = useState([]) const [projectName, setProjectName] = useState('demo') const [selectedProjectId, setSelectedProjectId] = useState(null) @@ -31,13 +34,15 @@ export default function App() { const nodeTypes = useMemo(() => ({ tableNode: TableNode }), []) useEffect(() => { - listProjects() - .then((p) => { + localStorage.setItem('devUser', devUser) + Promise.all([getMe(), listProjects()]) + .then(([m, p]) => { + setMe({ subject: m.subject, display_name: m.display_name }) setProjects(p) - if (p[0]) setSelectedProjectId(p[0].project_space_uuid) + setSelectedProjectId(p[0]?.project_space_uuid || null) }) .catch((e) => setError(String(e))) - }, []) + }, [devUser]) useEffect(() => { if (!selectedProjectId) return @@ -97,6 +102,19 @@ export default function App() {