"""HTTP API for identity consumers. Two routers: * ``router`` — the admin governance surface (global_admin): GET /api/v1/identity/consumers list POST /api/v1/identity/consumers register GET /api/v1/identity/consumers/scopes scope catalog GET /api/v1/identity/consumers/{id} detail (+tokens/usage) PATCH /api/v1/identity/consumers/{id} update / kill switch DELETE /api/v1/identity/consumers/{id} soft delete (+revoke all) POST /api/v1/identity/consumers/{id}/tokens mint (plaintext once) POST /api/v1/identity/consumers/{id}/tokens/{tid}/revoke * ``open_router`` — the consumer-facing credential smoke test: GET /api/v1/open/directory/me (any valid service token) ``/me`` exists so an integrator can verify their credential + granted scopes end-to-end before the data endpoints, and so PR1 ships the whole auth path exercised in production. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import require_admin, require_admin_read from app.core.exceptions import ConflictError, NotFoundError, ValidationError from app.core.person_filters import FilterConfig, FilterError from app.core.response import success_response from app.identity.consumers.auth import ConsumerContext, require_consumer from app.identity.consumers.field_catalog import ( catalog_payload, reconcile_granted_fields, validate_granted_fields, ) from app.identity.consumers.models import ( CONSUMER_STATUS_ACTIVE, KNOWN_SCOPES, IdentityConsumer, IdentityConsumerToken, IdentityConsumerUsageDaily, ) from app.identity.consumers.schemas import ( ConsumerCreateRequest, ConsumerDetailResponse, ConsumerResponse, ConsumerUpdateRequest, TokenMintRequest, TokenMintResponse, TokenResponse, UsageDayResponse, ) from app.identity.consumers.token_service import ( ALLOWED_EXPIRY_DAYS, ConsumerTokenService, token_is_active, ) from app.identity.person_models import IdentityPerson router = APIRouter( prefix="/api/v1/identity/consumers", tags=["identity-consumers"], ) open_router = APIRouter( prefix="/api/v1/open/directory", tags=["open-directory"], ) async def _audit( db: AsyncSession, request: Request, admin: AuthUser, *, action: str, resource_id: str, detail: dict, ) -> None: await AuditService(db).log( trace_id=getattr(request.state, "trace_id", "") or "", actor_type="user", actor_user_id=admin.id, action=action, status="success", resource_type="identity_consumer", resource_id=resource_id, detail=detail, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) def _validate_filter_config(raw: dict) -> dict: """Round-trip through FilterConfig so a bad config fails at save time with a 422, not mid-request on the data surface.""" try: return FilterConfig.from_dict(raw or {}).to_dict() except FilterError as e: raise ValidationError(f"filter_config invalid: {e}") from e async def _require_owner(db: AsyncSession, owner_person_id: UUID) -> IdentityPerson: owner = await db.get(IdentityPerson, owner_person_id) if owner is None or owner.deleted_at is not None: raise ValidationError("owner_person_id does not reference a known person.") if owner.employment_status != "active": raise ValidationError( "Consumer owner must be an active employee — pick the person " "accountable for this integration." ) return owner async def _get_consumer(db: AsyncSession, consumer_id: UUID) -> IdentityConsumer: # Explicit selectinload + populate_existing: ``db.get`` on an # identity-map hit skips relationship loaders, and the later sync # ``row.tokens`` access would raise MissingGreenlet on the async # session. Forcing the load here keeps every handler safe. row = (await db.execute( select(IdentityConsumer) .where(IdentityConsumer.id == consumer_id) .options(selectinload(IdentityConsumer.tokens)) .execution_options(populate_existing=True) )).scalar_one_or_none() if row is None or row.deleted_at is not None: raise NotFoundError("IdentityConsumer", str(consumer_id)) return row def _token_response(t: IdentityConsumerToken) -> TokenResponse: return TokenResponse( id=t.id, name=t.name, token_prefix=t.token_prefix, expires_at=t.expires_at, revoked_at=t.revoked_at, last_used_at=t.last_used_at, last_used_ip=t.last_used_ip, created_at=t.created_at, is_active=token_is_active(t), ) def _consumer_response( c: IdentityConsumer, owner: IdentityPerson | None, ) -> ConsumerResponse: live = [t for t in c.tokens if token_is_active(t)] last_used_candidates = [t.last_used_at for t in c.tokens if t.last_used_at] return ConsumerResponse( id=c.id, name=c.name, slug=c.slug, description=c.description, owner_person_id=c.owner_person_id, owner_display_name=owner.display_name if owner else None, owner_work_email=owner.work_email if owner else None, owner_employee_no=owner.employee_no if owner else None, owner_employment_status=owner.employment_status if owner else None, status=c.status, scopes=list(c.scopes or []), granted_fields=list(c.granted_fields or []), filter_config=dict(c.filter_config or {}), rate_limit_per_minute=c.rate_limit_per_minute, created_at=c.created_at, updated_at=c.updated_at, active_token_count=len(live), last_used_at=max(last_used_candidates) if last_used_candidates else None, last_certified_at=c.last_certified_at, recert_lapsed_at=c.recert_lapsed_at, ) # ── Governance endpoints ────────────────────────────────────────────── @router.get("/scopes", summary="Scope/field catalog + token expiry presets") async def list_known_scopes( request: Request, _admin: AuthUser = Depends(require_admin_read()), ): """Catalog the applicant/admin UI renders: the coarse bundles, the per-field catalog (key + bundle + sensitivity + always-on flag), and the token-expiry presets. ``fields`` makes the grant transparent — you can see exactly what each scope exposes before granting it.""" return success_response( data={ "scopes": sorted(KNOWN_SCOPES), "fields": catalog_payload(), "token_expiry_days": sorted(ALLOWED_EXPIRY_DAYS), }, request=request, ) @router.get("", summary="List identity consumers") async def list_consumers( request: Request, include_deleted: bool = Query(default=False), db: AsyncSession = Depends(get_db), _admin: AuthUser = Depends(require_admin_read()), ): stmt = select(IdentityConsumer).order_by(IdentityConsumer.created_at.desc()) if not include_deleted: stmt = stmt.where(IdentityConsumer.deleted_at.is_(None)) rows = list((await db.execute(stmt)).scalars().all()) owner_ids = {c.owner_person_id for c in rows} owners: dict[UUID, IdentityPerson] = {} if owner_ids: result = await db.execute( select(IdentityPerson).where(IdentityPerson.id.in_(owner_ids)) ) owners = {p.id: p for p in result.scalars().all()} data = [ _consumer_response(c, owners.get(c.owner_person_id)).model_dump(mode="json") for c in rows ] return success_response(data=data, request=request) @router.post("", summary="Register an identity consumer") async def create_consumer( body: ConsumerCreateRequest, request: Request, db: AsyncSession = Depends(get_db), admin: AuthUser = Depends(require_admin()), ): existing = await db.execute( select(IdentityConsumer.id).where(IdentityConsumer.slug == body.slug) ) if existing.scalar_one_or_none() is not None: # Includes soft-deleted rows on purpose: the slug lands in audit # rows / logs, so recycling it would make old trails ambiguous. raise ConflictError(f"Consumer slug '{body.slug}' already exists.") owner = await _require_owner(db, body.owner_person_id) filter_config = _validate_filter_config(body.filter_config) try: granted_fields = validate_granted_fields(body.granted_fields, body.scopes) except ValueError as e: raise ValidationError(f"granted_fields invalid: {e}") from e row = IdentityConsumer( name=body.name.strip(), slug=body.slug, description=(body.description or "").strip() or None, owner_person_id=body.owner_person_id, scopes=body.scopes, granted_fields=granted_fields, filter_config=filter_config, rate_limit_per_minute=body.rate_limit_per_minute, created_by=admin.id, ) db.add(row) await db.flush() await _audit( db, request, admin, action="identity_consumer.create", resource_id=str(row.id), detail={"slug": row.slug, "scopes": body.scopes, "owner": owner.work_email}, ) await db.commit() # Re-read with the selectin token relationship warm (empty list). row = await _get_consumer(db, row.id) return success_response( data=_consumer_response(row, owner).model_dump(mode="json"), request=request, ) @router.get("/{consumer_id}", summary="Consumer detail (tokens + usage)") async def get_consumer( consumer_id: UUID, request: Request, db: AsyncSession = Depends(get_db), _admin: AuthUser = Depends(require_admin_read()), ): row = await _get_consumer(db, consumer_id) owner = await db.get(IdentityPerson, row.owner_person_id) since = (datetime.now(UTC) - timedelta(days=30)).date() usage_rows = list((await db.execute( select(IdentityConsumerUsageDaily) .where( IdentityConsumerUsageDaily.consumer_id == consumer_id, IdentityConsumerUsageDaily.usage_date >= since, ) .order_by(IdentityConsumerUsageDaily.usage_date.asc()) )).scalars().all()) base = _consumer_response(row, owner) detail = ConsumerDetailResponse( **base.model_dump(), tokens=[_token_response(t) for t in row.tokens], usage_recent_days=[ UsageDayResponse.model_validate(u) for u in usage_rows ], requests_30d=sum(u.request_count for u in usage_rows), ) return success_response(data=detail.model_dump(mode="json"), request=request) @router.patch("/{consumer_id}", summary="Update consumer / kill switch") async def update_consumer( consumer_id: UUID, body: ConsumerUpdateRequest, request: Request, db: AsyncSession = Depends(get_db), admin: AuthUser = Depends(require_admin()), ): row = await _get_consumer(db, consumer_id) changes: dict = {} if body.name is not None and body.name.strip() != row.name: changes["name"] = {"from": row.name, "to": body.name.strip()} row.name = body.name.strip() if body.description is not None: desc = body.description.strip() or None if desc != row.description: changes["description"] = "updated" row.description = desc if body.owner_person_id is not None and body.owner_person_id != row.owner_person_id: owner = await _require_owner(db, body.owner_person_id) changes["owner"] = {"from": str(row.owner_person_id), "to": owner.work_email} row.owner_person_id = body.owner_person_id scopes_changed = ( body.scopes is not None and sorted(body.scopes) != sorted(row.scopes or []) ) if scopes_changed: changes["scopes"] = {"from": row.scopes, "to": body.scopes} row.scopes = body.scopes # Resolve the field grant against the *effective* scopes (post-change). # Explicit list → strict validation; scope-only change → keep the # existing narrowing minus orphaned fields. effective_scopes = list(row.scopes or []) new_granted: list[str] | None = None if body.granted_fields is not None: try: new_granted = validate_granted_fields(body.granted_fields, effective_scopes) except ValueError as e: raise ValidationError(f"granted_fields invalid: {e}") from e elif scopes_changed: new_granted = reconcile_granted_fields(row.granted_fields, effective_scopes) if new_granted is not None and sorted(new_granted) != sorted(row.granted_fields or []): changes["granted_fields"] = {"from": row.granted_fields, "to": new_granted} row.granted_fields = new_granted if body.filter_config is not None: validated = _validate_filter_config(body.filter_config) if validated != (row.filter_config or {}): changes["filter_config"] = {"from": row.filter_config, "to": validated} row.filter_config = validated if ( "rate_limit_per_minute" in body.model_fields_set and body.rate_limit_per_minute != row.rate_limit_per_minute ): # ``model_fields_set`` distinguishes "field omitted" from an # explicit ``null`` — the latter resets the consumer back to # the platform-default rate limit (review fix 2026-06-11; # ``None``-as-skip made a custom limit permanently sticky). changes["rate_limit_per_minute"] = { "from": row.rate_limit_per_minute, "to": body.rate_limit_per_minute, } row.rate_limit_per_minute = body.rate_limit_per_minute if body.status is not None and body.status != row.status: changes["status"] = {"from": row.status, "to": body.status} row.status = body.status # An admin status change makes the recert-lapse marker stale: a # manual enable/disable is the admin's own decision, NOT a lapse, # so clear the flag. Otherwise an admin who disables an # already-lapsed consumer would leave recert_lapsed_at set and a # later recert `keep` would re-enable it against the admin's # intent (review fix 2026-06-15). if row.recert_lapsed_at is not None: row.recert_lapsed_at = None if changes: await db.flush() action = "identity_consumer.update" if "status" in changes: action = ( "identity_consumer.disable" if row.status != CONSUMER_STATUS_ACTIVE else "identity_consumer.enable" ) await _audit( db, request, admin, action=action, resource_id=str(row.id), detail={"slug": row.slug, "changes": changes}, ) await db.commit() row = await _get_consumer(db, consumer_id) owner = await db.get(IdentityPerson, row.owner_person_id) return success_response( data=_consumer_response(row, owner).model_dump(mode="json"), request=request, ) @router.delete("/{consumer_id}", summary="Soft-delete consumer (revokes all tokens)") async def delete_consumer( consumer_id: UUID, request: Request, db: AsyncSession = Depends(get_db), admin: AuthUser = Depends(require_admin()), ): row = await _get_consumer(db, consumer_id) now = datetime.now(UTC) row.deleted_at = now revoked = 0 for t in row.tokens: if t.revoked_at is None: t.revoked_at = now t.revoked_by_user_id = admin.id revoked += 1 await db.flush() await _audit( db, request, admin, action="identity_consumer.delete", resource_id=str(row.id), detail={"slug": row.slug, "tokens_revoked": revoked}, ) await db.commit() return success_response( data={"deleted": True, "tokens_revoked": revoked}, request=request, ) @router.post("/{consumer_id}/tokens", summary="Mint a service token") async def mint_token( consumer_id: UUID, body: TokenMintRequest, request: Request, db: AsyncSession = Depends(get_db), admin: AuthUser = Depends(require_admin()), ): row = await _get_consumer(db, consumer_id) token_row, plaintext = await ConsumerTokenService(db).mint( consumer=row, name=body.name, expires_in_days=body.expires_in_days, created_by=admin.id, ) await _audit( db, request, admin, action="identity_consumer.token_mint", resource_id=str(row.id), detail={"slug": row.slug, "token_id": str(token_row.id), "token_prefix": token_row.token_prefix, "expires_in_days": body.expires_in_days}, ) await db.commit() return success_response( data=TokenMintResponse( token=_token_response(token_row), plaintext=plaintext, ).model_dump(mode="json"), request=request, ) @router.post( "/{consumer_id}/tokens/{token_id}/revoke", summary="Revoke a service token", ) async def revoke_token( consumer_id: UUID, token_id: UUID, request: Request, db: AsyncSession = Depends(get_db), admin: AuthUser = Depends(require_admin()), ): row = await _get_consumer(db, consumer_id) token_row = await ConsumerTokenService(db).revoke( consumer_id=consumer_id, token_id=token_id, actor_user_id=admin.id, ) await _audit( db, request, admin, action="identity_consumer.token_revoke", resource_id=str(row.id), detail={"slug": row.slug, "token_id": str(token_id), "token_prefix": token_row.token_prefix}, ) await db.commit() return success_response( data=_token_response(token_row).model_dump(mode="json"), request=request, ) # ── Consumer-facing smoke test ──────────────────────────────────────── @open_router.get("/me", summary="Verify service credential + granted scopes") async def open_directory_me( request: Request, ctx: ConsumerContext = Depends(require_consumer()), ): """The integrator's first call: proves the token works and echoes back the consumer's effective grants — no person data involved.""" return success_response( data={ "consumer": ctx.slug, "name": ctx.name, "scopes": sorted(ctx.scopes), "filter_config": ctx.filter_config, }, request=request, ) __all__ = ["open_router", "router"]