"""HTTP API for the identity golden record (Identity Hub spine). GET /api/v1/identity/persons/{id}/identity-profile full golden record — login bindings, downstream projection matrix (+ drift), signal-derived attribute provenance, and the identity change timeline. PATCH /api/v1/identity/persons/{id}/external-identities/{ext_id} admin kill-switch / suspend on one login binding. Read is gated at ``require_hr_read`` (the same tier that can open ``GET /users/{id}``); the kill-switch mutation is ``global_admin`` only and behind the browser-session gate, because disabling a login binding is a security action. """ from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel 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_real_ip, require_admin, require_browser_session, require_hr_read, ) from app.core.response import success_response from app.identity.hub_service import IdentityHubService from app.identity.profile_service import IdentityProfileService router = APIRouter(prefix="/api/v1/identity", tags=["identity"]) @router.get( "/hub/overview", summary="Identity Hub landing cockpit — composed identity-platform snapshot", ) async def get_hub_overview( request: Request, _user: AuthUser = Depends(require_hr_read()), db: AsyncSession = Depends(get_db), ): data = await IdentityHubService(db).overview() return success_response(data=data, request=request) @router.get( "/field-catalog", summary="Read-only identity field dictionary (user + department fields)", ) async def get_field_catalog( request: Request, _user: AuthUser = Depends(require_hr_read()), ): from app.identity.field_catalog import build_field_catalog return success_response(data=build_field_catalog(), request=request) class ExternalIdentityLoginUpdate(BaseModel): is_login_identity: bool | None = None identity_status: str | None = None @router.get( "/persons/{person_id}/identity-profile", summary="Identity golden record for one person", ) async def get_identity_profile( person_id: UUID, request: Request, drift: bool = Query( True, description=( "Re-render the live mapping payload per target to flag drift " "(payload changed since last push). Set false to skip the " "render cost when only the static state is needed." ), ), _user: AuthUser = Depends(require_hr_read()), db: AsyncSession = Depends(get_db), ): svc = IdentityProfileService(db) data = await svc.get_identity_profile(person_id, include_drift=drift) return success_response(data=data, request=request) @router.patch( "/persons/{person_id}/external-identities/{ext_identity_id}", summary="Toggle a login binding's kill-switch / suspend state", ) async def update_external_identity_login( person_id: UUID, ext_identity_id: UUID, body: ExternalIdentityLoginUpdate, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), _gate: None = Depends(require_browser_session), ): svc = IdentityProfileService(db) data = await svc.set_external_identity_login( person_id=person_id, ext_identity_id=ext_identity_id, is_login_identity=body.is_login_identity, identity_status=body.identity_status, actor_user_id=user.id, trace_id=getattr(request.state, "trace_id", ""), ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), ) return success_response(data=data, request=request)