diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2bb35a984..9feb9446b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,11 +1,13 @@ -name: codeql +name: codeql-advanced (manual) on: - push: - branches: ["main"] - pull_request: - schedule: - - cron: "0 2 * * 1" + # NOTE: This repository has GitHub Code Scanning "default setup" enabled and + # it is controlled by organization administrators. Running an advanced + # CodeQL workflow alongside default setup can fail with: + # "CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled". + # + # To avoid merge-gate noise, keep this workflow manual only. + workflow_dispatch: permissions: contents: read diff --git a/.gitignore b/.gitignore index 3f799b852..c5a1c9399 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ __pycache__/ node_modules/ dist/ .vite/ +*.tsbuildinfo # opencode tooling artifacts registered_agents.json diff --git a/backend/alembic/versions/0002_auth_share.py b/backend/alembic/versions/0002_auth_share.py index bd7aaf38f..4904f1146 100644 --- a/backend/alembic/versions/0002_auth_share.py +++ b/backend/alembic/versions/0002_auth_share.py @@ -18,6 +18,23 @@ def upgrade() -> None: + # Backfill: ensure any existing project_space.created_by_user_uuid values exist in user_account + # before adding FK constraints. Previous versions could have inserted random UUIDs. + op.execute( + """ + INSERT INTO user_account (user_account_uuid, oidc_subject, display_name, created_at) + SELECT DISTINCT + p.created_by_user_uuid, + 'migrated:' || p.created_by_user_uuid::text, + 'migrated-' || p.created_by_user_uuid::text, + now() + FROM project_space p + LEFT JOIN user_account u ON u.user_account_uuid = p.created_by_user_uuid + WHERE p.created_by_user_uuid IS NOT NULL AND u.user_account_uuid IS NULL + ON CONFLICT DO NOTHING; + """ + ) + # Add FK constraints (MVP: best-effort for fresh DB) op.create_foreign_key( "fk_project_space__created_by_user", @@ -25,6 +42,7 @@ def upgrade() -> None: "user_account", ["created_by_user_uuid"], ["user_account_uuid"], + postgresql_not_valid=True, ) op.create_foreign_key( "fk_project_member__project_space", @@ -139,3 +157,11 @@ def downgrade() -> None: op.drop_constraint( "fk_project_space__created_by_user", "project_space", type_="foreignkey" ) + + # Remove backfilled user_account rows created during upgrade. + op.execute( + """ + DELETE FROM user_account + WHERE oidc_subject LIKE 'migrated:%'; + """ + ) diff --git a/backend/alembic/versions/0003_validate_project_space_fk.py b/backend/alembic/versions/0003_validate_project_space_fk.py new file mode 100644 index 000000000..d6831fffd --- /dev/null +++ b/backend/alembic/versions/0003_validate_project_space_fk.py @@ -0,0 +1,28 @@ +"""validate project_space created_by FK + +Revision ID: 0003_validate_project_space_fk +Revises: 0002_auth_share +Create Date: 2026-02-01 + +""" + +from __future__ import annotations + +from alembic import op + +revision = "0003_validate_project_space_fk" +down_revision = "0002_auth_share" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Validate the FK created as NOT VALID in 0002. + op.execute( + "ALTER TABLE project_space VALIDATE CONSTRAINT fk_project_space__created_by_user;" + ) + + +def downgrade() -> None: + # No-op: validation does not require rollback. + pass diff --git a/backend/app/api/connections.py b/backend/app/api/connections.py index 2851e5033..1e50b7e7d 100644 --- a/backend/app/api/connections.py +++ b/backend/app/api/connections.py @@ -25,6 +25,7 @@ async def list_connections( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> list[ConnectionOut]: + """List DB connections for a project.""" await require_project_member(session, project_space_uuid, user.user_account_uuid) rows = await session.execute( select(DbConnection) @@ -45,6 +46,7 @@ async def create_connection( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> ConnectionOut: + """Create a DB connection for a project (encrypt DSN at rest).""" await require_project_member(session, project_space_uuid, user.user_account_uuid) encrypted = encrypt_text(str(sanitize_for_storage(body.dsn))) c = DbConnection( diff --git a/backend/app/api/me.py b/backend/app/api/me.py index 841abbba1..482ec7717 100644 --- a/backend/app/api/me.py +++ b/backend/app/api/me.py @@ -11,6 +11,7 @@ @router.get("/me", response_model=MeOut) async def get_me(user: CurrentUser = Depends(get_current_user)) -> MeOut: + """Return the current user's identity.""" return MeOut( user_account_uuid=user.user_account_uuid, subject=user.subject, diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 71bfe657f..ba47279a8 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.auth import CurrentUser, get_current_user @@ -27,6 +28,7 @@ async def list_projects( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> list[ProjectOut]: + """List projects that the current user is a member of.""" rows = await session.execute( select(ProjectSpace) .join( @@ -49,6 +51,7 @@ async def create_project( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> ProjectOut: + """Create a new project and add the creator as the owner.""" p = ProjectSpace( project_space_uuid=uuid.uuid4(), project_name=str(sanitize_for_storage(body.project_name)), @@ -77,6 +80,7 @@ async def list_project_members( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> list[ProjectMemberOut]: + """List members of a project (MVP: any member can view).""" # owner/editor/viewer 모두 멤버 조회 가능(MVP) row = await session.execute( select(ProjectMember).where( @@ -115,6 +119,10 @@ async def add_project_member( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> ProjectMemberOut: + """Invite/add a project member (owner-only). + + Uses a Postgres upsert to make the operation idempotent and race-safe. + """ # MVP 권한: owner만 초대 가능 row = await session.execute( select(ProjectMember.project_role).where( @@ -145,17 +153,43 @@ async def add_project_member( 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), + # Idempotent invite: if already a member, update role instead of raising 500. + row3 = await session.execute( + select(ProjectMember.project_role).where( + ProjectMember.project_space_uuid == project_space_uuid, + ProjectMember.user_account_uuid == u.user_account_uuid, + ) ) - session.add(m) + existing_role = row3.scalar_one_or_none() + if existing_role == "owner": + # Avoid leaving project without an owner via this endpoint. + raise HTTPException( + status_code=400, detail="cannot change owner role via invite endpoint" + ) + + # Race-safe upsert on composite PK. + stmt = ( + insert(ProjectMember) + .values( + 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), + ) + .on_conflict_do_update( + index_elements=[ + ProjectMember.project_space_uuid, + ProjectMember.user_account_uuid, + ], + set_={"project_role": body.project_role}, + ) + .returning(ProjectMember.project_role) + ) + new_role = (await session.execute(stmt)).scalar_one() await session.commit() return ProjectMemberOut( user_account_uuid=u.user_account_uuid, member_subject=u.oidc_subject, - project_role=m.project_role, + project_role=str(new_role), ) diff --git a/backend/app/api/share.py b/backend/app/api/share.py index b0c6b31fa..761258967 100644 --- a/backend/app/api/share.py +++ b/backend/app/api/share.py @@ -23,6 +23,7 @@ async def create_share_link( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> dict: + """Create a share link for a project (owner-only).""" # owner only row = await session.execute( select(ProjectMember.project_role).where( @@ -55,6 +56,7 @@ async def get_share_link_info( share_link_uuid: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> dict: + """Return share link metadata and recent snapshots.""" link = await session.get(ShareLink, share_link_uuid) if link is None: raise HTTPException(status_code=404, detail="share link not found") @@ -91,6 +93,7 @@ async def get_shared_snapshot( schema_snapshot_uuid: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> dict: + """Return a snapshot via a share link (no auth).""" link = await session.get(ShareLink, share_link_uuid) if link is None: raise HTTPException(status_code=404, detail="share link not found") @@ -122,6 +125,7 @@ async def export_shared_snapshot_sql( schema_snapshot_uuid: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> str: + """Export a shared snapshot as SQL via a share link.""" link = await session.get(ShareLink, share_link_uuid) if link is None: raise HTTPException(status_code=404, detail="share link not found") diff --git a/backend/app/api/snapshots.py b/backend/app/api/snapshots.py index 8d5cd6173..d60e3da3d 100644 --- a/backend/app/api/snapshots.py +++ b/backend/app/api/snapshots.py @@ -26,6 +26,7 @@ async def create_snapshot( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> SnapshotOut: + """Create a schema snapshot job for a project connection.""" await require_project_member(session, project_space_uuid, user.user_account_uuid) # Ensure connection belongs to this project @@ -78,6 +79,7 @@ async def get_snapshot( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> SnapshotDetailOut: + """Get a snapshot's status and (if present) captured JSON.""" snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) if snap is None: return SnapshotDetailOut( @@ -106,6 +108,7 @@ async def export_snapshot_sql( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> str: + """Export a snapshot as PostgreSQL DDL (best-effort).""" snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) if snap is None: return "-- snapshot not found\n" @@ -124,6 +127,7 @@ async def list_snapshots( user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> list[SnapshotOut]: + """List snapshots for a project.""" await require_project_member(session, project_space_uuid, user.user_account_uuid) rows = await session.execute( select(SchemaSnapshot) diff --git a/backend/app/auth.py b/backend/app/auth.py index 4857dd63c..bf58c4a7c 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -18,6 +18,8 @@ @dataclass(frozen=True) class CurrentUser: + """Authenticated user identity used by API handlers.""" + user_account_uuid: uuid.UUID subject: str display_name: str | None @@ -29,6 +31,7 @@ class CurrentUser: async def _get_oidc_config() -> dict: + """Fetch and cache the OIDC discovery document.""" if not settings.oidc_issuer: raise RuntimeError("OIDC is disabled") @@ -50,6 +53,7 @@ async def _get_oidc_config() -> dict: async def _get_jwks() -> dict: + """Fetch and cache the OIDC JWKS (signing keys).""" config = await _get_oidc_config() jwks_uri = config.get("jwks_uri") if not isinstance(jwks_uri, str): @@ -70,6 +74,7 @@ async def _get_jwks() -> dict: def _pick_jwk(jwks: dict, kid: str | None) -> dict | None: + """Pick a JWK from a JWKS set by kid (or first if kid is None).""" keys = jwks.get("keys") if not isinstance(keys, list): return None @@ -82,6 +87,12 @@ def _pick_jwk(jwks: dict, kid: str | None) -> dict | None: async def _get_subject_from_request(request: Request) -> tuple[str, str | None]: + """Extract (subject, display_name) from a request. + + Uses OIDC bearer tokens when configured; otherwise falls back to a dev + header for local development. + """ + # OIDC mode (Casdoor etc.) if settings.oidc_issuer: auth = request.headers.get("Authorization", "") @@ -125,6 +136,7 @@ async def _get_subject_from_request(request: Request) -> tuple[str, str | None]: async def _ensure_user( session: AsyncSession, subject: str, display_name: str | None ) -> CurrentUser: + """Get or create a UserAccount for the given OIDC subject.""" row = await session.execute( select(UserAccount).where(UserAccount.oidc_subject == subject) ) @@ -155,6 +167,7 @@ async def get_current_user( request: Request, session: AsyncSession = Depends(get_session), ) -> CurrentUser: + """FastAPI dependency that authenticates and returns the current user.""" 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 bfe7067a8..e6dd3eee8 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -12,6 +12,12 @@ def get_sync_database_url() -> str: + """Return a sync database URL for Alembic. + + Alembic uses a synchronous engine; convert an async SQLAlchemy URL to a + compatible sync URL. + """ + # Alembic uses sync engine; convert async URL. url = settings.database_url if url.startswith("postgresql+asyncpg://"): @@ -25,5 +31,6 @@ def get_sync_database_url() -> str: async def get_session() -> AsyncGenerator[AsyncSession, None]: + """FastAPI dependency that yields an AsyncSession.""" async with SessionLocal() as session: yield session diff --git a/backend/app/ddl/export.py b/backend/app/ddl/export.py index 98805fd0c..13d2d4160 100644 --- a/backend/app/ddl/export.py +++ b/backend/app/ddl/export.py @@ -4,11 +4,14 @@ def _q(ident: str) -> str: + """Quote a PostgreSQL identifier.""" + # Quote identifier with double-quotes, escaping internal quotes. return '"' + ident.replace('"', '""') + '"' def _qname(schema: str, name: str) -> str: + """Quote a schema-qualified name.""" return f"{_q(schema)}.{_q(name)}" diff --git a/backend/app/jobs/snapshot_job.py b/backend/app/jobs/snapshot_job.py index fce5bb483..11d805558 100644 --- a/backend/app/jobs/snapshot_job.py +++ b/backend/app/jobs/snapshot_job.py @@ -15,6 +15,7 @@ async def handle_snapshot_job( session_factory: Callable[[], AsyncSession], job: JobQueue, ) -> None: + """Run a schema snapshot job and persist the resulting JSON.""" payload = job.payload_json snapshot_id = uuid.UUID(payload["schema_snapshot_uuid"]) async with session_factory() as session: diff --git a/backend/app/jobs/worker.py b/backend/app/jobs/worker.py index e65b63f24..f9a14f313 100644 --- a/backend/app/jobs/worker.py +++ b/backend/app/jobs/worker.py @@ -15,6 +15,8 @@ async def claim_one_job(session: AsyncSession) -> JobQueue | None: + """Claim one queued job using FOR UPDATE SKIP LOCKED.""" + # Transaction: claim a queued job using SKIP LOCKED (non-blocking) # We use raw SQL to leverage FOR UPDATE SKIP LOCKED reliably. row = await session.execute( @@ -47,6 +49,7 @@ async def run_worker_forever( handlers: Mapping[str, Handler], poll_interval_s: float = 1.0, ) -> None: + """Continuously poll the queue and dispatch jobs to handlers.""" while True: async with session_factory() as session: async with session.begin(): diff --git a/backend/app/main.py b/backend/app/main.py index c9f643300..e32ef0b44 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -16,7 +18,27 @@ from app.settings import settings -app = FastAPI(title="pg-erd-cloud backend") +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + """Run application startup/shutdown hooks. + + Starts a background job worker on startup and ensures it is cancelled and + awaited on shutdown. + """ + + handlers = {"snapshot": handle_snapshot_job} + task = asyncio.create_task(run_worker_forever(SessionLocal, handlers)) + try: + yield + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +app = FastAPI(title="pg-erd-cloud backend", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -29,6 +51,7 @@ @app.get("/healthz") async def healthz() -> dict: + """Simple health-check endpoint.""" return {"ok": True} @@ -37,10 +60,3 @@ async def healthz() -> dict: app.include_router(snapshots_router) app.include_router(me_router) app.include_router(share_router) - - -@app.on_event("startup") -async def start_worker() -> None: - handlers = {"snapshot": handle_snapshot_job} - # Background worker loop. For production, run as separate process. - asyncio.create_task(run_worker_forever(SessionLocal, handlers)) diff --git a/backend/app/models.py b/backend/app/models.py index 527c51906..7cb04c84c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -9,14 +9,19 @@ class Base(DeclarativeBase): + """SQLAlchemy declarative base for ORM models.""" + pass def utcnow() -> dt.datetime: + """Return the current UTC timestamp (timezone-aware).""" return dt.datetime.now(dt.timezone.utc) class UserAccount(Base): + """User record keyed by a UUID and identified by OIDC subject.""" + __tablename__ = "user_account" user_account_uuid: Mapped[uuid.UUID] = mapped_column( @@ -30,6 +35,8 @@ class UserAccount(Base): class ProjectSpace(Base): + """Project container that groups connections and snapshots.""" + __tablename__ = "project_space" project_space_uuid: Mapped[uuid.UUID] = mapped_column( @@ -45,16 +52,18 @@ class ProjectSpace(Base): class ProjectMember(Base): + """Membership mapping between users and projects with a role.""" + __tablename__ = "project_member" project_space_uuid: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("project_space.project_space_uuid"), + ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), primary_key=True, ) user_account_uuid: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("user_account.user_account_uuid"), + ForeignKey("user_account.user_account_uuid", ondelete="CASCADE"), primary_key=True, ) project_role: Mapped[str] = mapped_column(Text()) @@ -68,13 +77,17 @@ class ProjectMember(Base): class DbConnection(Base): + """Encrypted PostgreSQL DSN belonging to a project.""" + __tablename__ = "db_connection" db_connection_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 + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + index=True, ) conn_name: Mapped[str] = mapped_column(Text()) dsn_ciphertext: Mapped[bytes] = mapped_column(LargeBinary()) @@ -88,16 +101,21 @@ class DbConnection(Base): class SchemaSnapshot(Base): + """Snapshot job record for a database introspection run.""" + __tablename__ = "schema_snapshot" schema_snapshot_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 + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + index=True, ) db_connection_uuid: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("db_connection.db_connection_uuid") + UUID(as_uuid=True), + ForeignKey("db_connection.db_connection_uuid", ondelete="CASCADE"), ) status: Mapped[str] = mapped_column(Text()) schema_filter: Mapped[str | None] = mapped_column(Text(), nullable=True) @@ -114,11 +132,13 @@ class SchemaSnapshot(Base): class SchemaSnapshotData(Base): + """Captured schema snapshot JSON payload.""" + __tablename__ = "schema_snapshot_data" schema_snapshot_uuid: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("schema_snapshot.schema_snapshot_uuid"), + ForeignKey("schema_snapshot.schema_snapshot_uuid", ondelete="CASCADE"), primary_key=True, ) snapshot_json: Mapped[dict] = mapped_column(JSONB()) @@ -128,6 +148,8 @@ class SchemaSnapshotData(Base): class JobQueue(Base): + """Lightweight DB-backed job queue (MVP).""" + __tablename__ = "job_queue" job_queue_uuid: Mapped[uuid.UUID] = mapped_column( @@ -155,16 +177,21 @@ class JobQueue(Base): class ShareLink(Base): + """Public share link granting read access to a project's snapshots.""" + __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 + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + index=True, ) created_by_user_uuid: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("user_account.user_account_uuid") + UUID(as_uuid=True), + ForeignKey("user_account.user_account_uuid", ondelete="CASCADE"), ) permission_kind: Mapped[str] = mapped_column(Text()) # viewer/editor (MVP: viewer) expires_at: Mapped[dt.datetime | None] = mapped_column( diff --git a/backend/app/permissions.py b/backend/app/permissions.py index 3e274cd3d..83c8883ca 100644 --- a/backend/app/permissions.py +++ b/backend/app/permissions.py @@ -14,6 +14,7 @@ async def require_project_member( project_space_uuid: uuid.UUID, user_account_uuid: uuid.UUID, ) -> str: + """Ensure the user is a project member and return their role.""" row = await session.execute( select(ProjectMember.project_role).where( ProjectMember.project_space_uuid == project_space_uuid, diff --git a/backend/app/pg_introspect/introspect.py b/backend/app/pg_introspect/introspect.py index 9f6b8525e..bf2d12464 100644 --- a/backend/app/pg_introspect/introspect.py +++ b/backend/app/pg_introspect/introspect.py @@ -9,6 +9,8 @@ async def introspect_postgres(dsn: str, schema_filter: str | None) -> dict: + """Introspect a PostgreSQL database and return a snapshot JSON.""" + # Note: avoid logging DSN. conn = await asyncpg.connect(dsn, timeout=10) try: diff --git a/backend/app/sanitize.py b/backend/app/sanitize.py index 9f66254b6..3c009b63c 100644 --- a/backend/app/sanitize.py +++ b/backend/app/sanitize.py @@ -5,6 +5,8 @@ def strip_nul(value: str) -> str: + """Remove NUL (0x00) characters from a string.""" + # PostgreSQL text/json rejects NUL(0x00). Remove it. return value.replace("\x00", "") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 56b9fff34..e57782946 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,33 +1,45 @@ from __future__ import annotations import uuid +from typing import Literal from pydantic import BaseModel, Field class ProjectCreateIn(BaseModel): + """Request body for creating a project.""" + project_name: str = Field(min_length=1) class ProjectOut(BaseModel): + """Project summary returned by API.""" + project_space_uuid: uuid.UUID project_name: str class ProjectMemberAddIn(BaseModel): + """Request body for inviting/adding a project member.""" + member_subject: str = Field( min_length=1, description="OIDC sub, or dev: in dev mode" ) - project_role: str = Field(default="viewer") + # MVP: restrict to non-owner roles. Owner is assigned at project creation. + project_role: Literal["viewer", "editor"] = Field(default="viewer") class ProjectMemberOut(BaseModel): + """Project member representation returned by API.""" + user_account_uuid: uuid.UUID member_subject: str project_role: str class ConnectionCreateIn(BaseModel): + """Request body for creating a DB connection.""" + conn_name: str = Field(min_length=1) dsn: str = Field( min_length=1, description="PostgreSQL connection string. Not logged." @@ -35,11 +47,15 @@ class ConnectionCreateIn(BaseModel): class ConnectionOut(BaseModel): + """Connection summary returned by API.""" + db_connection_uuid: uuid.UUID conn_name: str class SnapshotCreateIn(BaseModel): + """Request body for creating a schema snapshot.""" + db_connection_uuid: uuid.UUID schema_filter: str | None = Field( default=None, description="If set, only introspect this schema" @@ -47,12 +63,16 @@ class SnapshotCreateIn(BaseModel): class SnapshotOut(BaseModel): + """Snapshot summary returned by API.""" + schema_snapshot_uuid: uuid.UUID status: str schema_filter: str | None class SnapshotDetailOut(BaseModel): + """Snapshot detail returned by API.""" + schema_snapshot_uuid: uuid.UUID status: str schema_filter: str | None @@ -61,6 +81,8 @@ class SnapshotDetailOut(BaseModel): class MeOut(BaseModel): + """Current user payload returned by /me.""" + user_account_uuid: uuid.UUID subject: str display_name: str | None diff --git a/backend/app/security.py b/backend/app/security.py index 2445fff23..e81f2f599 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -9,6 +9,11 @@ def _derive_key() -> bytes: + """Derive a stable 32-byte key from APP_SECRET (MVP). + + In production, prefer KMS/HKDF with rotation. + """ + # MVP key derivation: stable 32-bytes from APP_SECRET. # In production prefer KMS/HKDF with rotation. return hashlib.sha256(settings.app_secret.encode("utf-8")).digest() @@ -16,11 +21,14 @@ def _derive_key() -> bytes: @dataclass(frozen=True) class EncryptedBlob: + """Encrypted bytes plus nonce for AES-GCM.""" + ciphertext: bytes nonce: bytes def encrypt_text(plaintext: str) -> EncryptedBlob: + """Encrypt a UTF-8 string using AES-256-GCM.""" key = _derive_key() aes = AESGCM(key) import os @@ -31,6 +39,7 @@ def encrypt_text(plaintext: str) -> EncryptedBlob: def decrypt_text(ciphertext: bytes, nonce: bytes) -> str: + """Decrypt a blob produced by encrypt_text.""" key = _derive_key() aes = AESGCM(key) plaintext = aes.decrypt(nonce, ciphertext, None) @@ -38,6 +47,8 @@ def decrypt_text(ciphertext: bytes, nonce: bytes) -> str: def redact_dsn(dsn: str) -> str: + """Redact credentials from a DSN for safe logging.""" + # Avoid leaking credentials in logs. # Best-effort: remove password in typical URI formats. # If unsure, return a constant to avoid partial leaks. diff --git a/backend/app/settings.py b/backend/app/settings.py index 30ecb576f..578e33826 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -4,6 +4,8 @@ class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + model_config = SettingsConfigDict(env_file=".env", extra="ignore") database_url: str diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 948dba82a..ad2385d11 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "alembic>=1.13.0", "cryptography>=42.0.0", "httpx>=0.27.0", - "python-jose[cryptography]>=3.3.0", + "python-jose[cryptography]>=3.4.0", ] [project.optional-dependencies] diff --git a/frontend/src/erd/TableNode.tsx b/frontend/src/erd/TableNode.tsx index c664f212e..2e1456b3c 100644 --- a/frontend/src/erd/TableNode.tsx +++ b/frontend/src/erd/TableNode.tsx @@ -1,4 +1,7 @@ -import type { NodeProps } from '@xyflow/react' +import { memo } from 'react' +import type { Node, NodeProps } from '@xyflow/react' + +const MAX_RENDERED_COLUMNS = 25 type Column = { column_name: string @@ -7,8 +10,16 @@ type Column = { is_pk?: boolean } -export default function TableNode(props: NodeProps) { - const data = props.data as { title: string; columns: Column[]; badges?: { pk?: boolean; fk?: boolean } } +type TableNodeData = { + title: string + columns: Column[] + badges?: { pk?: boolean; fk?: boolean } +} + +type TableNodeNode = Node + +function TableNode(props: NodeProps) { + const { data } = props return (
@@ -19,7 +30,7 @@ export default function TableNode(props: NodeProps) {
- {data.columns.slice(0, 25).map((c) => ( + {data.columns.slice(0, MAX_RENDERED_COLUMNS).map((c) => (
{c.column_name} {c.data_type} @@ -27,8 +38,39 @@ export default function TableNode(props: NodeProps) { {c.is_not_null ? NOT NULL : null}
))} - {data.columns.length > 25 ?
… {data.columns.length - 25} more
: null} + {data.columns.length > MAX_RENDERED_COLUMNS ? ( +
… {data.columns.length - MAX_RENDERED_COLUMNS} more
+ ) : null}
) } + +function isSameRenderedColumns(prevCols: Column[], nextCols: Column[]): boolean { + if (prevCols.length !== nextCols.length) return false + + // The component only renders the first MAX_RENDERED_COLUMNS and the "… N more" count. + const limit = Math.min(MAX_RENDERED_COLUMNS, prevCols.length, nextCols.length) + for (let i = 0; i < limit; i += 1) { + const a = prevCols[i] + const b = nextCols[i] + if (a.column_name !== b.column_name) return false + if (a.data_type !== b.data_type) return false + if (a.is_not_null !== b.is_not_null) return false + if (a.is_pk !== b.is_pk) return false + } + return true +} + +export default memo(TableNode, (prev, next) => { + // React Flow typically provides new node objects when data changes. + // This comparator is a conservative safeguard for the most relevant fields. + // Note: if upstream mutates `columns` in-place between renders, no memo comparator can + // reliably detect it. Prefer immutable updates from the graph producer. + return ( + prev.data.title === next.data.title && + isSameRenderedColumns(prev.data.columns, next.data.columns) && + prev.data.badges?.pk === next.data.badges?.pk && + prev.data.badges?.fk === next.data.badges?.fk + ) +})