Skip to content
36 changes: 27 additions & 9 deletions backend/alembic/versions/0001_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@
def upgrade() -> None:
op.create_table(
"user_account",
sa.Column("user_account_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column(
"user_account_uuid", sa.Uuid(), primary_key=True, nullable=False
),
sa.Column("oidc_subject", sa.Text(), nullable=False, unique=True),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)

op.create_table(
"project_space",
sa.Column("project_space_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column(
"project_space_uuid", sa.Uuid(), primary_key=True, nullable=False
),
sa.Column("project_name", sa.Text(), nullable=False),
sa.Column("created_by_user_uuid", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
Expand All @@ -55,7 +59,9 @@ def upgrade() -> None:

op.create_table(
"db_connection",
sa.Column("db_connection_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column(
"db_connection_uuid", sa.Uuid(), primary_key=True, nullable=False
),
sa.Column("project_space_uuid", sa.Uuid(), nullable=False),
sa.Column("conn_name", sa.Text(), nullable=False),
sa.Column("dsn_ciphertext", sa.LargeBinary(), nullable=False),
Expand All @@ -71,7 +77,9 @@ def upgrade() -> None:

op.create_table(
"schema_snapshot",
sa.Column("schema_snapshot_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column(
"schema_snapshot_uuid", sa.Uuid(), primary_key=True, nullable=False
),
sa.Column("project_space_uuid", sa.Uuid(), nullable=False),
sa.Column("db_connection_uuid", sa.Uuid(), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
Expand All @@ -89,14 +97,18 @@ def upgrade() -> None:

op.create_table(
"schema_snapshot_data",
sa.Column("schema_snapshot_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column(
"schema_snapshot_uuid", sa.Uuid(), primary_key=True, nullable=False
),
sa.Column("snapshot_json", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)

op.create_table(
"job_queue",
sa.Column("job_queue_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column(
"job_queue_uuid", sa.Uuid(), primary_key=True, nullable=False
),
sa.Column("job_type", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("payload_json", sa.JSON(), nullable=False),
Expand All @@ -122,13 +134,19 @@ def downgrade() -> None:
)
op.drop_table("schema_snapshot")

op.drop_index("ix_db_connection__project_space_uuid", table_name="db_connection")
op.drop_index(
"ix_db_connection__project_space_uuid", table_name="db_connection"
)
op.drop_table("db_connection")

op.drop_index("ix_project_member__user_account_uuid", table_name="project_member")
op.drop_index(
"ix_project_member__user_account_uuid", table_name="project_member"
)
op.drop_table("project_member")

op.drop_index("ix_project_space__created_by_user_uuid", table_name="project_space")
op.drop_index(
"ix_project_space__created_by_user_uuid", table_name="project_space"
)
op.drop_table("project_space")

op.drop_table("user_account")
63 changes: 41 additions & 22 deletions backend/alembic/versions/0002_auth_share.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,31 @@
depends_on = None


_MIGRATED_OIDC_SUBJECT_PREFIX = "migrated:0002_auth_share:"
_MIGRATED_DISPLAY_NAME_PREFIX = "migrated-0002_auth_share-"


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;
"""
conn = op.get_bind()
conn.execute(
sa.text("""
INSERT INTO user_account (user_account_uuid, oidc_subject, display_name, created_at)
SELECT DISTINCT
p.created_by_user_uuid,
:oidc_prefix || p.created_by_user_uuid::text,
:display_prefix || 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;
"""),
{
"oidc_prefix": _MIGRATED_OIDC_SUBJECT_PREFIX,
"display_prefix": _MIGRATED_DISPLAY_NAME_PREFIX,
},
)

# Add FK constraints (MVP: best-effort for fresh DB)
Expand Down Expand Up @@ -96,7 +105,9 @@ def upgrade() -> None:
# Share links
op.create_table(
"share_link",
sa.Column("share_link_uuid", sa.Uuid(), primary_key=True, nullable=False),
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),
Expand Down Expand Up @@ -130,7 +141,9 @@ 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_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")

Expand All @@ -140,10 +153,14 @@ def downgrade() -> None:
type_="foreignkey",
)
op.drop_constraint(
"fk_schema_snapshot__db_connection", "schema_snapshot", type_="foreignkey"
"fk_schema_snapshot__db_connection",
"schema_snapshot",
type_="foreignkey",
)
op.drop_constraint(
"fk_schema_snapshot__project_space", "schema_snapshot", type_="foreignkey"
"fk_schema_snapshot__project_space",
"schema_snapshot",
type_="foreignkey",
)
op.drop_constraint(
"fk_db_connection__project_space", "db_connection", type_="foreignkey"
Expand All @@ -159,9 +176,11 @@ def downgrade() -> None:
)

# Remove backfilled user_account rows created during upgrade.
op.execute(
"""
DELETE FROM user_account
WHERE oidc_subject LIKE 'migrated:%';
"""
conn = op.get_bind()
conn.execute(
sa.text("""
DELETE FROM user_account
WHERE oidc_subject LIKE :oidc_prefix_like;
"""),
{"oidc_prefix_like": f"{_MIGRATED_OIDC_SUBJECT_PREFIX}%"},
)
25 changes: 17 additions & 8 deletions backend/app/api/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,38 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import CurrentUser, get_current_user
from app.db import get_session
from app.db import get_read_session, 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


router = APIRouter(prefix="/api/connections", tags=["connections"])


@router.get("/by-project/{project_space_uuid}", response_model=list[ConnectionOut])
@router.get(
"/by-project/{project_space_uuid}", response_model=list[ConnectionOut]
)
async def list_connections(
project_space_uuid: uuid.UUID,
user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
session: AsyncSession = Depends(get_read_session),
) -> list[ConnectionOut]:
"""List DB connections for a project."""
await require_project_member(session, project_space_uuid, user.user_account_uuid)
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)
.order_by(DbConnection.created_at.desc())
)
cons = rows.scalars().all()
return [
ConnectionOut(db_connection_uuid=c.db_connection_uuid, conn_name=c.conn_name)
ConnectionOut(
db_connection_uuid=c.db_connection_uuid, conn_name=c.conn_name
)
for c in cons
]

Expand All @@ -47,7 +52,9 @@ async def create_connection(
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)
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(),
Expand All @@ -60,4 +67,6 @@ async def create_connection(
)
session.add(c)
await session.commit()
return ConnectionOut(db_connection_uuid=c.db_connection_uuid, conn_name=c.conn_name)
return ConnectionOut(
db_connection_uuid=c.db_connection_uuid, conn_name=c.conn_name
)
1 change: 0 additions & 1 deletion backend/app/api/me.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from app.auth import CurrentUser, get_current_user
from app.schemas import MeOut


router = APIRouter(prefix="/api", tags=["me"])


Expand Down
18 changes: 11 additions & 7 deletions backend/app/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import CurrentUser, get_current_user
from app.db import get_session
from app.db import get_read_session, get_session
from app.models import ProjectMember, ProjectSpace, UserAccount
from app.schemas import (
ProjectCreateIn,
Expand All @@ -19,14 +19,13 @@
)
from app.sanitize import sanitize_for_storage


router = APIRouter(prefix="/api/projects", tags=["projects"])


@router.get("", response_model=list[ProjectOut])
async def list_projects(
user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
session: AsyncSession = Depends(get_read_session),
) -> list[ProjectOut]:
"""List projects that the current user is a member of."""
rows = await session.execute(
Expand All @@ -40,7 +39,9 @@ async def list_projects(
)
projects = rows.scalars().all()
return [
ProjectOut(project_space_uuid=p.project_space_uuid, project_name=p.project_name)
ProjectOut(
project_space_uuid=p.project_space_uuid, project_name=p.project_name
)
for p in projects
]

Expand Down Expand Up @@ -74,11 +75,13 @@ async def create_project(
)


@router.get("/{project_space_uuid}/members", response_model=list[ProjectMemberOut])
@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),
session: AsyncSession = Depends(get_read_session),
) -> list[ProjectMemberOut]:
"""List members of a project (MVP: any member can view)."""
# owner/editor/viewer 모두 멤버 조회 가능(MVP)
Expand Down Expand Up @@ -164,7 +167,8 @@ async def add_project_member(
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"
status_code=400,
detail="cannot change owner role via invite endpoint",
)

# Race-safe upsert on composite PK.
Expand Down
16 changes: 10 additions & 6 deletions backend/app/api/share.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import CurrentUser, get_current_user
from app.db import get_session
from app.db import get_read_session, get_session
from app.ddl.export import snapshot_json_to_sql
from app.models import ProjectMember, SchemaSnapshot, SchemaSnapshotData, ShareLink

from app.models import (
ProjectMember,
SchemaSnapshot,
SchemaSnapshotData,
ShareLink,
)

router = APIRouter(prefix="/api", tags=["share"])

Expand Down Expand Up @@ -54,7 +58,7 @@ async def create_share_link(
@router.get("/share/{share_link_uuid}")
async def get_share_link_info(
share_link_uuid: uuid.UUID,
session: AsyncSession = Depends(get_session),
session: AsyncSession = Depends(get_read_session),
) -> dict:
"""Return share link metadata and recent snapshots."""
link = await session.get(ShareLink, share_link_uuid)
Expand Down Expand Up @@ -91,7 +95,7 @@ async def get_share_link_info(
async def get_shared_snapshot(
share_link_uuid: uuid.UUID,
schema_snapshot_uuid: uuid.UUID,
session: AsyncSession = Depends(get_session),
session: AsyncSession = Depends(get_read_session),
) -> dict:
"""Return a snapshot via a share link (no auth)."""
link = await session.get(ShareLink, share_link_uuid)
Expand Down Expand Up @@ -123,7 +127,7 @@ async def get_shared_snapshot(
async def export_shared_snapshot_sql(
share_link_uuid: uuid.UUID,
schema_snapshot_uuid: uuid.UUID,
session: AsyncSession = Depends(get_session),
session: AsyncSession = Depends(get_read_session),
) -> str:
"""Export a shared snapshot as SQL via a share link."""
link = await session.get(ShareLink, share_link_uuid)
Expand Down
Loading
Loading