"""Software permission endpoints.""" from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user, require_admin from app.core.dependencies import ( # noqa: E402 require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import ( AppException, ForbiddenError, is_transaction_aborting_db_error, ) from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role from app.core.response import paginated_response, success_response from app.core.safe_logging import get_safe_logger from app.domains.permission.models import ( PERM_EXPIRED, PERM_REJECTED, PERM_REVOKED, UserPermission, ) from app.domains.permission.schemas import ( AdminBatchRevokeRequest, AdminGrantRequest, AdminRevokeRequest, ApprovalDecisionRequest, PermissionApprovalSettingsBody, PermissionCreate, PermissionUpdate, UserPermissionRequest, ) from app.domains.permission.service import PermissionService router = APIRouter( prefix="/api/v1/permissions", tags=["permissions"], dependencies=[Depends(_leaver_runtime_gate)], ) logger = get_safe_logger(__name__) # ── Catalog (admin) ── @router.get("/catalog") async def list_catalog( request: Request, software_id: UUID | None = Query(None), active_only: bool = Query(False), _u: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) items = await svc.list_permissions(software_id=software_id, active_only=active_only) return success_response(data=items, request=request) @router.post("/catalog") async def create_permission( request: Request, body: PermissionCreate, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" item = await svc.create_permission(body, trace_id=trace_id, actor_user_id=current_user.id) await db.commit() return success_response(data=item, request=request) @router.patch("/catalog/{permission_id}") async def update_permission( request: Request, permission_id: UUID, body: PermissionUpdate, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" item = await svc.update_permission(permission_id, body, trace_id=trace_id, actor_user_id=current_user.id) await db.commit() return success_response(data=item, request=request) @router.delete("/catalog/{permission_id}") async def delete_permission( request: Request, permission_id: UUID, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" await svc.delete_permission(permission_id, trace_id=trace_id, actor_user_id=current_user.id) await db.commit() return success_response(data=None, request=request) # ── User grants ── @router.post("/request") async def request_permission( request: Request, body: UserPermissionRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" row = await svc.request_permission( current_user.id, body.permission_id, reason=body.reason, trace_id=trace_id, ) await db.commit() items, _ = await svc.list_user_permissions(user_id=current_user.id, permission_id=body.permission_id) return success_response( data=next((i for i in items if i.id == row.id), items[0] if items else None), request=request, ) @router.post("/requests/{user_permission_id}/cancel") async def cancel_permission_request( request: Request, user_permission_id: UUID, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Requester withdraws their own still-pending permission request. Lifecycle-verb parity with sso / vpn / domain / cloud-scope / asset request withdrawal. Surfaced inline in the unified '我的申请' tracker. """ svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" await svc.cancel_request( user_permission_id, actor_user_id=current_user.id, trace_id=trace_id, ) await db.commit() return success_response(data={"cancelled": True}, request=request) @router.get("/my") async def my_permissions( request: Request, status: str | None = Query(None), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) items, total = await svc.list_user_permissions( user_id=current_user.id, status=status, page=1, page_size=200, viewer_id=current_user.id, ) return paginated_response( data=items, total=total, page=1, page_size=200, request=request, ) # ── In-app approval (migration 606) ── @router.get("/approvals") async def list_permission_approvals( request: Request, scope: str = Query("mine", pattern="^(mine|all)$"), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """``pending_approval`` permission requests the caller may decide. ``scope=mine`` — requests whose software names the caller as owner / IT contact (the software-owner to-do). ``scope=all`` — every pending request (global_admin + management read; the IT fallback surface). """ is_admin = has_role(current_user.role_names, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE) if scope == "all" and not is_admin: raise ForbiddenError("Only global admins may list every pending permission request.") svc = PermissionService(db) items, total = await svc.list_pending_approvals( approver_id=current_user.id, include_all=(scope == "all"), page=page, page_size=page_size, ) return paginated_response( data=items, total=total, page=page, page_size=page_size, request=request, ) @router.post("/approvals/{user_permission_id}/approve") async def approve_permission_request( request: Request, user_permission_id: UUID, body: ApprovalDecisionRequest | None = None, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" row = await svc.approve_request( user_permission_id, actor_user_id=current_user.id, note=body.note if body else None, valid_days=body.valid_days if body else None, trace_id=trace_id, ) await db.commit() items, _ = await svc.list_user_permissions( user_id=row.user_id, permission_id=row.permission_id, viewer_id=current_user.id, viewer_is_admin=has_role(current_user.role_names, GLOBAL_ADMIN_ROLE), ) return success_response( data=next((i for i in items if i.id == row.id), None), request=request, ) @router.post("/approvals/{user_permission_id}/reject") async def reject_permission_request( request: Request, user_permission_id: UUID, body: ApprovalDecisionRequest | None = None, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" row = await svc.reject_request( user_permission_id, actor_user_id=current_user.id, note=body.note if body else None, trace_id=trace_id, ) await db.commit() items, _ = await svc.list_user_permissions( user_id=row.user_id, permission_id=row.permission_id, viewer_id=current_user.id, viewer_is_admin=has_role(current_user.role_names, GLOBAL_ADMIN_ROLE), ) return success_response( data=next((i for i in items if i.id == row.id), None), request=request, ) @router.get("/approval-settings") async def get_permission_approval_settings( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not has_role(current_user.role_names, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE): raise ForbiddenError("Only global admins may read the permission approval policy.") from app.domains.permission.lifecycle import load_approval_settings # noqa: PLC0415 settings = await load_approval_settings(db) return success_response(data=settings.to_setting(), request=request) @router.put("/approval-settings") async def put_permission_approval_settings( request: Request, body: PermissionApprovalSettingsBody, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): from app.domains.permission.lifecycle import ( # noqa: PLC0415 ApprovalSettings, load_approval_settings, save_approval_settings, ) previous = await load_approval_settings(db) settings = ApprovalSettings.from_setting(body.model_dump()) await save_approval_settings(db, settings) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" await AuditService(db).log( trace_id=trace_id, actor_type="user", actor_user_id=current_user.id, action="permission.approval_settings.updated", status="success", resource_type="platform_setting", resource_id="permission.approval", detail={"before": previous.to_setting(), "after": settings.to_setting()}, ) await db.commit() return success_response(data=settings.to_setting(), request=request) @router.get("/grants") async def list_grants( request: Request, user_id: UUID | None = Query(None), permission_id: UUID | None = Query(None), status: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): # Self-view path: the IT 宇宙 page on /me calls this with # ``user_id=``. Cross-user reads were previously gated on # ``can_read_all_assets`` which folds in auditor + management # roles — that surface is correct for asset history reads but # leaks software-permission profiles to every auditor seat. We # restrict cross-user grants reads to global_admin + management # (read-only leadership view explicitly chartered to see the same # admin surface as global_admin per rbac.py docstring), matching # the audit / compliance scope expressed in DECISIONS for the # permission domain. Auditors who need a permission roster can # still query via the audit log surface (read-only, fully # scope-tagged). if not has_role(current_user.role_names, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE): if user_id is None: raise ForbiddenError( "You may only view your own permission grants.", ) if user_id != current_user.id: # Managers see permissions of every transitive subordinate # (matches the documented role table in CLAUDE.md and the # asset / sso / cloud sibling surfaces). Without this branch # a manager hitting /admin//permissions gets a # 403 and the "user is locked out" loop is impossible to # debug from their side. from app.core.rbac import get_subordinate_person_ids # Use the canonical default depth (10) — capping at 6 silently # dropped the deepest reports of a senior manager / VP (the # rbac docstring warns against this), producing a 403 on a # depth-7 report here while the same report is visible via the # hr/leave + asset + cloud sibling surfaces (all depth 10). subordinate_ids = await get_subordinate_person_ids( db, str(current_user.id), ) if str(user_id) not in set(subordinate_ids): raise ForbiddenError( "You may only view your own permission grants, or " "those of your direct or transitive reports.", ) svc = PermissionService(db) items, total = await svc.list_user_permissions( user_id=user_id, permission_id=permission_id, status=status, page=page, page_size=page_size, viewer_id=current_user.id, viewer_is_admin=has_role(current_user.role_names, GLOBAL_ADMIN_ROLE), ) return paginated_response( data=items, total=total, page=page, page_size=page_size, request=request, ) @router.post("/grants") async def admin_grant( request: Request, body: AdminGrantRequest, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" row = await svc.admin_grant( user_id=body.user_id, permission_id=body.permission_id, reason=body.reason, trace_id=trace_id, actor_user_id=current_user.id, valid_days=body.valid_days, ) await db.commit() items, _ = await svc.list_user_permissions(user_id=body.user_id, permission_id=body.permission_id) return success_response( data=next((i for i in items if i.id == row.id), items[0] if items else None), request=request, ) @router.post("/grants/batch-revoke") async def batch_revoke_grants( request: Request, body: AdminBatchRevokeRequest, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Revoke many grants in one request. Per-row SAVEPOINT isolation — one bad row only fails that row. Grants already revoked (or otherwise terminal: rejected / expired — nothing to revoke) → ``skipped``. Reuses the single-row ``revoke_user_permission`` service, so each succeeded row gets its own revoke provisioning task + per-row audit entry; one aggregated ``permission.grants.batch_revoke`` audit row summarises the batch. """ svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" terminal = {PERM_REVOKED, PERM_REJECTED, PERM_EXPIRED} results: list[dict] = [] succeeded = skipped = failed = 0 for grant_id in body.grant_ids: up = await db.get(UserPermission, grant_id) if up is None: failed += 1 results.append({"id": str(grant_id), "status": "failed", "error": "not_found"}) continue if up.status in terminal: skipped += 1 results.append({"id": str(grant_id), "status": "skipped"}) continue try: async with db.begin_nested(): await svc.revoke_user_permission( grant_id, reason=body.reason, trace_id=trace_id, actor_user_id=current_user.id, ) succeeded += 1 results.append({"id": str(grant_id), "status": "succeeded"}) except AppException as e: failed += 1 results.append({"id": str(grant_id), "status": "failed", "error": e.code}) except Exception as e: # noqa: BLE001 — single-row failure must not sink the batch # A lock-wait/deadlock (1205/1213) rolls back the WHOLE # transaction server-side — the savepoint is already gone, so # swallowing it here turns a transient, retryable deadlock # into a "SAVEPOINT does not exist" cascade that silently # marks every remaining grant as failed and commits a partial # batch. Re-raise so the caller restarts on a fresh tx. if is_transaction_aborting_db_error(e): raise failed += 1 logger.error( "permission.batch_revoke row failed grant_id=%s trace_id=%s " "error_type=%s", grant_id, trace_id, type(e).__name__, ) results.append({ "id": str(grant_id), "status": "failed", "error": "INTERNAL_ERROR", }) audit = AuditService(db) await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=current_user.id, action="permission.grants.batch_revoke", status="success", resource_type="user_permission", resource_id=str(body.grant_ids[0]), detail={ "total": len(body.grant_ids), "succeeded": succeeded, "skipped": skipped, "failed": failed, "ids": [str(gid) for gid in body.grant_ids[:50]], "reason": body.reason, }, ) await db.commit() return success_response( data={ "succeeded": succeeded, "skipped": skipped, "failed": failed, "results": results, }, request=request, ) @router.post("/grants/{user_permission_id}/revoke") async def revoke_grant( request: Request, user_permission_id: UUID, body: AdminRevokeRequest, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = PermissionService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" await svc.revoke_user_permission( user_permission_id, reason=body.reason, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=None, request=request)