"""Central approval-request core for agent action approval. Provides the ``ApprovalRequest`` model and service helpers used by the ReAct agent loop to pause before executing tools that require human approval, and by the approvals API routes to create / resolve requests. Status lifecycle: ``pending`` → ``approved`` | ``rejected`` | ``expired``. """ from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import DateTime, Index, String, Text, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin # Valid statuses. APPROVAL_STATUSES = ("pending", "approved", "rejected", "expired") # Valid requested_by_type values. REQUESTER_TYPES = ("user", "agent", "system") class ApprovalRequest(Base, TenantMixin): """A request for human approval of an agent action.""" __tablename__ = "approval_requests" __table_args__ = ( Index("ix_approval_requests_tenant_status", "tenant_id", "status"), Index("ix_approval_requests_tenant_entity", "tenant_id", "entity_type", "entity_id"), Index("ix_approval_requests_tenant_approver", "tenant_id", "approver_id"), ) id: Mapped[uuid.UUID] = mapped_column( PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) entity_type: Mapped[str] = mapped_column(String(80), nullable=False) entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False) action: Mapped[str] = mapped_column(String(120), nullable=False) requested_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False) requested_by_type: Mapped[str] = mapped_column( String(20), nullable=False, default="agent" ) approver_id: Mapped[uuid.UUID | None] = mapped_column( PGUUID(as_uuid=True), nullable=True ) approver_group: Mapped[str | None] = mapped_column(String(120), nullable=True) # F11: who actually decided — approver_id stays the ASSIGNMENT, # resolved_by records the ACTUAL decider (previously the assignment # was overwritten by whoever decided). resolved_by: Mapped[uuid.UUID | None] = mapped_column( PGUUID(as_uuid=True), nullable=True ) status: Mapped[str] = mapped_column( String(20), nullable=False, default="pending" ) comment: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) resolved_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) expires_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) request_metadata: Mapped[dict[str, Any]] = mapped_column( "metadata", JSONB, nullable=False, default=dict ) async def create_approval_request( db: AsyncSession, tenant_id: uuid.UUID, *, entity_type: str, entity_id: uuid.UUID, action: str, requested_by: uuid.UUID, requested_by_type: str = "agent", approver_id: uuid.UUID | None = None, approver_group: str | None = None, expires_at: datetime | None = None, metadata: dict[str, Any] | None = None, ) -> ApprovalRequest: """Create a new pending approval request.""" req = ApprovalRequest( tenant_id=tenant_id, entity_type=entity_type, entity_id=entity_id, action=action, requested_by=requested_by, requested_by_type=requested_by_type, approver_id=approver_id, approver_group=approver_group, status="pending", expires_at=expires_at, request_metadata=metadata or {}, ) db.add(req) await db.flush() return req class ApprovalDecisionError(Exception): """Raised when an approval decision is invalid (F11/Astra). Attributes: code: machine-readable reason for the HTTP layer. http_status: suggested HTTP status code. """ def __init__(self, code: str, message: str, http_status: int = 403): super().__init__(message) self.code = code self.http_status = http_status async def _user_in_approver_group( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, group_name: str ) -> bool: """Check whether the user is a member of the named approver group.""" from sqlalchemy import select from app.models.group import Group, UserGroup result = await db.execute( select(UserGroup.id) .join(Group, UserGroup.group_id == Group.id) .where( UserGroup.user_id == user_id, UserGroup.tenant_id == tenant_id, Group.name == group_name, ) .limit(1) ) return result.first() is not None async def resolve_approval_request( db: AsyncSession, tenant_id: uuid.UUID, request_id: uuid.UUID, *, decision: str, approver_id: uuid.UUID, comment: str | None = None, is_system_admin: bool = False, ) -> ApprovalRequest | None: """Approve or reject a pending approval request (F11 hardened). Returns the updated request, or ``None`` if not found. Raises ApprovalDecisionError when the decision is invalid: - ``expired`` (410): the request's expires_at has passed — it is marked expired and can no longer be decided. - ``not_pending`` (409): the request was already decided concurrently. - ``wrong_approver``(403): the acting user is neither the assigned approver (approver_id) nor a member of the assigned approver_group. Unassigned requests (no approver_id AND no approver_group) may be decided by anyone holding approvals:approve; system admins may decide any request (documented operations override). The assignment (approver_id) is NEVER overwritten — the actual decider is recorded in resolved_by (F11: assignment and decider are separate). """ from datetime import UTC, datetime from sqlalchemy import select, update if decision not in ("approved", "rejected"): raise ValueError(f"invalid decision: {decision!r}") result = await db.execute( select(ApprovalRequest).where( ApprovalRequest.id == request_id, ApprovalRequest.tenant_id == tenant_id, ) ) req = result.scalar_one_or_none() if req is None: return None # 1. Expiry check — an expired request can no longer be decided. if ( req.status == "pending" and req.expires_at is not None and req.expires_at < datetime.now(UTC) ): req.status = "expired" req.resolved_at = datetime.now(UTC) await db.flush() raise ApprovalDecisionError( "expired", "Approval request has expired", http_status=410 ) # 2. Approver check — who may decide this request? if not is_system_admin: assigned_user = req.approver_id assigned_group = req.approver_group allowed = False if assigned_user is not None: allowed = assigned_user == approver_id if not allowed and assigned_group: allowed = await _user_in_approver_group( db, tenant_id, approver_id, assigned_group ) if not allowed and assigned_user is None and assigned_group is None: # Unassigned request: anyone with approvals:approve may decide. allowed = True if not allowed: raise ApprovalDecisionError( "wrong_approver", "This approval request is assigned to a different approver", http_status=403, ) # 3. Atomic status transition — a concurrent decision must not win twice. now = datetime.now(UTC) upd = await db.execute( update(ApprovalRequest) .where( ApprovalRequest.id == request_id, ApprovalRequest.tenant_id == tenant_id, ApprovalRequest.status == "pending", ) .values( status=decision, resolved_by=approver_id, comment=comment, resolved_at=now, ) ) if upd.rowcount == 0: raise ApprovalDecisionError( "not_pending", "Approval request was already decided", http_status=409, ) await db.refresh(req) return req async def expire_approval_request( db: AsyncSession, tenant_id: uuid.UUID, request_id: uuid.UUID, ) -> ApprovalRequest | None: """Mark a pending approval request as expired (system only).""" from sqlalchemy import select result = await db.execute( select(ApprovalRequest).where( ApprovalRequest.id == request_id, ApprovalRequest.tenant_id == tenant_id, ) ) req = result.scalar_one_or_none() if req is None or req.status != "pending": return None req.status = "expired" req.resolved_at = datetime.now(UTC) await db.flush() return req