"""HTTP surface for the SSO grant flow. Endpoint summary: POST /sso/grants — submit a new request GET /sso/grants — list (admin sees all; employee sees own) POST /sso/grants/{id}/cancel — requester cancels while pending POST /sso/grants/{id}/handover — admin reassigns to a successor POST /sso/grants/{id}/revoke — admin revokes an approved grant GET /sso/grants/leaver/{id} — list active grants held by a leaver """ from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from sqlalchemy import func as _func from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( get_current_user, require_admin, require_admin_read, ) from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import NotFoundError from app.core.response import success_response from app.domains.sso.models import ( REMOVAL_OPEN_STATUSES, SSO_GRANT_STATUS_APPROVED, SSO_GRANT_STATUS_CANCELLED, SSO_GRANT_STATUS_PENDING, SSO_GRANT_STATUS_REJECTED, SSO_GRANT_STATUS_REVOKED, SsoGrant, ) from app.domains.sso.removal import ( SsoRemovalService, google_driver_for_org, load_removal_settings, ) from app.domains.sso.service import SSO_PROVIDERS, SsoGrantService from app.identity.person_models import IdentityPerson router = APIRouter( prefix="/api/v1/sso", tags=["sso"], # Pre-launch hardening 2026-05-04: leaver_pending users must NOT be # able to file new SSO grant requests (or cancel/handover paths) — # all mutate methods route through the runtime gate below. dependencies=[Depends(_leaver_runtime_gate)], ) class SsoGrantSubmitBody(BaseModel): sso_provider: str = Field(min_length=1, max_length=64) system_name: str = Field(min_length=1, max_length=255) # ``system_owner_person_id`` is the canonical pointer — the # frontend picks an employee from the directory (defaults to the # requester themself). The service resolves the person row and # derives the display label from it, so we don't need a separate # free-text label field anymore. system_owner_person_id: str reason: str = Field(min_length=1, max_length=2000) # SAML metadata (Google flow only) — optional in the schema # because Lark requests carry none and the service-layer # validator distinguishes "Google needs at least one shape" from # "Lark must have none". See ``SsoGrantService.submit``. acs_url: str | None = Field(default=None, max_length=1024) entity_id: str | None = Field(default=None, max_length=1024) x509_cert: str | None = Field(default=None, max_length=20_000) saml_metadata_xml: str | None = Field(default=None, max_length=1_000_000) class SsoGrantHandoverBody(BaseModel): successor_person_id: str class SsoGrantRevokeBody(BaseModel): reason: str | None = Field(default=None, max_length=255) def _serialize(grant: SsoGrant, persons: dict[str, IdentityPerson]) -> dict[str, Any]: holder = persons.get(grant.person_id) handover_from = ( persons.get(grant.handover_from_person_id) if grant.handover_from_person_id else None ) confirmed_by = ( persons.get(str(grant.removal_confirmed_by_person_id)) if grant.removal_confirmed_by_person_id else None ) return { "id": grant.id, "person_id": grant.person_id, "person_label": ( (holder.display_name or holder.work_email) if holder else None ), "sso_provider": grant.sso_provider, "system_name": grant.system_name, "system_owner_label": grant.system_owner_label, "system_owner_person_id": grant.system_owner_person_id, "reason": grant.reason, "status": grant.status, "feishu_instance_code": grant.feishu_instance_code, "granted_at": grant.granted_at.isoformat() if grant.granted_at else None, "revoked_at": grant.revoked_at.isoformat() if grant.revoked_at else None, "revoke_reason": grant.revoke_reason, "handover_from_person_id": grant.handover_from_person_id, "handover_from_label": ( (handover_from.display_name or handover_from.work_email) if handover_from else None ), "handover_at": ( grant.handover_at.isoformat() if grant.handover_at else None ), "acs_url": grant.acs_url, "entity_id": grant.entity_id, # X.509 cert + metadata XML omitted from the default # serialization — they're large and only meaningful for the # post-approval Google API call. The catalog detail endpoint # surfaces them when an admin clicks into a row. "has_saml_metadata_xml": bool(grant.saml_metadata_xml), "created_app_id": grant.created_app_id, "created_at": grant.created_at.isoformat(), "updated_at": grant.updated_at.isoformat(), # Removal lifecycle (migration 603) — the IdP-side half of a revoke. **SsoRemovalService.serialize(grant), "removal_confirmed_by_label": ( (confirmed_by.display_name or confirmed_by.work_email) if confirmed_by else None ), } async def _hydrate(db: AsyncSession, rows: list[SsoGrant]) -> list[dict[str, Any]]: if not rows: return [] person_ids: set[str] = set() for r in rows: person_ids.add(r.person_id) if r.handover_from_person_id: person_ids.add(r.handover_from_person_id) if r.removal_confirmed_by_person_id: person_ids.add(str(r.removal_confirmed_by_person_id)) persons: dict[str, IdentityPerson] = {} if person_ids: rows_p = (await db.execute( select(IdentityPerson).where( IdentityPerson.id.in_([uuid.UUID(p) for p in person_ids]), ) )).scalars().all() persons = {str(p.id): p for p in rows_p} return [_serialize(r, persons) for r in rows] @router.post("/grants", status_code=201) async def submit_sso_grant( body: SsoGrantSubmitBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = SsoGrantService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") grant = await svc.submit( requester=user, sso_provider=body.sso_provider, system_name=body.system_name, system_owner_person_id=body.system_owner_person_id, reason=body.reason, trace_id=trace_id, acs_url=body.acs_url, entity_id=body.entity_id, x509_cert=body.x509_cert, saml_metadata_xml=body.saml_metadata_xml, ) await db.commit() [data] = await _hydrate(db, [grant]) return success_response(data=data, request=request) @router.get("/grants") async def list_sso_grants( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), scope: str = Query("auto", pattern="^(auto|mine|all)$"), status: str | None = Query( None, pattern=( f"^({SSO_GRANT_STATUS_PENDING}|{SSO_GRANT_STATUS_APPROVED}|" f"{SSO_GRANT_STATUS_REJECTED}|{SSO_GRANT_STATUS_CANCELLED}|" f"{SSO_GRANT_STATUS_REVOKED})$" ), ), removal: str | None = Query( None, pattern="^(open|pending|confirmed|verified|contradicted|unverified_legacy|check_error)$", description=( "Removal-lifecycle filter (admin ledger). ``open`` = pending + " "contradicted + unverified_legacy; ``check_error`` = rows whose last " "IdP verification could not run." ), ), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ): """List SSO grants visible to the caller. ``scope=mine`` always self-filters; ``scope=all`` is admin-only; ``scope=auto`` (default) self-filters for non-admins and lists everything for admins. """ # Direct attribute access per ``feedback_no_getattr_default_for_model_columns`` # memory rule: getattr fallback silently returns False if the # property is ever renamed, which would silently downgrade every # admin's scope=all SSO-grant query to scope=mine. Fail-loud is # better than silent role downgrade. is_admin = bool(user.is_admin) if scope == "all" and not is_admin: scope = "mine" if scope == "auto": scope = "all" if is_admin else "mine" q = select(SsoGrant) if scope == "mine": q = q.where(SsoGrant.person_id == str(user.id)) if status: q = q.where(SsoGrant.status == status) if removal: q = q.where(SsoGrant.status == SSO_GRANT_STATUS_REVOKED) if removal == "open": q = q.where(SsoGrant.removal_status.in_(REMOVAL_OPEN_STATUSES)) elif removal == "check_error": q = q.where(SsoGrant.removal_check_error.isnot(None)) else: q = q.where(SsoGrant.removal_status == removal) count_q = select(_func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar() or 0 q = ( q.order_by(SsoGrant.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) rows = (await db.execute(q)).scalars().all() return success_response( data=await _hydrate(db, list(rows)), request=request, meta={"total": total, "page": page, "page_size": page_size}, ) @router.post("/grants/{grant_id}/cancel") async def cancel_sso_grant( grant_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = SsoGrantService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") grant = await svc.cancel(grant_id=str(grant_id), actor=user, trace_id=trace_id) await db.commit() [data] = await _hydrate(db, [grant]) # False ⇒ Helix cancelled but the parallel Feishu instance is still # pending (e.g. template withdraw disabled) — frontend warns. data["feishu_cancel_synced"] = svc.last_feishu_cancel_synced return success_response(data=data, request=request) @router.post("/grants/{grant_id}/handover") async def handover_sso_grant( grant_id: uuid.UUID, body: SsoGrantHandoverBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = SsoGrantService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") grant = await svc.handover( grant_id=str(grant_id), successor_person_id=body.successor_person_id, actor=user, trace_id=trace_id, ) await db.commit() [data] = await _hydrate(db, [grant]) return success_response(data=data, request=request) @router.post("/grants/{grant_id}/revoke") async def revoke_sso_grant( grant_id: uuid.UUID, body: SsoGrantRevokeBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = SsoGrantService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") grant = await svc.revoke( grant_id=str(grant_id), actor=user, reason=body.reason, trace_id=trace_id, ) await db.commit() [data] = await _hydrate(db, [grant]) return success_response(data=data, request=request) class SsoRemovalOpenBody(BaseModel): """Bulk "open a review task" for legacy / task-less revoked grants.""" grant_ids: list[str] = Field(min_length=1, max_length=200) @router.get("/grants/removal-summary") async def sso_removal_summary( request: Request, _user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ): """Status strip for the admin ledger: how many revokes are still not established at the IdP, how many are overdue, how many could not be verified (and why), plus the cadence settings in force.""" data = await SsoRemovalService(db).summary() return success_response(data=data, request=request) @router.post("/grants/removal/open-tasks") async def sso_removal_open_tasks( body: SsoRemovalOpenBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Turn ``unverified_legacy`` (or task-less ``pending``) revokes into real work: one manual_notify task each for the system owner (IT fallback).""" trace_id = str(getattr(request.state, "trace_id", "") or "") result = await SsoRemovalService(db).open_review_for_legacy( body.grant_ids, actor_user_id=user.id, trace_id=trace_id, ) await db.commit() return success_response(data=result, request=request) @router.post("/grants/{grant_id}/removal/verify") async def sso_removal_verify_now( grant_id: uuid.UUID, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Run the IdP evidence check for one grant right now (Google only). Bypasses the once-a-day floor; honours everything else the cron does (name alignment, verified_after_days). Returns the outcome plus the refreshed grant so the drawer can render the new state without a second fetch.""" grant = await db.get(SsoGrant, str(grant_id)) if grant is None: raise NotFoundError("SsoGrant", str(grant_id)) trace_id = str(getattr(request.state, "trace_id", "") or "") settings = await load_removal_settings(db) driver = None driver_error: str | None = None try: driver = await google_driver_for_org(db) except Exception as exc: # noqa: BLE001 driver_error = f"{type(exc).__name__}: {exc}"[:200] svc = SsoRemovalService(db) outcome = await svc.verify_grant( grant, settings=settings, now=datetime.now(UTC), driver=driver, trace_id=trace_id, force=True, ) if driver_error and outcome.get("result", "").startswith("skipped_no_driver"): outcome["driver_error"] = driver_error # Mirror an outcome onto any attestation item that revoked this grant. if outcome.get("result") in ("verified", "contradicted"): from app.core.coordinators.access_certification_coordinator import ( # noqa: PLC0415 sync_cert_items_for_sso_grant, ) await sync_cert_items_for_sso_grant( db, grant_id=grant.id, removal_status=grant.removal_status, now=datetime.now(UTC), ) await db.commit() # ``updated_at`` is an ``onupdate=func.now()`` column: after the commit # it is expired on the instance and the serializer's attribute read # would lazy-load it outside the greenlet (MissingGreenlet → 500, seen # in the browser walk-through). Refresh explicitly before serialising. await db.refresh(grant) [data] = await _hydrate(db, [grant]) return success_response(data={"outcome": outcome, "grant": data}, request=request) @router.get("/grants/leaver/{person_id}") async def list_leaver_handovers( person_id: str, request: Request, user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ): """Active SSO grants held by ``person_id``. Used by the offboarding UI to enumerate what needs handover before the leave date.""" svc = SsoGrantService(db) grants = await svc.list_pending_handovers_for_person(person_id) return success_response( data=await _hydrate(db, grants), request=request, ) @router.get("/providers") async def list_sso_providers( request: Request, _user: AuthUser = Depends(get_current_user), ): """Return the SSO platform options the Feishu template enumerates, so the frontend dropdown stays server-driven. Pre-launch hardening 2026-05-04: requires authentication. Previously this endpoint had no auth dependency — informational only today but a regression risk if a future field bleed leaks per-tenant config. """ return success_response( data=list(SSO_PROVIDERS), request=request, )