Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ __pycache__/
node_modules/
dist/
.vite/
*.tsbuildinfo

# opencode tooling artifacts
registered_agents.json
Expand Down
26 changes: 26 additions & 0 deletions backend/alembic/versions/0002_auth_share.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,31 @@


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;
"""
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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"],
postgresql_not_valid=True,
)
op.create_foreign_key(
"fk_project_member__project_space",
Expand Down Expand Up @@ -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:%';
"""
)
28 changes: 28 additions & 0 deletions backend/alembic/versions/0003_validate_project_space_fk.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions backend/app/api/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions backend/app/api/me.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 41 additions & 7 deletions backend/app/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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)),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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),
)
4 changes: 4 additions & 0 deletions backend/app/api/share.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions backend/app/api/snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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", "")
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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)
7 changes: 7 additions & 0 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://"):
Expand All @@ -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
3 changes: 3 additions & 0 deletions backend/app/ddl/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"


Expand Down
1 change: 1 addition & 0 deletions backend/app/jobs/snapshot_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading