"""Employee self-service surface for the half-yearly access self-attestation. Mounts under ``/api/v1/me/access-review``. Any authenticated employee may read THEIR OWN open campaign items and submit a batch decision over them — ``submit_self_attestation`` enforces that every item belongs to the caller, so there is no separate RBAC gate here (self-scope is the authorization). This is the holder-facing counterpart to the auditor/manager governance surface (``/api/v1/compliance/certifications`` + the auditor console). The page only renders when a campaign is open; outside a campaign the GET returns ``{"open": false}``. """ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.response import success_response from app.domains.auditor import access_certification_service as acs from app.identity.person_models import IdentityPerson router = APIRouter( prefix="/api/v1/me/access-review", tags=["access-review"], ) class DecideAssignedReviewBody(BaseModel): decision: str = Field(pattern="^(keep|revoke)$") note: str | None = Field(default=None, max_length=500) class SubmitAccessReviewBody(BaseModel): keep_item_ids: list[uuid.UUID] = Field(default_factory=list) remove_item_ids: list[uuid.UUID] = Field(default_factory=list) remove_reason: str | None = Field(default=None, max_length=500) @router.get("") async def get_my_access_review( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The currently-open self-attestation campaign as the caller sees it (summary + their own items, elevated-first). ``open=false`` when none is in progress.""" data = await acs.my_open_self_attestation(db, person_id=user.id) return success_response(data=data, request=request) @router.post("/submit") async def submit_my_access_review( body: SubmitAccessReviewBody, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Batch-decide the caller's own items: ``keep`` = still need it, ``remove`` = 我不需要了 → routes to the access type's existing self-service removal. Idempotent (already-decided items skipped).""" result = await acs.submit_self_attestation( db, actor_person_id=user.id, keep_item_ids=body.keep_item_ids, remove_item_ids=body.remove_item_ids, remove_reason=body.remove_reason, trace_id=uuid.uuid4(), ) await db.commit() return success_response(data=result, request=request) # ── Assigned reviews: the manager side of the no-response follow-up (PR5) ── # # Self-scoped like everything else in this router: being named as the # item's ``assigned_reviewer_person_id`` IS the authorization. These two # endpoints exist because the other decide paths — the compliance router # and the MCP tool — are both gated on ``can_manage_security``, which an # ordinary manager does not hold. Before PR5, an item assigned to a # manager was therefore reachable by nobody: the follow-up would have # been a database row with no door. @router.get("/assigned") async def list_assigned_reviews( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Pending items where the caller is the named reviewer. Grouped counts per holder come back alongside the rows so the tab can say "3 reports, 11 items" without a second call. """ data = await acs.assigned_reviews_for(db, reviewer_person_id=user.id) return success_response(data=data, request=request) @router.post("/assigned/{item_id}/decide") async def decide_assigned_review( item_id: uuid.UUID, body: DecideAssignedReviewBody, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Decide one item the caller reviews on a report's behalf. ``revoke`` routes through the same revocation pipeline the auditor console uses — there is one implementation of "take this access away", and this endpoint only adds the check that the caller is the item's named reviewer. """ item = await acs.decide_assigned_review( db, item_id=item_id, decision=body.decision, note=body.note, reviewer_person_id=user.id, trace_id=uuid.UUID(request.state.trace_id), ) await db.commit() return success_response( data={"id": str(item.id), "decision": item.decision}, request=request, )