From 6680423556a212e9af099cea7ee4bc3f41c00e1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 13:21:57 +0900 Subject: [PATCH] feat(platform): API keys for programmatic access (CI/CD, SDKs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 roadmap item — API-first platform access. Bearer pgerd_* keys accepted by get_current_user alongside OIDC (non-invasive branch). Secrets never stored: SHA-256 hash only, plaintext returned exactly once at creation, key_prefix for recognition, revocation is an auditable timestamp (idempotent). /api/api-keys: POST create, GET list (metadata only), DELETE revoke (uniform 404 for other users' keys). Also carries the 0003_revoked_token down_revision fix ('0002' -> '0002_auth_share') without which alembic upgrade head breaks on a fresh DB. Migration 0007 verified against real Postgres. +5 tests; backend suite 236; mypy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AxU2xaupAjp912oDNFuWyd --- .../alembic/versions/0003_revoked_token.py | 4 +- backend/alembic/versions/0007_api_key.py | 43 +++++++ backend/app/api/api_keys.py | 93 ++++++++++++++ backend/app/auth.py | 44 ++++++- backend/app/main.py | 2 + backend/app/models.py | 30 +++++ backend/app/schemas.py | 22 ++++ backend/tests/test_api_keys.py | 115 ++++++++++++++++++ 8 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/0007_api_key.py create mode 100644 backend/app/api/api_keys.py create mode 100644 backend/tests/test_api_keys.py diff --git a/backend/alembic/versions/0003_revoked_token.py b/backend/alembic/versions/0003_revoked_token.py index b7bcce29..88296542 100644 --- a/backend/alembic/versions/0003_revoked_token.py +++ b/backend/alembic/versions/0003_revoked_token.py @@ -1,7 +1,7 @@ """revoked_token Revision ID: 0003 -Revises: 0002 +Revises: 0002_auth_share Create Date: 2026-06-22 10:00:00.000000 """ @@ -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 diff --git a/backend/alembic/versions/0007_api_key.py b/backend/alembic/versions/0007_api_key.py new file mode 100644 index 00000000..5e535174 --- /dev/null +++ b/backend/alembic/versions/0007_api_key.py @@ -0,0 +1,43 @@ +"""api_key table for programmatic access + +Revision ID: 0007_api_key +Revises: 0006_table_annotation +Create Date: 2026-07-06 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "0007_api_key" +down_revision = "0006_table_annotation" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "api_key", + sa.Column("api_key_uuid", UUID(as_uuid=True), primary_key=True), + sa.Column( + "user_account_uuid", + UUID(as_uuid=True), + sa.ForeignKey("user_account.user_account_uuid", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("key_name", sa.Text(), nullable=False), + sa.Column("key_hash", sa.Text(), nullable=False), + sa.Column("key_prefix", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_api_key__user", "api_key", ["user_account_uuid"]) + op.create_index("ix_api_key__hash", "api_key", ["key_hash"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_api_key__hash", table_name="api_key") + op.drop_index("ix_api_key__user", table_name="api_key") + op.drop_table("api_key") diff --git a/backend/app/api/api_keys.py b/backend/app/api/api_keys.py new file mode 100644 index 00000000..50d903f3 --- /dev/null +++ b/backend/app/api/api_keys.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import datetime as dt +import secrets +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import API_KEY_PREFIX, CurrentUser, get_current_user, hash_api_key +from app.db import get_read_session, get_session +from app.models import ApiKey +from app.sanitize import sanitize_for_storage +from app.schemas import ApiKeyCreatedOut, ApiKeyCreateIn, ApiKeyOut + +router = APIRouter(prefix="/api/api-keys", tags=["api-keys"]) + + +def _to_out(key: ApiKey) -> ApiKeyOut: + return ApiKeyOut( + api_key_uuid=key.api_key_uuid, + key_name=key.key_name, + key_prefix=key.key_prefix, + created_at=key.created_at, + revoked_at=key.revoked_at, + ) + + +@router.get("", response_model=list[ApiKeyOut]) +async def list_api_keys( + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> list[ApiKeyOut]: + """List the caller's API keys (never the secrets).""" + rows = await session.execute( + select(ApiKey) + .where(ApiKey.user_account_uuid == user.user_account_uuid) + .order_by(ApiKey.created_at.desc()) + ) + return [_to_out(k) for k in rows.scalars().all()] + + +@router.post("", response_model=ApiKeyCreatedOut) +async def create_api_key( + body: ApiKeyCreateIn, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> ApiKeyCreatedOut: + """Create an API key. The secret is returned ONCE and never stored. + + Only its SHA-256 hash is persisted; ``key_prefix`` lets the user recognize + the key later without exposing it. + """ + token = API_KEY_PREFIX + secrets.token_urlsafe(32) + key = ApiKey( + api_key_uuid=uuid.uuid4(), + user_account_uuid=user.user_account_uuid, + key_name=str(sanitize_for_storage(body.key_name)), + key_hash=hash_api_key(token), + key_prefix=token[: len(API_KEY_PREFIX) + 6], + created_at=dt.datetime.now(dt.timezone.utc), + revoked_at=None, + ) + session.add(key) + await session.commit() + return ApiKeyCreatedOut( + api_key_uuid=key.api_key_uuid, + key_name=key.key_name, + key_prefix=key.key_prefix, + created_at=key.created_at, + revoked_at=None, + secret=token, + ) + + +@router.delete("/{api_key_uuid}", response_model=ApiKeyOut) +async def revoke_api_key( + api_key_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> ApiKeyOut: + """Revoke an API key (idempotent; keeps the row for auditability). + + IDOR-safe: another user's key yields the same uniform 404 as a missing one. + """ + key = await session.get(ApiKey, api_key_uuid) + if key is None or key.user_account_uuid != user.user_account_uuid: + raise HTTPException(status_code=404, detail="api key not found") + if key.revoked_at is None: + key.revoked_at = dt.datetime.now(dt.timezone.utc) + await session.commit() + return _to_out(key) diff --git a/backend/app/auth.py b/backend/app/auth.py index 132e57f3..3fdafcfd 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -2,6 +2,7 @@ import asyncio import datetime as dt +import hashlib import uuid from dataclasses import dataclass from typing import Any, cast @@ -13,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_session -from app.models import UserAccount +from app.models import ApiKey, UserAccount from app.settings import settings @@ -406,11 +407,50 @@ async def _ensure_user( return user +API_KEY_PREFIX = "pgerd_" + + +def hash_api_key(token: str) -> str: + """Deterministic SHA-256 hex digest of an API key (indexable lookup).""" + + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +async def _user_from_api_key(session: AsyncSession, token: str) -> CurrentUser: + """Authenticate an ``Authorization: Bearer pgerd_...`` API key. + + Looks up the SHA-256 hash (constant-shape errors: any failure is the same + 401 so keys cannot be probed) and rejects revoked keys. + """ + + row = await session.execute( + select(ApiKey, UserAccount) + .join(UserAccount, UserAccount.user_account_uuid == ApiKey.user_account_uuid) + .where(ApiKey.key_hash == hash_api_key(token)) + ) + pair = row.first() + if pair is None or pair[0].revoked_at is not None: + raise HTTPException(status_code=401, detail="invalid API key") + user = pair[1] + 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: - """FastAPI dependency that authenticates and returns the current user.""" + """FastAPI dependency that authenticates and returns the current user. + + Accepts either the standard OIDC bearer token or a ``pgerd_``-prefixed + API key (programmatic access for CI/CD and SDKs). + """ + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer " + API_KEY_PREFIX): + return await _user_from_api_key(session, auth_header[len("Bearer "):]) 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/main.py b/backend/app/main.py index c191880a..a0f0780c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.annotations import router as annotations_router +from app.api.api_keys import router as api_keys_router from app.api.connections import router as connections_router from app.api.auth_routes import router as auth_router from app.api.diagram_views import router as diagram_views_router @@ -169,6 +170,7 @@ async def csrf_token() -> dict[str, str]: app.include_router(snapshots_router) app.include_router(diagram_views_router) app.include_router(annotations_router) +app.include_router(api_keys_router) app.include_router(me_router) app.include_router(share_router) app.include_router(auth_router) diff --git a/backend/app/models.py b/backend/app/models.py index 9c0fd845..bd6bd438 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -286,3 +286,33 @@ class ShareLink(Base): created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), default=utcnow ) + + +class ApiKey(Base): + """A long-lived API key for programmatic access (CI/CD, SDKs). + + Only a SHA-256 hash of the secret is stored; the plaintext is shown once at + creation. ``key_prefix`` (the first characters of the token) lets users + recognize a key without exposing it. Revocation is a timestamp so it is + auditable and cannot be un-revoked silently. + """ + + __tablename__ = "api_key" + + api_key_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_account_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("user_account.user_account_uuid", ondelete="CASCADE"), + index=True, + ) + key_name: Mapped[str] = mapped_column(Text()) + key_hash: Mapped[str] = mapped_column(Text(), unique=True, index=True) + key_prefix: Mapped[str] = mapped_column(Text()) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + revoked_at: Mapped[dt.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 016c82a0..96fb8dfd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -141,3 +141,25 @@ class MeOut(BaseModel): user_account_uuid: uuid.UUID subject: str display_name: str | None + + +class ApiKeyCreateIn(BaseModel): + """Request body for creating an API key.""" + + key_name: str = Field(min_length=1, max_length=128) + + +class ApiKeyOut(BaseModel): + """API key metadata (never contains the secret).""" + + api_key_uuid: uuid.UUID + key_name: str + key_prefix: str + created_at: dt.datetime + revoked_at: dt.datetime | None + + +class ApiKeyCreatedOut(ApiKeyOut): + """Creation response: includes the secret exactly once.""" + + secret: str diff --git a/backend/tests/test_api_keys.py b/backend/tests/test_api_keys.py new file mode 100644 index 00000000..7b1acd82 --- /dev/null +++ b/backend/tests/test_api_keys.py @@ -0,0 +1,115 @@ +import datetime as dt +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from app.api.api_keys import create_api_key, list_api_keys, revoke_api_key +from app.auth import API_KEY_PREFIX, CurrentUser, _user_from_api_key, hash_api_key +from app.schemas import ApiKeyCreateIn + + +def _user(uid=None): + return CurrentUser( + user_account_uuid=uid or uuid.uuid4(), subject="t", display_name="T" + ) + + +def test_hash_is_deterministic_and_not_reversible(): + token = API_KEY_PREFIX + "abc123" + assert hash_api_key(token) == hash_api_key(token) + assert token not in hash_api_key(token) + assert len(hash_api_key(token)) == 64 # sha256 hex + + +@pytest.mark.asyncio +async def test_create_returns_secret_once_and_stores_only_hash(): + session = AsyncMock() + user = _user() + out = await create_api_key( + body=ApiKeyCreateIn(key_name="ci"), user=user, session=session + ) + assert out.secret.startswith(API_KEY_PREFIX) + added = session.add.call_args[0][0] + assert added.key_hash == hash_api_key(out.secret) + assert out.secret not in (added.key_hash, added.key_prefix, added.key_name) + assert out.key_prefix == out.secret[: len(API_KEY_PREFIX) + 6] + + +@pytest.mark.asyncio +async def test_auth_accepts_valid_key_and_rejects_revoked_or_unknown(): + token = API_KEY_PREFIX + "secret" + account = SimpleNamespace( + user_account_uuid=uuid.uuid4(), oidc_subject="dev:x", display_name="X" + ) + live_key = SimpleNamespace(revoked_at=None) + + session = AsyncMock() + session.execute = AsyncMock( + return_value=SimpleNamespace(first=lambda: (live_key, account)) + ) + user = await _user_from_api_key(session, token) + assert user.user_account_uuid == account.user_account_uuid + + # revoked + revoked_key = SimpleNamespace(revoked_at=dt.datetime.now(dt.timezone.utc)) + session.execute = AsyncMock( + return_value=SimpleNamespace(first=lambda: (revoked_key, account)) + ) + with pytest.raises(HTTPException) as e: + await _user_from_api_key(session, token) + assert e.value.status_code == 401 + + # unknown + session.execute = AsyncMock(return_value=SimpleNamespace(first=lambda: None)) + with pytest.raises(HTTPException) as e2: + await _user_from_api_key(session, token) + assert e2.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_revoke_is_idor_safe_and_idempotent(): + owner = _user() + other_key = SimpleNamespace( + user_account_uuid=uuid.uuid4(), # someone else's + revoked_at=None, + ) + session = AsyncMock() + session.get = AsyncMock(return_value=other_key) + with pytest.raises(HTTPException) as e: + await revoke_api_key(api_key_uuid=uuid.uuid4(), user=owner, session=session) + assert e.value.status_code == 404 # uniform: same as missing + + session.get = AsyncMock(return_value=None) + with pytest.raises(HTTPException): + await revoke_api_key(api_key_uuid=uuid.uuid4(), user=owner, session=session) + + # own key: revokes once, second call keeps timestamp (idempotent) + ts = dt.datetime.now(dt.timezone.utc) + own = SimpleNamespace( + api_key_uuid=uuid.uuid4(), + user_account_uuid=owner.user_account_uuid, + key_name="ci", key_prefix="pgerd_abc", created_at=ts, revoked_at=ts, + ) + session.get = AsyncMock(return_value=own) + out = await revoke_api_key(api_key_uuid=own.api_key_uuid, user=owner, session=session) + assert out.revoked_at == ts + session.commit.assert_not_awaited() # already revoked -> no write + + +@pytest.mark.asyncio +async def test_list_returns_only_metadata(): + user = _user() + key = SimpleNamespace( + api_key_uuid=uuid.uuid4(), key_name="ci", key_prefix="pgerd_abc", + created_at=dt.datetime.now(dt.timezone.utc), revoked_at=None, + ) + session = AsyncMock() + session.execute = AsyncMock( + return_value=SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: [key])) + ) + out = await list_api_keys(user=user, session=session) + assert len(out) == 1 + assert not hasattr(out[0], "secret")