"""DAX grant lifecycle endpoints (migration 606). Admin console (``/admin/data-warehouse/grants``): GET /data-access/admin/grants inventory + lifecycle filters GET /data-access/admin/grants/summary KPI strip + effective settings GET /data-access/admin/grants/lifecycle-settings read the policy PUT /data-access/admin/grants/lifecycle-settings change it (global_admin) POST /data-access/admin/grants/{id}/extend extend a personal grant POST /data-access/admin/grants/{id}/revoke admin revoke GET /data-access/admin/grants/{id}/events timeline Employee (``/my/data-access/grants``): GET /data-access/me/lifecycle-policy TTL / renewal rules for the UI POST /data-access/me/grants/{id}/renew holder renews POST /data-access/me/grants/{id}/release holder / owner gives it up GET /data-access/me/owner-confirmations SA confirmations waiting on me POST /data-access/me/owner-confirmations/{id}/respond Reads are open to global_admin + auditor + management; writes to the policy and admin verbs are global_admin only. Holder verbs authorise inside the service (holder / technical owner / dataset owner / group owner / admin). """ from __future__ import annotations from datetime import UTC, datetime from typing import Literal from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, ConfigDict, Field 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 from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import AppException from app.core.idempotent_route import idempotent from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role from app.core.response import paginated_response, success_response from app.domains.data_access.lifecycle import ( LIFECYCLE_STATES, DataAccessLifecycleService, LifecycleSettings, lifecycle_fields, load_lifecycle_settings, save_lifecycle_settings, ) from app.identity.person_models import IdentityPerson router = APIRouter( prefix="/api/v1/data-access", tags=["data-access-lifecycle"], dependencies=[Depends(_leaver_runtime_gate)], ) def _trace(request: Request) -> str: return str(getattr(request.state, "trace_id", "") or "") def _require_read(current_user: AuthUser) -> None: if has_role(current_user.role_names, GLOBAL_ADMIN_ROLE, AUDITOR_ROLE, MANAGEMENT_ROLE): return raise AppException( code="DATA_ACCESS_ADMIN_READ_REQUIRED", message="Only global admins, auditors and management may read the DAX grant inventory.", status_code=403, ) def _require_admin(current_user: AuthUser) -> None: if has_role(current_user.role_names, GLOBAL_ADMIN_ROLE): return raise AppException( code="DATA_ACCESS_ADMIN_REQUIRED", message="Only global admins may change DAX grants or their lifecycle policy.", status_code=403, ) async def _actor(db: AsyncSession, current_user: AuthUser) -> IdentityPerson: person = await db.get(IdentityPerson, current_user.id) if person is None: raise AppException(code="ACTOR_NOT_FOUND", message="Acting person not found", status_code=400) return person # ── schemas ── class LifecycleSettingsBody(BaseModel): model_config = ConfigDict(extra="forbid") personal_ttl_days: int = Field(ge=30, le=730) reminder_ladder_days: list[int] = Field(min_length=1, max_length=6) renewal_extends_days: int = Field(ge=7, le=730) self_renewals_before_reapproval: int = Field(ge=0, le=12) employee_push_enabled: bool auto_revoke_on_expiry: bool sa_confirmation_interval_days: int = Field(ge=30, le=730) sa_confirmation_grace_days: int = Field(ge=0, le=180) owner_confirmation_push_enabled: bool auto_revoke_on_confirmation_lapse: bool class ExtendBody(BaseModel): model_config = ConfigDict(extra="forbid") days: int = Field(ge=1, le=730) reason: str = Field(min_length=1, max_length=500) class ReasonBody(BaseModel): model_config = ConfigDict(extra="forbid") reason: str | None = Field(default=None, max_length=500) class RenewBody(BaseModel): model_config = ConfigDict(extra="forbid") reason: str = Field(min_length=1, max_length=500) class OwnerRespondBody(BaseModel): model_config = ConfigDict(extra="forbid") decision: Literal["confirm", "revoke"] note: str | None = Field(default=None, max_length=500) def _binding_payload(binding, *, now: datetime, settings: LifecycleSettings) -> dict: return { "binding_id": str(binding.id), "resource_id": str(binding.resource_id), "principal_type": binding.principal_type_snapshot, "principal_email": binding.principal_email_snapshot, "binding_status": binding.status, "reconcile_status": binding.reconcile_status, "revoke_reason": binding.revoke_reason, "revoked_at": binding.revoked_at.isoformat() if binding.revoked_at else None, **lifecycle_fields(binding, now=now, settings=settings), } # ── admin ── @router.get("/admin/grants", summary="DAX grant inventory with lifecycle filters") async def admin_list_grants( request: Request, principal_type: Literal["google_user", "google_group", "gcp_service_account"] | None = Query(None), lifecycle: str | None = Query(None, description="ok | expiring | expired | confirmation_due | confirmation_overdue | lifecycle_revoked"), status: Literal["active", "revoked_pending", "revoked"] | None = Query(None), search: str | None = Query(None, max_length=255), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_read(current_user) if lifecycle is not None and lifecycle not in (*LIFECYCLE_STATES, "lifecycle_revoked"): raise AppException( code="DATA_ACCESS_LIFECYCLE_FILTER_INVALID", message="lifecycle must be one of ok, expiring, expired, confirmation_due, confirmation_overdue, lifecycle_revoked", status_code=422, field="lifecycle", ) settings = await load_lifecycle_settings(db) items, total = await DataAccessLifecycleService(db).list_admin_grants( now=datetime.now(UTC), settings=settings, principal_type=principal_type, lifecycle=lifecycle, status=status, search=search, page=page, page_size=page_size, ) return paginated_response(data=items, total=total, page=page, page_size=page_size, request=request) @router.get("/admin/grants/summary", summary="DAX grant lifecycle KPI strip") async def admin_grants_summary( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_read(current_user) settings = await load_lifecycle_settings(db) data = await DataAccessLifecycleService(db).summary(now=datetime.now(UTC), settings=settings) return success_response(data=data, request=request) @router.get("/admin/grants/lifecycle-settings", summary="Read the DAX grant lifecycle policy") async def get_lifecycle_settings( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_read(current_user) settings = await load_lifecycle_settings(db) return success_response(data=settings.to_setting(), request=request) @router.put("/admin/grants/lifecycle-settings", summary="Change the DAX grant lifecycle policy") async def put_lifecycle_settings( request: Request, body: LifecycleSettingsBody, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(current_user) previous = await load_lifecycle_settings(db) settings = LifecycleSettings.from_setting(body.model_dump()) await save_lifecycle_settings(db, settings) from app.audit.service import AuditService # noqa: PLC0415 await AuditService(db).log( trace_id=_trace(request), actor_type="user", actor_user_id=current_user.id, action="data_access.lifecycle_settings.updated", status="success", resource_type="platform_setting", resource_id="data_access.grant_lifecycle", detail={"before": previous.to_setting(), "after": settings.to_setting()}, ) await db.commit() return success_response(data=settings.to_setting(), request=request) @router.post("/admin/grants/{binding_id}/extend", summary="Extend a personal DAX grant") @idempotent("data_access_grant.extend", key_params=("binding_id", "user.id")) async def admin_extend_grant( request: Request, binding_id: UUID, body: ExtendBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) actor = await _actor(db, user) settings = await load_lifecycle_settings(db) binding = await DataAccessLifecycleService(db).extend( binding_id, actor=actor, days=body.days, reason=body.reason, trace_id=_trace(request), ) await db.commit() await db.refresh(binding) return success_response( data=_binding_payload(binding, now=datetime.now(UTC), settings=settings), request=request, ) @router.post("/admin/grants/{binding_id}/revoke", summary="Admin revokes a DAX grant") @idempotent("data_access_grant.admin_revoke", key_params=("binding_id", "user.id")) async def admin_revoke_grant( request: Request, binding_id: UUID, body: ReasonBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) actor = await _actor(db, user) settings = await load_lifecycle_settings(db) binding = await DataAccessLifecycleService(db).revoke_as_admin( binding_id, actor=actor, reason_note=body.reason, trace_id=_trace(request), ) await db.commit() await db.refresh(binding) return success_response( data=_binding_payload(binding, now=datetime.now(UTC), settings=settings), request=request, ) @router.get("/admin/grants/{binding_id}/events", summary="Lifecycle timeline of one DAX grant") async def admin_grant_events( request: Request, binding_id: UUID, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_read(current_user) svc = DataAccessLifecycleService(db) events = await svc.events_for(binding_id) actor_ids = {e.actor_person_id for e in events if e.actor_person_id} labels = await svc._person_labels(actor_ids) # noqa: SLF001 — same module family return success_response( data=[ { "id": str(e.id), "event_kind": e.event_kind, "actor_type": e.actor_type, "actor_person_id": e.actor_person_id, "actor_label": (labels.get(e.actor_person_id) or {}).get("display_name") if e.actor_person_id else None, "detail": e.detail or {}, "created_at": e.created_at.isoformat() if e.created_at else None, } for e in events ], request=request, ) # ── employee ── @router.get("/me/lifecycle-policy", summary="DAX grant lifecycle rules as they apply to me") async def my_lifecycle_policy( request: Request, _: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): s = await load_lifecycle_settings(db) return success_response( data={ "personal_ttl_days": s.personal_ttl_days, "reminder_ladder_days": list(s.reminder_ladder_days), "renewal_extends_days": s.renewal_extends_days, "self_renewals_before_reapproval": s.self_renewals_before_reapproval, "sa_confirmation_interval_days": s.sa_confirmation_interval_days, "sa_confirmation_grace_days": s.sa_confirmation_grace_days, "auto_revoke_on_expiry": s.auto_revoke_on_expiry, "auto_revoke_on_confirmation_lapse": s.auto_revoke_on_confirmation_lapse, }, request=request, ) @router.post("/me/grants/{binding_id}/renew", summary="Renew my personal DAX grant") @idempotent("data_access_grant.renew", key_params=("binding_id", "user.id")) async def renew_my_grant( request: Request, binding_id: UUID, body: RenewBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): actor = await _actor(db, user) settings = await load_lifecycle_settings(db) binding = await DataAccessLifecycleService(db).renew( binding_id, actor=actor, reason=body.reason, settings=settings, trace_id=_trace(request), ) await db.commit() await db.refresh(binding) return success_response( data=_binding_payload(binding, now=datetime.now(UTC), settings=settings), request=request, ) @router.post("/me/grants/{binding_id}/release", summary="Give up a DAX grant I hold or own") @idempotent("data_access_grant.release", key_params=("binding_id", "user.id")) async def release_my_grant( request: Request, binding_id: UUID, body: ReasonBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): actor = await _actor(db, user) settings = await load_lifecycle_settings(db) binding = await DataAccessLifecycleService(db).release( binding_id, actor=actor, reason_note=body.reason, trace_id=_trace(request), ) await db.commit() await db.refresh(binding) return success_response( data=_binding_payload(binding, now=datetime.now(UTC), settings=settings), request=request, ) @router.get("/me/owner-confirmations", summary="Service-account grant confirmations waiting on me") async def my_owner_confirmations( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): now = datetime.now(UTC) settings = await load_lifecycle_settings(db) rows = await DataAccessLifecycleService(db).list_owner_confirmations_for(current_user.id, now=now) data = [] for binding, resource in rows: data.append({ **_binding_payload(binding, now=now, settings=settings), "locator": ".".join(filter(None, (resource.project_id, resource.dataset_id, resource.table_id))), "display_name": resource.display_name, "granted_at": binding.granted_at.isoformat() if binding.granted_at else None, "reminder_count": int(binding.owner_confirmation_reminder_count or 0), "grace_days": settings.sa_confirmation_grace_days, }) return success_response(data=data, request=request) @router.post( "/me/owner-confirmations/{binding_id}/respond", summary="Answer a service-account grant confirmation (confirm / revoke)", ) @idempotent("data_access_grant.owner_respond", key_params=("binding_id", "user.id")) async def respond_owner_confirmation( request: Request, binding_id: UUID, body: OwnerRespondBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): actor = await _actor(db, user) settings = await load_lifecycle_settings(db) binding = await DataAccessLifecycleService(db).respond_owner_confirmation( binding_id, actor=actor, decision=body.decision, note=body.note, settings=settings, trace_id=_trace(request), ) await db.commit() await db.refresh(binding) return success_response( data=_binding_payload(binding, now=datetime.now(UTC), settings=settings), request=request, )