Skip to content
Open
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
4 changes: 2 additions & 2 deletions backend/alembic/versions/0003_revoked_token.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""revoked_token

Revision ID: 0003
Revises: 0002
Revises: 0002_auth_share
Create Date: 2026-06-22 10:00:00.000000

"""
Expand All @@ -12,7 +12,7 @@

# revision identifiers, used by Alembic.
revision = "0003"
down_revision = "0002"
down_revision = "0002_auth_share"
branch_labels = None
depends_on = None

Expand Down
28 changes: 28 additions & 0 deletions backend/alembic/versions/0004_merge_heads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""merge revoked_token and validate_project_space_fk into a single head

Two migrations branched independently from 0002_auth_share:
- 0003 (revoked_token): creates the revoked_token table
- 0003_validate_project_space_fk: validates a project_space FK
They are unrelated and safe to apply in any order, so this is a no-op merge
that unifies the graph to a single head (``alembic upgrade head`` was ambiguous
with two heads).

Revision ID: 0004_merge_heads
Revises: 0003, 0003_validate_project_space_fk
Create Date: 2026-07-05 00:00:00.000000

"""

# revision identifiers, used by Alembic.
revision = "0004_merge_heads"
down_revision = ("0003", "0003_validate_project_space_fk")
branch_labels = None
depends_on = None


def upgrade() -> None:
"""No schema change; this revision only merges two heads."""


def downgrade() -> None:
"""No schema change; splitting back into two heads requires no work."""
48 changes: 48 additions & 0 deletions backend/alembic/versions/0005_diagram_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""diagram_view: saved ERD canvas views

Revision ID: 0005_diagram_view
Revises: 0004_merge_heads
Create Date: 2026-07-05

