Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
95e3c5d
docs(phase-7c): add spec + TDD implementation plan for API-Key auth
strausmann May 17, 2026
39e19a0
feat(security): Phase 7c Step 1 — ApiKey model, migration, and reposi…
strausmann May 17, 2026
7605377
feat(security): Phase 7c Step 2 — key generation + bcrypt verify + LR…
strausmann May 17, 2026
033a664
feat(security): Phase 7c Step 3 — require_scope() FastAPI dependency …
strausmann May 17, 2026
2e327a4
feat(security): Phase 7c Step 4 — wire require_scope into all routes
strausmann May 17, 2026
380e612
feat(security): Phase 7c Step 5 — in-memory token-bucket rate limiter
strausmann May 17, 2026
36c9504
feat(security): Phase 7c Step 6 — per-key printer ACL check
strausmann May 17, 2026
0104978
feat(security): Phase 7c Step 7 — audit trail on jobs (api_key_id + s…
strausmann May 17, 2026
ee466be
feat(security): Phase 7c Step 8 — backend CRUD API for /api/admin/api…
strausmann May 17, 2026
46dd6f6
feat(ui): Phase 7c Step 9 — frontend HTMX /admin/api-keys UI
strausmann May 17, 2026
544030d
feat(security): Phase 7c Step 10 — final integration + production-rea…
strausmann May 17, 2026
5282c5f
fix(security): offload bcrypt.checkpw to thread pool (Fix A)
strausmann May 18, 2026
924a9ca
fix(security): scope hierarchy fail-closed + no implicit read (Fixes …
strausmann May 18, 2026
7e0b1e4
fix(api,security): admin_api_keys cleanup — Fixes D+E + GitGuardian (…
strausmann May 18, 2026
1d91cf6
fix(tests): ruff + mypy clean — Fail 1 Python lint/type CI (Round 2)
strausmann May 18, 2026
c4f050f
fix(api): fix retry_after f-string not interpolated in 429 response body
strausmann May 18, 2026
c09c158
feat(api): change key format to lh_pat_ with 16-char prefix
strausmann May 18, 2026
309d6b5
feat(security): add gitleaks + gitguardian custom detector for lh_pat…
strausmann May 18, 2026
3ff8524
chore(security): exclude test trees from GitGuardian scan
strausmann May 18, 2026
17cda07
Merge remote-tracking branch 'origin/main' into feat/phase-7c-api-auth
strausmann May 18, 2026
8b975bf
test(auth): lower-entropy fixture strings for GitGuardian compatibility
strausmann May 18, 2026
8a86118
fix(tests): Phase 7b migration downgrade target — explicit revision
strausmann May 30, 2026
1b173ef
fix(tests): wrap _get_engine idempotence test in asyncio.run
strausmann May 30, 2026
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
92 changes: 92 additions & 0 deletions backend/alembic/versions/20260517_phase7c_api_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Phase 7c — api_keys table + audit columns on jobs + bootstrap-admin seed.

Revision ID: 20260517_phase7c_api_keys
Revises: 20260517_phase7b_datetime_tz
Create Date: 2026-05-17
"""

from __future__ import annotations

import json
import logging
import secrets

import bcrypt
from alembic import op
import sqlalchemy as sa

revision = "20260517_phase7c_api_keys"
down_revision = "20260517_phase7b_datetime_tz"
branch_labels = None
depends_on = None

_log = logging.getLogger(__name__)
_BOOTSTRAP_KEY_NAME = "bootstrap-admin"


def _generate_bootstrap_key() -> tuple[str, str, str]:
body = secrets.token_urlsafe(32)
plaintext = f"lh_{body}"
prefix = plaintext[:12]
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=12)).decode()
return plaintext, prefix, hashed


def upgrade() -> None:
op.create_table(
"api_keys",
sa.Column("id", sa.Uuid, primary_key=True),
sa.Column("name", sa.String, nullable=False),
sa.Column("key_hash", sa.String, nullable=False),
sa.Column("key_prefix", sa.String, nullable=False),
sa.Column("scopes", sa.JSON, nullable=False),
sa.Column("allowed_printer_ids", sa.JSON, nullable=False),
sa.Column("rate_limit_per_minute", sa.Integer, nullable=False, server_default="60"),
sa.Column("enabled", sa.Boolean, nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_ip", sa.String, nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("notes", sa.String, nullable=True),
)
op.create_index("ix_api_keys_name", "api_keys", ["name"])
op.create_index("ix_api_keys_key_prefix", "api_keys", ["key_prefix"])

with op.batch_alter_table("jobs") as batch_op:
batch_op.add_column(sa.Column("api_key_id", sa.Uuid, nullable=True))
batch_op.add_column(sa.Column("source_ip", sa.String, nullable=True))
op.create_index("ix_jobs_api_key_id", "jobs", ["api_key_id"])

conn = op.get_bind()
count = conn.execute(sa.text("SELECT COUNT(*) FROM api_keys")).scalar()
if count == 0:
from datetime import UTC, datetime
from uuid import uuid4
plaintext, prefix, hashed = _generate_bootstrap_key()
key_id = str(uuid4())
now = datetime.now(UTC).isoformat()
conn.execute(
sa.text(
"INSERT INTO api_keys "
"(id, name, key_hash, key_prefix, scopes, allowed_printer_ids, "
" rate_limit_per_minute, enabled, created_at) "
"VALUES (:id, :name, :hash, :prefix, :scopes, :printers, "
" :rate, :enabled, :now)"
),
{
"id": key_id, "name": _BOOTSTRAP_KEY_NAME, "hash": hashed,
"prefix": prefix, "scopes": json.dumps(["admin"]),
"printers": json.dumps([]), "rate": 60, "enabled": 1, "now": now,
},
)
_log.warning("BOOTSTRAP API KEY: %s (prefix: %s) — rotate via /admin/api-keys", plaintext, prefix)


def downgrade() -> None:
op.drop_index("ix_jobs_api_key_id", table_name="jobs")
with op.batch_alter_table("jobs") as batch_op:
batch_op.drop_column("source_ip")
batch_op.drop_column("api_key_id")
op.drop_index("ix_api_keys_key_prefix", table_name="api_keys")
op.drop_index("ix_api_keys_name", table_name="api_keys")
op.drop_table("api_keys")
243 changes: 243 additions & 0 deletions backend/app/api/routes/admin_api_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
"""REST CRUD endpoints for API key management — Phase 7c Step 8.

All endpoints require ``admin`` scope.

Routes
------
GET /api/admin/api-keys — list all keys (metadata only, no hashes/plaintexts)
POST /api/admin/api-keys — create key, returns plaintext ONCE in response
GET /api/admin/api-keys/{id} — single key metadata
PATCH /api/admin/api-keys/{id} — update enabled/rate_limit/notes
DELETE /api/admin/api-keys/{id} — revoke key (sets enabled=False)
"""

from __future__ import annotations

from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import AuthContext
from app.auth.key_generator import generate_api_key
from app.auth.scope_deps import require_admin
from app.auth.verifier import invalidate_cache
from app.db.session import get_session
from app.models.api_key import ApiKey
from app.repositories import api_keys as api_keys_repo
from app.services.rate_limiter import _rate_limiter

router = APIRouter(prefix="/api/admin/api-keys", tags=["admin"])

SessionDep = Annotated[AsyncSession, Depends(get_session)]
AdminAuthDep = Annotated[AuthContext, Depends(require_admin)]


# ---------------------------------------------------------------------------
# Request / Response schemas
# ---------------------------------------------------------------------------


class ApiKeyCreate(BaseModel):
name: str
scopes: list[str]
allowed_printer_ids: list[str] = []
rate_limit_per_minute: int = 60
notes: str | None = None
expires_at: str | None = None # ISO-8601 string or null

Comment on lines +45 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ApiKeyCreate model should use the datetime type for expires_at and include validation for rate_limit_per_minute. Using str for dates requires manual parsing and bypasses Pydantic's automatic validation and error reporting (422 Unprocessable Entity). Additionally, a non-positive rate limit could cause a division-by-zero error in the rate limiter.

Suggested change
class ApiKeyCreate(BaseModel):
name: str
scopes: list[str]
allowed_printer_ids: list[str] = []
rate_limit_per_minute: int = 60
notes: str | None = None
expires_at: str | None = None # ISO-8601 string or null
class ApiKeyCreate(BaseModel):
name: str
scopes: list[str]
allowed_printer_ids: list[str] = []
rate_limit_per_minute: int = Field(default=60, gt=0)
notes: str | None = None
expires_at: datetime | None = None

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7e0b1e4 (Fix E): expires_at is now datetime | None — Pydantic parses and validates at request time, returning 422 on invalid input. rate_limit_per_minute now uses Field(ge=1, le=10000).

Comment on lines +45 to +52

class ApiKeyCreateResponse(BaseModel):
"""Returned ONCE on creation — includes plaintext. Never return again."""

key_id: UUID
plaintext: str
prefix: str
name: str
scopes: list[str]


class ApiKeyRead(BaseModel):
"""Metadata-only view — no key_hash, no plaintext."""

id: UUID
name: str
key_prefix: str
scopes: list[str]
allowed_printer_ids: list[str]
rate_limit_per_minute: int
enabled: bool
created_at: str
last_used_at: str | None
last_used_ip: str | None
expires_at: str | None
notes: str | None


class ApiKeyPatch(BaseModel):
enabled: bool | None = None
rate_limit_per_minute: int | None = None
notes: str | None = None
allowed_printer_ids: list[str] | None = None


def _key_to_read(key: ApiKey) -> ApiKeyRead:
return ApiKeyRead(
id=key.id,
name=key.name,
key_prefix=key.key_prefix,
scopes=key.scopes,
allowed_printer_ids=key.allowed_printer_ids,
rate_limit_per_minute=key.rate_limit_per_minute,
enabled=key.enabled,
created_at=key.created_at.isoformat() if key.created_at else "",
last_used_at=key.last_used_at.isoformat() if key.last_used_at else None,
last_used_ip=key.last_used_ip,
expires_at=key.expires_at.isoformat() if key.expires_at else None,
notes=key.notes,
)


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------


@router.get(
"",
response_model=list[ApiKeyRead],
summary="List all API keys",
description="Returns metadata for all API keys. key_hash and plaintext are never included.",
)
async def list_api_keys(session: SessionDep, _auth: AdminAuthDep) -> list[ApiKeyRead]:
result = await session.execute(__import__("sqlalchemy", fromlist=["select"]).select(ApiKey))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using __import__ to dynamically load sqlalchemy.select is unconventional and reduces code readability and maintainability. Use a standard import statement at the top of the file or a local import if circularity is a concern.

Suggested change
result = await session.execute(__import__("sqlalchemy", fromlist=["select"]).select(ApiKey))
from sqlalchemy import select
result = await session.execute(select(ApiKey))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7e0b1e4 (Fix D): replaced with top-level from sqlalchemy import select at module scope.

keys = list(result.scalars())
return [_key_to_read(k) for k in keys]
Comment on lines +116 to +119


@router.post(
"",
response_model=ApiKeyCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new API key",
description=(
"Creates a new API key. The ``plaintext`` field in the response is the "
"full key — it is shown ONCE and never stored. Copy it before closing "
"this response. Subsequent GETs return only the prefix."
),
)
async def create_api_key(
body: ApiKeyCreate,
session: SessionDep,
_auth: AdminAuthDep,
) -> ApiKeyCreateResponse:
plaintext, prefix, hashed = generate_api_key()
key = ApiKey(
name=body.name,
key_hash=hashed,
key_prefix=prefix,
scopes=body.scopes,
allowed_printer_ids=body.allowed_printer_ids,
rate_limit_per_minute=body.rate_limit_per_minute,
notes=body.notes,
enabled=True,
)
if body.expires_at:
from datetime import datetime

key.expires_at = datetime.fromisoformat(body.expires_at)

created = await api_keys_repo.create(session, key)
return ApiKeyCreateResponse(
key_id=created.id,
plaintext=plaintext,
prefix=prefix,
name=created.name,
scopes=created.scopes,
)


@router.get(
"/{key_id}",
response_model=ApiKeyRead,
summary="Get API key metadata",
description="Returns metadata for a single API key. key_hash and plaintext are never included.",
)
async def get_api_key(
key_id: UUID,
session: SessionDep,
_auth: AdminAuthDep,
) -> ApiKeyRead:
key = await api_keys_repo.get(session, key_id)
if key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"API key {key_id} not found",
)
Comment on lines +174 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error response format here (a plain string detail) is inconsistent with the structured dictionary format used in backend/app/auth/dependencies.py (e.g., {"error_code": "...", "error_message": "..."}). For a consistent API experience, especially for automated consumers, all error responses should follow the same schema (ideally RFC 7807 Problem Details as referenced in the project's OpenAPI completeness tests).

return _key_to_read(key)


@router.patch(
"/{key_id}",
response_model=ApiKeyRead,
summary="Update API key metadata",
description=(
"Update ``enabled``, ``rate_limit_per_minute``, ``notes``, or "
"``allowed_printer_ids``. Cannot change scopes or the key value itself — "
"revoke and recreate for that."
),
)
async def update_api_key(
key_id: UUID,
body: ApiKeyPatch,
session: SessionDep,
_auth: AdminAuthDep,
) -> ApiKeyRead:
key = await api_keys_repo.get(session, key_id)
if key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"API key {key_id} not found",
)
if body.enabled is not None:
key.enabled = body.enabled
if body.rate_limit_per_minute is not None:
key.rate_limit_per_minute = body.rate_limit_per_minute
if body.notes is not None:
key.notes = body.notes
if body.allowed_printer_ids is not None:
key.allowed_printer_ids = body.allowed_printer_ids

session.add(key)
await session.commit()
await session.refresh(key)
return _key_to_read(key)


@router.delete(
"/{key_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Revoke an API key",
description=(
"Sets ``enabled = False``. The key will be rejected on next use. "
"The row is kept for audit purposes (jobs referencing this key_id "
"remain intact)."
),
)
async def revoke_api_key(
key_id: UUID,
session: SessionDep,
_auth: AdminAuthDep,
) -> None:
key = await api_keys_repo.revoke(session, key_id)
if key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"API key {key_id} not found",
)
# Invalidate bcrypt cache so the key is rejected immediately
invalidate_cache(key.key_hash)
# Clear rate-limiter bucket
_rate_limiter.reset(key_id)
7 changes: 7 additions & 0 deletions backend/app/api/routes/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import AuthContext
from app.auth.scope_deps import require_print, require_read
from app.db.session import get_session
from app.models.job import Job, JobState
from app.repositories import jobs as jobs_repo
Expand All @@ -48,6 +50,8 @@

# Type alias for the session dependency
SessionDep = Annotated[AsyncSession, Depends(get_session)]
ReadAuthDep = Annotated[AuthContext, Depends(require_read)]
PrintAuthDep = Annotated[AuthContext, Depends(require_print)]

# Query parameter type aliases (Annotated avoids B008 on Query() in arg defaults)
StateQuery = Annotated[
Expand Down Expand Up @@ -103,6 +107,7 @@ async def _get_job_or_404(session: AsyncSession, job_id: UUID) -> Job:
)
async def list_jobs(
session: SessionDep,
_auth: ReadAuthDep,
state: StateQuery = None,
printer_id: PrinterIdQuery = None,
since: SinceQuery = None,
Expand Down Expand Up @@ -133,6 +138,7 @@ async def list_jobs(
async def get_job(
job_id: UUID,
session: SessionDep,
_auth: ReadAuthDep,
) -> JobRead:
"""Return a single job by ID."""
job = await _get_job_or_404(session, job_id)
Expand All @@ -158,6 +164,7 @@ async def get_job(
async def cancel_job(
job_id: UUID,
session: SessionDep,
_auth: PrintAuthDep,
) -> JobRead:
"""Cancel a QUEUED job; reject with 409 for any other state."""
job = await _get_job_or_404(session, job_id)
Expand Down
Loading
Loading