"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision = "0005_diagram_view"
down_revision = "0004_merge_heads"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"diagram_view",
sa.Column("diagram_view_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column("project_space_uuid", sa.Uuid(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("layout_json", sa.JSON(), nullable=False),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["project_space_uuid"],
["project_space.project_space_uuid"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("diagram_view_uuid"),
)
op.create_index(
"ix_diagram_view__project_space_uuid",
"diagram_view",
["project_space_uuid"],
)


def downgrade() -> None:
op.drop_index(
"ix_diagram_view__project_space_uuid", table_name="diagram_view"
)
op.drop_table("diagram_view")
55 changes: 55 additions & 0 deletions backend/alembic/versions/0006_table_annotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""table_annotation: per-project notes attached to tables by name

Revision ID: 0006_table_annotation
Revises: 0005_diagram_view
Create Date: 2026-07-05

"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision = "0006_table_annotation"
down_revision = "0005_diagram_view"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"table_annotation",
sa.Column("table_annotation_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column("project_space_uuid", sa.Uuid(), nullable=False),
sa.Column("schema_name", sa.Text(), nullable=False),
sa.Column("relation_name", sa.Text(), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["project_space_uuid"],
["project_space.project_space_uuid"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("table_annotation_uuid"),
sa.UniqueConstraint(
"project_space_uuid",
"schema_name",
"relation_name",
name="uq_table_annotation__project_table",
),
)
op.create_index(
"ix_table_annotation__project_space_uuid",
"table_annotation",
["project_space_uuid"],
)


def downgrade() -> None:
op.drop_index(
"ix_table_annotation__project_space_uuid", table_name="table_annotation"
)
op.drop_table("table_annotation")
139 changes: 139 additions & 0 deletions backend/app/api/annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from __future__ import annotations

import datetime as dt
import uuid

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_read_session, get_session
from app.models import TableAnnotation
from app.permissions import require_project_member
from app.schemas import TableAnnotationOut, TableAnnotationUpsertIn

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


def _to_out(ann: TableAnnotation) -> TableAnnotationOut:
return TableAnnotationOut(
table_annotation_uuid=ann.table_annotation_uuid,
schema_name=ann.schema_name,
relation_name=ann.relation_name,
body=ann.body,
created_at=ann.created_at,
updated_at=ann.updated_at,
)


async def _get_authorized_annotation(
session: AsyncSession,
table_annotation_uuid: uuid.UUID,
user: CurrentUser,
minimum_role: str | None = None,
) -> TableAnnotation | None:
"""Fetch an annotation only after project membership has been checked.

Returns ``None`` for both missing and unauthorized so callers can respond
with a uniform 404 (no existence enumeration / IDOR).
"""
project_space_uuid = await session.scalar(
select(TableAnnotation.project_space_uuid).where(
TableAnnotation.table_annotation_uuid == table_annotation_uuid
)
)
if project_space_uuid is None:
return None
try:
await require_project_member(
session,
project_space_uuid,
user.user_account_uuid,
minimum_role=minimum_role,
)
except HTTPException as exc:
if exc.status_code == 403:
return None
raise
return await session.get(TableAnnotation, table_annotation_uuid)


@router.get(
"/by-project/{project_space_uuid}", response_model=list[TableAnnotationOut]
)
async def list_annotations(
project_space_uuid: uuid.UUID,
user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_read_session),
) -> list[TableAnnotationOut]:
"""List table annotations for a project."""
await require_project_member(session, project_space_uuid, user.user_account_uuid)
rows = await session.execute(
select(TableAnnotation)
.where(TableAnnotation.project_space_uuid == project_space_uuid)
.order_by(TableAnnotation.schema_name, TableAnnotation.relation_name)
)
return [_to_out(a) for a in rows.scalars().all()]


@router.put(
"/by-project/{project_space_uuid}", response_model=TableAnnotationOut
)
async def upsert_annotation(
project_space_uuid: uuid.UUID,
body: TableAnnotationUpsertIn,
user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> TableAnnotationOut:
"""Create or update the note for one table in a project (editor role).

Keyed by (project, schema_name, relation_name); a unique constraint keeps
it to a single note per table.
"""
await require_project_member(
session, project_space_uuid, user.user_account_uuid, minimum_role="editor"
)
now = dt.datetime.now(dt.timezone.utc)
existing = await session.scalar(
select(TableAnnotation).where(
TableAnnotation.project_space_uuid == project_space_uuid,
TableAnnotation.schema_name == body.schema_name,
TableAnnotation.relation_name == body.relation_name,
)
)
if existing is not None:
existing.body = body.body
existing.updated_at = now
ann = existing
else:
ann = TableAnnotation(
table_annotation_uuid=uuid.uuid4(),
project_space_uuid=project_space_uuid,
schema_name=body.schema_name,
relation_name=body.relation_name,
body=body.body,
created_by=user.user_account_uuid,
created_at=now,
updated_at=now,
)
session.add(ann)
await session.commit()
return _to_out(ann)


@router.delete("/{table_annotation_uuid}")
async def delete_annotation(
table_annotation_uuid: uuid.UUID,
user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict[str, bool]:
"""Delete a table annotation (requires editor membership on its project)."""
ann = await _get_authorized_annotation(
session, table_annotation_uuid, user, minimum_role="editor"
)
if ann is None:
raise HTTPException(status_code=404, detail="annotation not found")
await session.delete(ann)
await session.commit()
return {"ok": True}
48 changes: 45 additions & 3 deletions backend/app/api/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
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_read_session, get_session
from app.db_introspect import probe_database
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.schemas import ConnectionCreateIn, ConnectionOut, ConnectionTestOut
from app.security import decrypt_text, encrypt_text
from app.sanitize import sanitize_for_storage

router = APIRouter(prefix="/api/connections", tags=["connections"])
Expand Down Expand Up @@ -62,3 +63,44 @@ async def create_connection(
session.add(c)
await session.commit()
return ConnectionOut(db_connection_uuid=c.db_connection_uuid, conn_name=c.conn_name)


@router.post("/{db_connection_uuid}/test", response_model=ConnectionTestOut)
async def test_connection(
db_connection_uuid: uuid.UUID,
user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_read_session),
) -> ConnectionTestOut:
"""Probe a stored connection's DSN and report reachability.

IDOR-safe: the connection's project is resolved first and membership is
required, with a uniform 404 for missing/unauthorized. The DSN is decrypted
only in memory; the live probe reuses the introspectors' SSRF guard, and any
failure message is DSN-redacted (``ok=false`` rather than an error status,
since an unreachable database is a normal result).
"""
project_space_uuid = await session.scalar(
select(DbConnection.project_space_uuid).where(
DbConnection.db_connection_uuid == db_connection_uuid
)
)
if project_space_uuid is None:
raise HTTPException(status_code=404, detail="connection not found")
try:
await require_project_member(
session, project_space_uuid, user.user_account_uuid
)
except HTTPException as exc:
if exc.status_code == 403:
raise HTTPException(status_code=404, detail="connection not found")
raise

conn = await session.get(DbConnection, db_connection_uuid)
if conn is None:
raise HTTPException(status_code=404, detail="connection not found")
dsn = decrypt_text(conn.dsn_ciphertext, conn.dsn_nonce)
try:
version = await probe_database(dsn)
return ConnectionTestOut(ok=True, server_version=version, error=None)
except Exception as exc: # noqa: BLE001 - message is already DSN-redacted
return ConnectionTestOut(ok=False, server_version=None, error=str(exc))
Loading
Loading