diff --git a/alembic/versions/0123_approval_requests.py b/alembic/versions/0123_approval_requests.py new file mode 100644 index 0000000..2aa7494 --- /dev/null +++ b/alembic/versions/0123_approval_requests.py @@ -0,0 +1,81 @@ +"""Create approval_requests and ai_decision_records tables. + +Adds the central approval-request table for agent action approval (F-APPR) +and the AI decision-record table for the human-oversight audit trail +(F-OVERSIGHT). + +Revision ID: 0123 +Revises: 0122 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID + +revision = "0123" +down_revision = "0122" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "approval_requests", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True), + sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("entity_type", sa.String(80), nullable=False), + sa.Column("entity_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("action", sa.String(120), nullable=False), + sa.Column("requested_by", PGUUID(as_uuid=True), nullable=False), + sa.Column("requested_by_type", sa.String(20), nullable=False, server_default="agent"), + sa.Column("approver_id", PGUUID(as_uuid=True), nullable=True), + sa.Column("approver_group", sa.String(120), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + ) + op.create_index( + "ix_approval_requests_tenant_status", "approval_requests", ["tenant_id", "status"] + ) + op.create_index( + "ix_approval_requests_tenant_entity", + "approval_requests", + ["tenant_id", "entity_type", "entity_id"], + ) + op.create_index( + "ix_approval_requests_tenant_approver", + "approval_requests", + ["tenant_id", "approver_id"], + ) + + op.create_table( + "ai_decision_records", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True), + sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("agent_run_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("recommendation", sa.Text(), nullable=False), + sa.Column("evidence", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("reviewer_id", PGUUID(as_uuid=True), nullable=True), + sa.Column("decision", sa.String(20), nullable=True), + sa.Column("decision_timestamp", sa.String(40), nullable=True), + sa.Column("deviation_note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("owner_id", PGUUID(as_uuid=True), nullable=True), + ) + op.create_index( + "ix_ai_decision_records_tenant_run", "ai_decision_records", ["tenant_id", "agent_run_id"] + ) + + +def downgrade() -> None: + op.drop_index("ix_ai_decision_records_tenant_run", table_name="ai_decision_records") + op.drop_table("ai_decision_records") + op.drop_index("ix_approval_requests_tenant_approver", table_name="approval_requests") + op.drop_index("ix_approval_requests_tenant_entity", table_name="approval_requests") + op.drop_index("ix_approval_requests_tenant_status", table_name="approval_requests") + op.drop_table("approval_requests") diff --git a/app/ai/agent_loop.py b/app/ai/agent_loop.py index e7e6fb8..e014d5f 100644 --- a/app/ai/agent_loop.py +++ b/app/ai/agent_loop.py @@ -148,6 +148,7 @@ async def run_react_loop( timeout_seconds: int = 300, trace_id: str | None = None, on_step: Callable | None = None, + dry_run: bool = False, ) -> ReActResult: """Execute a ReAct loop: LLM reasoning → tool execution → repeat. @@ -164,6 +165,9 @@ async def run_react_loop( timeout_seconds: Overall timeout (default 300). trace_id: Optional trace ID for correlation. on_step: Optional async callback fired after each step. + dry_run: When True, tool execution is simulated — tool handlers are + NOT called. A mock result is returned instead and steps are still + logged with real LLM cost. Returns: ReActResult with final content, steps, cost, and status. @@ -193,6 +197,38 @@ async def run_react_loop( "db": db, } + # Audit helper — records every tool call in the audit log. + async def _audit_tool_call( + step_number: int, + tool_name: str, + arguments: dict[str, Any], + result: str, + cost_usd: float, + ) -> None: + """Create an audit log entry for a single tool call.""" + try: + from app.core.audit import log_audit + + await log_audit( + db=db, + tenant_id=tenant_id, + user_id=user_id, + action="agent.tool_call", + entity_type="agent_run", + entity_id=agent_run_id, + details={ + "agent_run_id": str(agent_run_id) if agent_run_id else None, + "step_number": step_number, + "tool_name": tool_name, + "arguments": arguments, + "result": result[:2000], + "cost_usd": cost_usd, + "dry_run": dry_run, + }, + ) + except Exception: + logger.exception("Failed to audit tool call '%s'", tool_name) + for step_num in range(1, max_steps + 1): # ── Timeout check ── elapsed = time.monotonic() - start_time @@ -327,9 +363,27 @@ async def run_react_loop( args = {} logger.warning("Invalid JSON arguments for tool '%s': %s", tool_name, tc["arguments"]) - observation = await _execute_tool(tool_registry, tool_name, args, tool_context) + if dry_run: + observation = json.dumps( + { + "dry_run": True, + "would_execute": tool_name, + "arguments": args, + } + ) + else: + observation = await _execute_tool(tool_registry, tool_name, args, tool_context) observations.append(observation) + # Audit every tool call (real or simulated) + await _audit_tool_call( + step_number=step_num, + tool_name=tool_name, + arguments=args, + result=observation, + cost_usd=cost_usd / len(tool_calls) if tool_calls else cost_usd, + ) + # Feed tool result back into conversation full_messages.append({ "role": "tool", diff --git a/app/ai/agent_permissions.py b/app/ai/agent_permissions.py new file mode 100644 index 0000000..3ce0736 --- /dev/null +++ b/app/ai/agent_permissions.py @@ -0,0 +1,230 @@ +"""Agent permission context resolution for AI agents. + +Resolves the effective permissions available to an agent run as the +intersection of the user's (or run-as user's) RBAC permissions, the agent's +configured tools/skills, and the tools each skill is allowed to use. + +Effective = User/Run-as ∩ Agent ∩ Skill ∩ Tool + +Key principles: +- Skills orchestrate tools but NEVER grant additional permissions. +- Every tool/service call re-checks permissions — rights are NOT frozen + for a run. +- System admins get all tools. +""" + +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.error_codes import ApiError +from app.core.permissions import check_permission, resolve_permissions + +logger = logging.getLogger(__name__) + + +@dataclass +class AgentPermissionContext: + """Effective permission context for a single agent run.""" + + user_id: uuid.UUID + tenant_id: uuid.UUID + run_as_user_id: uuid.UUID | None + user_permissions: dict[str, Any] # RBAC permissions from Role + agent_tool_ids: list[str] + agent_skill_ids: list[str] + effective_tool_ids: list[str] # After intersection + is_system_admin: bool = False + + def has_permission(self, permission: str) -> bool: + """Check whether the run-as user has the given RBAC permission.""" + return check_permission(self.user_permissions, permission) + + def can_use_tool(self, tool_id: str) -> bool: + """Check whether the agent may call the given tool.""" + return tool_id in self.effective_tool_ids + + +def _resolve_effective_tool_ids( + agent_definition: Any, + user_permissions: dict[str, Any], +) -> list[str]: + """Compute the effective tool IDs after User ∩ Agent ∩ Skill ∩ Tool. + + Mirrors the semantics of ``app.ai.agent_tools.get_agent_tools``: skills + orchestrate tools but never grant additional permissions. + """ + from app.ai.skill_registry import get_skill_registry + from app.plugins.builtins.ai_assistant.contracts import get_tool_registry + + agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or []) + agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or []) + + skill_registry = get_skill_registry() + skills = skill_registry.get_by_names(agent_skill_ids) + + direct_tool_ids = set(agent_tool_ids) + skill_tool_ids: set[str] = set() + for skill in skills: + skill_tool_ids.update(skill.allowed_tool_ids or []) + + # Tools directly on the agent, plus tools reachable via skills that are + # also directly on the agent (skills never widen the agent's tool set). + available_tool_ids = direct_tool_ids | (direct_tool_ids & skill_tool_ids) + + tool_registry = get_tool_registry() + tools = tool_registry.get_by_names(sorted(available_tool_ids)) + + permitted = [ + tool + for tool in tools + if not getattr(tool, "required_permission", None) + or check_permission(user_permissions, tool.required_permission) + ] + return [tool.name for tool in permitted] + + +async def resolve_agent_permissions( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + agent_definition: Any, + run_as_user_id: uuid.UUID | None = None, +) -> AgentPermissionContext: + """Resolve effective permissions for an agent run. + + Effective = User/Run-as ∩ Agent ∩ Skill ∩ Tool. + + Args: + db: Async DB session. + tenant_id: Tenant ID for multi-tenancy. + user_id: The user requesting the run (permission source). + agent_definition: AgentDefinition with tool_ids and skill_ids. + run_as_user_id: Optional user the agent runs as. When provided, the + run-as user's permissions are used instead of the requester's. + + Returns: + AgentPermissionContext with the resolved effective tool IDs. + """ + effective_user_id = run_as_user_id or user_id + user_permissions = await resolve_permissions(db, effective_user_id, tenant_id) + is_system_admin = bool(user_permissions.get("is_system_admin", False)) + + agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or []) + agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or []) + + if is_system_admin: + effective_tool_ids = list(agent_tool_ids) + else: + effective_tool_ids = _resolve_effective_tool_ids(agent_definition, user_permissions) + + return AgentPermissionContext( + user_id=user_id, + tenant_id=tenant_id, + run_as_user_id=run_as_user_id, + user_permissions=user_permissions, + agent_tool_ids=agent_tool_ids, + agent_skill_ids=agent_skill_ids, + effective_tool_ids=effective_tool_ids, + is_system_admin=is_system_admin, + ) + + +async def check_entity_lock( + db: AsyncSession, + entity_type: str, + entity_id: uuid.UUID, + expected_version: int, +) -> bool: + """Optimistic-lock check: raise ApiError('conflict') on version mismatch. + + Loads the entity's ``version`` column. If the current version differs from + ``expected_version``, raises ``ApiError`` with code ``conflict``. Models + without a ``version`` column are treated as unlocked (no-op). + + Returns True when the lock check passes. + """ + from app.services.entity_permission_service import ENTITY_MODELS + + model = ENTITY_MODELS.get(entity_type) + if model is None or not hasattr(model, "version"): + return True + + result = await db.execute(select(model.version).where(model.id == entity_id)) + current_version = result.scalar_one_or_none() + if current_version is None: + raise ApiError(code="not_found", detail=f"{entity_type} not found") + + if int(current_version) != int(expected_version): + raise ApiError( + code="conflict", + detail=( + f"{entity_type} {entity_id} was modified concurrently " + f"(expected version {expected_version}, current {current_version})" + ), + ) + return True + + +async def filter_visible_agents( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + agents: list, +) -> list: + """Filter agents visible to a user based on agents:read + EntityPermission. + + A user sees an agent when they have the ``agents:read`` permission AND the + agent is visible via ownership, tenant-ownership, or entity_permissions. + System admins see all agents. + """ + user_permissions = await resolve_permissions(db, user_id, tenant_id) + if user_permissions.get("is_system_admin"): + return list(agents) + if not check_permission(user_permissions, "agents:read"): + return [] + + from app.services.permission_resolver import get_visible_ids + + visible_ids, _ = await get_visible_ids(db, tenant_id, user_id, "agent_definition") + return [a for a in agents if a.id in visible_ids] + + +async def check_agent_execute_permission( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + agent_id: uuid.UUID, +) -> bool: + """Check if a user has agents:execute permission for a specific agent. + + Requires the ``agents:execute`` RBAC permission AND entity-level access + (owner, tenant-owned, or shared via entity_permissions). System admins + always pass. + """ + user_permissions = await resolve_permissions(db, user_id, tenant_id) + if user_permissions.get("is_system_admin"): + return True + if not check_permission(user_permissions, "agents:execute"): + return False + + from app.services.permission_resolver import check_entity_access + + return await check_entity_access( + db, tenant_id, user_id, "agent_definition", agent_id, required_level="read" + ) + + +__all__ = [ + "AgentPermissionContext", + "resolve_agent_permissions", + "check_entity_lock", + "filter_visible_agents", + "check_agent_execute_permission", +] diff --git a/app/ai/ai_use_case.py b/app/ai/ai_use_case.py new file mode 100644 index 0000000..6ea8571 --- /dev/null +++ b/app/ai/ai_use_case.py @@ -0,0 +1,156 @@ +"""AI use-case metadata and validation. + +Defines the structured metadata that describes *why* and *how* an AI agent +may process data. This is the governance contract for an agent definition: +which data categories it may touch, which providers/models/actions are +allowed, and whether human oversight is required. + +Used by: +- ``app/ai/data_policy.py`` — runtime enforcement of allowed data categories +- ``app/ai/oversight.py`` — human-review policy (``oversight_policy``) +- ``app/plugins/builtins/automation/agent_routes.py`` — PATCH/GET endpoints +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + +# ────────────────────────────────────────────────────────────────────────── +# Constants +# ────────────────────────────────────────────────────────────────────────── + +# Known data categories an agent may declare it processes. +KNOWN_DATA_CATEGORIES = ( + "contact_data", + "email_content", + "calendar", + "tasks", + "dms", + "communication", + "financial", + "public", +) + +# Valid oversight policies. +OVERSIGHT_POLICIES = ("always_required", "on_high_risk", "never") + +# Valid risk classes. +RISK_CLASSES = ("low", "medium", "high") + +# Valid allowed actions. +KNOWN_ACTIONS = ("read", "summarize", "draft", "send", "create", "update", "delete") + + +class AIUseCaseMetadata(BaseModel): + """Structured metadata describing an AI agent's intended use case. + + Attributes: + intended_purpose: Human-readable description of the use case. + owner: User ID or email responsible for the use case. + data_categories: Data categories the agent may process. + allowed_providers: Provider IDs the agent may use (empty = any). + allowed_models: Model names the agent may use (empty = any). + allowed_actions: Actions the agent may perform (empty = any). + oversight_policy: When human review is required. + risk_class: Risk classification of the use case. + human_review_required: Whether a human must review outputs. + """ + + intended_purpose: str = Field(default="", max_length=1000) + owner: str = Field(default="", max_length=255) + data_categories: list[str] = Field(default_factory=list) + allowed_providers: list[str] = Field(default_factory=list) + allowed_models: list[str] = Field(default_factory=list) + allowed_actions: list[str] = Field(default_factory=list) + oversight_policy: str = Field(default="never") + risk_class: str = Field(default="low") + human_review_required: bool = False + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "AIUseCaseMetadata": + """Build metadata from a raw dict (e.g. the agent's JSONB column).""" + if not data: + return cls() + # Only pass known fields so unknown keys don't break validation. + known = {k: v for k, v in data.items() if k in cls.model_fields} + return cls(**known) + + def to_dict(self) -> dict[str, Any]: + """Serialize to a plain dict for JSONB storage.""" + return self.model_dump() + + +# ────────────────────────────────────────────────────────────────────────── +# Validation +# ────────────────────────────────────────────────────────────────────────── + + +def validate_ai_use_case(metadata: AIUseCaseMetadata, agent_definition: Any) -> list[str]: + """Validate metadata against an agent configuration. + + Returns a list of human-readable warnings. An empty list means the + metadata is consistent with the agent definition. + + Checks performed: + - ``intended_purpose`` and ``owner`` are set. + - ``data_categories`` are known values. + - ``oversight_policy`` and ``risk_class`` are valid. + - ``allowed_models`` (if non-empty) include the agent's configured model. + - ``allowed_providers`` (if non-empty) include the agent's provider. + - ``human_review_required`` is consistent with ``oversight_policy``. + """ + warnings: list[str] = [] + + if not metadata.intended_purpose.strip(): + warnings.append("intended_purpose is empty — describe the AI use case") + + if not metadata.owner.strip(): + warnings.append("owner is empty — set a responsible user or email") + + for cat in metadata.data_categories: + if cat not in KNOWN_DATA_CATEGORIES: + warnings.append(f"data_category '{cat}' is not a known category") + + if metadata.oversight_policy not in OVERSIGHT_POLICIES: + warnings.append( + f"oversight_policy '{metadata.oversight_policy}' is invalid " + f"(expected one of {OVERSIGHT_POLICIES})" + ) + + if metadata.risk_class not in RISK_CLASSES: + warnings.append( + f"risk_class '{metadata.risk_class}' is invalid " + f"(expected one of {RISK_CLASSES})" + ) + + # Model / provider consistency (only if the agent pins allowed values). + agent_model = getattr(agent_definition, "llm_model", None) + if metadata.allowed_models and agent_model: + # Strip provider prefix for comparison (e.g. "openai/gpt-4o" -> "gpt-4o"). + bare_model = agent_model.split("/", 1)[-1] + if agent_model not in metadata.allowed_models and bare_model not in metadata.allowed_models: + warnings.append( + f"agent model '{agent_model}' is not in allowed_models {metadata.allowed_models}" + ) + + agent_provider = getattr(agent_definition, "provider", None) + if metadata.allowed_providers and agent_provider: + if agent_provider not in metadata.allowed_providers: + warnings.append( + f"agent provider '{agent_provider}' is not in allowed_providers " + f"{metadata.allowed_providers}" + ) + + # Oversight consistency. + if metadata.oversight_policy == "always_required" and not metadata.human_review_required: + warnings.append( + "oversight_policy is 'always_required' but human_review_required is False" + ) + if metadata.oversight_policy == "never" and metadata.human_review_required: + warnings.append( + "oversight_policy is 'never' but human_review_required is True" + ) + + return warnings diff --git a/app/ai/data_policy.py b/app/ai/data_policy.py new file mode 100644 index 0000000..46ed95e --- /dev/null +++ b/app/ai/data_policy.py @@ -0,0 +1,210 @@ +"""Runtime provider / data policy enforcement for AI agents. + +Filters messages and context before they reach the LLM based on: +- Sensitive fields (``app.core.sensitive_data.SENSITIVE_FIELDS``) +- AI use-case metadata (allowed data categories) +- Provider compliance (data residency / allowed data classes) + +This is the enforcement layer that guarantees an agent never sends data it +is not permitted to process to a provider that is not approved for it. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.ai.ai_use_case import AIUseCaseMetadata +from app.core.sensitive_data import ( + SENSITIVE_FIELDS, + filter_for_llm_context, + get_data_class_for_field, +) + +logger = logging.getLogger(__name__) + +# Data categories that map to entity types for sensitive-field filtering. +_CATEGORY_ENTITY_MAP = { + "contact_data": "contact", + "email_content": "mail_account", + "communication": "mail_account", +} + + +async def enforce_data_policy( + db: AsyncSession, + tenant_id: uuid.UUID, + messages: list[dict[str, Any]], + agent_definition: Any, +) -> list[dict[str, Any]]: + """Filter messages/context based on the data policy. + + Steps: + 1. Remove sensitive fields from any dict content in the messages. + 2. Check AI use-case metadata for allowed data categories. + 3. Check provider compliance for data residency requirements. + + Args: + db: Async DB session (may be ``None`` in tests / mock mode). + tenant_id: Tenant ID for provider lookup. + messages: The chat messages to filter. + agent_definition: AgentDefinition with ``ai_use_case_metadata``. + + Returns: + A new list of messages with disallowed data removed. + """ + metadata = AIUseCaseMetadata.from_dict( + getattr(agent_definition, "ai_use_case_metadata", None) + ) + + # Provider compliance (data residency / allowed data classes). + compliance: dict[str, Any] | None = None + if db is not None and tenant_id is not None: + try: + from app.ai.llm_client import get_provider_compliance + + compliance = await get_provider_compliance(db, tenant_id) + except Exception: + logger.debug("Failed to load provider compliance — skipping residency check") + + filtered: list[dict[str, Any]] = [] + for msg in messages: + content = msg.get("content", "") + if isinstance(content, dict): + content = _filter_dict_content( + content, metadata, compliance, agent_definition + ) + elif isinstance(content, list): + content = [ + _filter_dict_content(c, metadata, compliance, agent_definition) + if isinstance(c, dict) + else c + for c in content + ] + new_msg = dict(msg) + new_msg["content"] = content + filtered.append(new_msg) + + return filtered + + +def _filter_dict_content( + data: dict[str, Any], + metadata: AIUseCaseMetadata, + compliance: dict[str, Any] | None, + agent_definition: Any, +) -> dict[str, Any]: + """Filter a single dict (entity payload) against the data policy.""" + # 1. Remove sensitive fields (always blocked from LLM context). + result = _strip_sensitive_fields(data) + + # 2. Enforce allowed data categories from AI use-case metadata. + if metadata.data_categories: + result = _filter_by_allowed_categories(result, metadata.data_categories) + + # 3. Provider compliance — block fields whose data class the provider + # is not approved to process. + if compliance is not None: + result = _filter_by_provider_compliance(result, compliance) + + return result + + +def _strip_sensitive_fields(data: dict[str, Any]) -> dict[str, Any]: + """Recursively remove any key that matches a sensitive field name.""" + sensitive_names = set() + for fields in SENSITIVE_FIELDS.values(): + sensitive_names |= fields + + result: dict[str, Any] = {} + for key, value in data.items(): + if key in sensitive_names: + continue + if isinstance(value, dict): + result[key] = _strip_sensitive_fields(value) + elif isinstance(value, list): + result[key] = [ + _strip_sensitive_fields(v) if isinstance(v, dict) else v + for v in value + ] + else: + result[key] = value + return result + + +def _filter_by_allowed_categories( + data: dict[str, Any], allowed_categories: list[str] +) -> dict[str, Any]: + """Remove entity-type payloads whose category is not allowed. + + Uses the category→entity mapping to decide whether a dict represents a + disallowed entity type. Unknown dicts are kept (fail-open for generic + context that has no clear entity type). + """ + # Determine the entity type of this dict by checking for known keys. + entity_type = _guess_entity_type(data) + if entity_type is None: + return data + + category = _entity_to_category(entity_type) + if category is not None and category not in allowed_categories: + return {} + return data + + +def _filter_by_provider_compliance( + data: dict[str, Any], compliance: dict[str, Any] +) -> dict[str, Any]: + """Remove fields whose data class the provider may not process.""" + allowed_classes = compliance.get("allowed_data_classes") or [] + if not allowed_classes: + return data # No restriction configured (fail-open). + + from app.core.sensitive_data import check_provider_compliance + + result: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, dict): + result[key] = _filter_by_provider_compliance(value, compliance) + continue + # Determine data class for this field (best-effort). + data_class = _guess_data_class(key, value) + if check_provider_compliance(allowed_classes, data_class): + result[key] = value + return result + + +def _guess_entity_type(data: dict[str, Any]) -> str | None: + """Best-effort guess of the entity type from dict keys.""" + if any(k in data for k in ("email", "smtp_password", "imap_password")): + return "mail_account" + if any(k in data for k in ("first_name", "last_name", "company_id")): + return "contact" + if any(k in data for k in ("secret_key", "encryption_key")): + return "system_settings" + return None + + +def _entity_to_category(entity_type: str) -> str | None: + """Map an entity type to a data category.""" + for category, entity in _CATEGORY_ENTITY_MAP.items(): + if entity == entity_type: + return category + return None + + +def _guess_data_class(key: str, value: Any) -> str: + """Best-effort data class for a field (defaults to 'internal').""" + # Sensitive field names are always critical. + for fields in SENSITIVE_FIELDS.values(): + if key in fields: + return "critical" + # Heuristic: values that look like credentials/tokens are critical. + if isinstance(value, str) and any( + marker in key.lower() for marker in ("password", "token", "secret", "key") + ): + return "critical" + return "internal" diff --git a/app/ai/oversight.py b/app/ai/oversight.py new file mode 100644 index 0000000..e09a0ce --- /dev/null +++ b/app/ai/oversight.py @@ -0,0 +1,108 @@ +"""Human oversight and decision records for AI agents. + +Stores a durable audit trail of AI recommendations and the human decisions +made on them. A ``DecisionRecord`` captures the recommendation, the evidence +that supported it, and the reviewer's decision (approved / rejected) with an +explanation when the decision deviates from the recommendation. + +Used by: +- ``app/ai/agent_loop.py`` — recording recommendations that need review +- ``app/plugins/builtins/automation`` — agent run oversight +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import String, Text +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 +from app.models.owned_mixin import OwnedMixin + + +@dataclass +class DecisionRecord: + """A recommendation and its human decision, for the audit trail. + + Attributes: + agent_run_id: The agent run this decision belongs to. + recommendation: The AI's recommendation text. + evidence: Supporting data for the recommendation. + reviewer_id: The human reviewer (None while pending). + decision: ``approved``, ``rejected``, or ``None`` (pending). + decision_timestamp: ISO timestamp of the decision (None while pending). + deviation_note: Explanation when the decision differs from the + recommendation. + """ + + agent_run_id: uuid.UUID + recommendation: str + evidence: dict[str, Any] = field(default_factory=dict) + reviewer_id: uuid.UUID | None = None + decision: str | None = None # "approved", "rejected", None (pending) + decision_timestamp: str | None = None + deviation_note: str | None = None + + +class DecisionRecordDB(Base, TenantMixin, OwnedMixin): + """Persistent storage for AI decision records (audit trail).""" + + __tablename__ = "ai_decision_records" + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + agent_run_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), nullable=False, index=True + ) + recommendation: Mapped[str] = mapped_column(Text, nullable=False) + evidence: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, default=dict + ) + reviewer_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), nullable=True + ) + decision: Mapped[str | None] = mapped_column(String(20), nullable=True) + decision_timestamp: Mapped[str | None] = mapped_column( + String(40), nullable=True + ) + deviation_note: Mapped[str | None] = mapped_column(Text, nullable=True) + + +async def create_decision_record( + db: AsyncSession, + tenant_id: uuid.UUID, + record: DecisionRecord, +) -> uuid.UUID: + """Store a decision record for the audit trail. + + Args: + db: Async DB session. + tenant_id: Tenant ID. + record: The decision record to persist. + + Returns: + The UUID of the created record. + """ + entry = DecisionRecordDB( + tenant_id=tenant_id, + agent_run_id=record.agent_run_id, + recommendation=record.recommendation, + evidence=record.evidence or {}, + reviewer_id=record.reviewer_id, + decision=record.decision, + decision_timestamp=record.decision_timestamp + or (datetime.now(UTC).isoformat() if record.decision else None), + deviation_note=record.deviation_note, + owner_id=record.reviewer_id, + ) + db.add(entry) + await db.flush() + return entry.id diff --git a/app/ai/transparency.py b/app/ai/transparency.py new file mode 100644 index 0000000..11593ad --- /dev/null +++ b/app/ai/transparency.py @@ -0,0 +1,60 @@ +"""AI transparency helpers. + +Provides utilities to mark content as AI-generated and to detect whether a +communication participant is an AI agent. This is the transparency layer +required by the AI governance framework: any content produced by an AI agent +must be identifiable as such. + +Used by: +- ``app/plugins/builtins/kommunikation`` — marking AI agent messages +- ``app/ai/agent_loop.py`` — tagging final outputs as AI-generated +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +# Participant types that represent an AI agent (not a human user). +AI_PARTICIPANT_TYPES = ("agent", "ai", "system_ai") + + +def mark_as_ai_generated(content: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: + """Add AI transparency metadata to content. + + Args: + content: The AI-generated content. + metadata: Optional dict with ``model`` and ``provider`` keys plus any + additional context to record. + + Returns: + A dict with the original content plus an ``ai_generated`` flag and an + ``ai_metadata`` block containing model, provider, timestamp, and any + extra metadata passed in. + """ + metadata = metadata or {} + return { + "content": content, + "ai_generated": True, + "ai_metadata": { + "model": metadata.get("model", "unknown"), + "provider": metadata.get("provider", "unknown"), + "timestamp": datetime.now(UTC).isoformat(), + **metadata, + }, + } + + +def is_ai_participant(participant_id: str, participant_type: str) -> bool: + """Check if a participant is an AI agent. + + Args: + participant_id: The participant's ID (unused for the check, kept for + API symmetry and future heuristics). + participant_type: The participant type string (e.g. ``user``, + ``agent``, ``ai``, ``system_ai``). + + Returns: + ``True`` if the participant type is an AI agent type. + """ + return participant_type in AI_PARTICIPANT_TYPES diff --git a/app/core/approval.py b/app/core/approval.py new file mode 100644 index 0000000..d2970d3 --- /dev/null +++ b/app/core/approval.py @@ -0,0 +1,160 @@ +"""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) + 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 + ) + metadata: Mapped[dict[str, Any]] = mapped_column( + 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, + metadata=metadata or {}, + ) + db.add(req) + await db.flush() + return req + + +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, +) -> ApprovalRequest | None: + """Approve or reject a pending approval request. + + Returns the updated request, or ``None`` if not found / not pending. + """ + 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 = decision + req.approver_id = approver_id + req.comment = comment + req.resolved_at = datetime.now(UTC) + await db.flush() + 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 diff --git a/app/main.py b/app/main.py index 500ddde..c8d4e42 100644 --- a/app/main.py +++ b/app/main.py @@ -33,6 +33,7 @@ from app.routes import ( # noqa: E402 addresses, ai_copilot, api_tokens, + approvals, attachments, audit, auth, @@ -578,6 +579,7 @@ def create_app() -> FastAPI: app.include_router(workspaces.router) app.include_router(outbox.router) app.include_router(api_tokens.router) + app.include_router(approvals.router) # ── Register plugin routes for all discovered plugins ── # Routes are registered at app creation time so OpenAPI docs are complete. diff --git a/app/plugins/builtins/automation/agent_routes.py b/app/plugins/builtins/automation/agent_routes.py index a4f5964..448beef 100644 --- a/app/plugins/builtins/automation/agent_routes.py +++ b/app/plugins/builtins/automation/agent_routes.py @@ -11,6 +11,7 @@ from datetime import UTC from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db @@ -258,6 +259,93 @@ async def update_agent( return _agent_to_response(agent) +# ─── AI Use-Case Metadata ─── + + +class AIUseCaseMetadataUpdate(BaseModel): + """Update AI use-case metadata for an agent.""" + + intended_purpose: str | None = None + owner: str | None = None + data_categories: list[str] | None = None + allowed_providers: list[str] | None = None + allowed_models: list[str] | None = None + allowed_actions: list[str] | None = None + oversight_policy: str | None = None + risk_class: str | None = None + human_review_required: bool | None = None + + +@router.get( + "/{agent_id}/ai-use-case", + dependencies=[Depends(require_permission("agents:read"))], +) +async def get_ai_use_case( + agent_id: str, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get AI use-case metadata for an agent.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + aid = uuid.UUID(agent_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid agent ID") from None + + agent = await AgentService.get_by_id(db, tenant_id, aid) + if agent is None: + raise HTTPException(status_code=404, detail="Agent not found") + return {"agent_id": agent_id, "ai_use_case_metadata": agent.ai_use_case_metadata or {}} + + +@router.patch( + "/{agent_id}/ai-use-case", + dependencies=[Depends(require_permission("agents:write"))], +) +async def update_ai_use_case( + agent_id: str, + data: AIUseCaseMetadataUpdate, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Update AI use-case metadata for an agent.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + try: + aid = uuid.UUID(agent_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid agent ID") from None + + agent = await AgentService.get_by_id(db, tenant_id, aid) + if agent is None: + raise HTTPException(status_code=404, detail="Agent not found") + + # Merge with existing metadata (partial update). + current = dict(agent.ai_use_case_metadata or {}) + updates = data.model_dump(exclude_none=True) + current.update(updates) + + # Validate the merged metadata against the agent config. + from app.ai.ai_use_case import AIUseCaseMetadata, validate_ai_use_case + + try: + metadata = AIUseCaseMetadata(**current) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid AI use-case metadata: {e}") from None + warnings = validate_ai_use_case(metadata, agent) + + updated = await AgentService.update( + db, tenant_id, aid, {"ai_use_case_metadata": current}, user_id=user_id + ) + if updated is None: + raise HTTPException(status_code=404, detail="Agent not found") + return { + "agent_id": agent_id, + "ai_use_case_metadata": current, + "warnings": warnings, + } + + @router.delete( "/{agent_id}", dependencies=[Depends(require_permission("agents:delete"))], diff --git a/app/routes/approvals.py b/app/routes/approvals.py new file mode 100644 index 0000000..9c19ab1 --- /dev/null +++ b/app/routes/approvals.py @@ -0,0 +1,305 @@ +"""API routes for approval requests — /api/v1/approvals. + +Endpoints: create, list (filterable), get detail, approve, reject, expire. +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.approval import ( + APPROVAL_STATUSES, + ApprovalRequest, + create_approval_request, + expire_approval_request, + resolve_approval_request, +) +from app.core.db import get_db +from app.deps import get_current_user, require_permission + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/approvals", tags=["approvals"]) + + +# ─── Schemas ─── + + +class ApprovalCreate(BaseModel): + """Create an approval request.""" + + entity_type: str = Field(..., min_length=1, max_length=80) + entity_id: str = Field(..., min_length=1) + action: str = Field(..., min_length=1, max_length=120) + requested_by: str | None = None + requested_by_type: str = Field("agent", pattern="^(user|agent|system)$") + approver_id: str | None = None + approver_group: str | None = Field(None, max_length=120) + expires_at: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ApprovalResolve(BaseModel): + """Approve or reject an approval request.""" + + comment: str | None = Field(None, max_length=2000) + + +class ApprovalResponse(BaseModel): + """Approval request response.""" + + id: str + tenant_id: str + entity_type: str + entity_id: str + action: str + requested_by: str + requested_by_type: str + approver_id: str | None = None + approver_group: str | None = None + status: str + comment: str | None = None + created_at: str | None = None + resolved_at: str | None = None + expires_at: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ApprovalListResponse(BaseModel): + """Paginated approval request list.""" + + items: list[ApprovalResponse] + total: int + + +# ─── Helpers ─── + + +def _to_response(r: ApprovalRequest) -> ApprovalResponse: + return ApprovalResponse( + id=str(r.id), + tenant_id=str(r.tenant_id), + entity_type=r.entity_type, + entity_id=str(r.entity_id), + action=r.action, + requested_by=str(r.requested_by), + requested_by_type=r.requested_by_type, + approver_id=str(r.approver_id) if r.approver_id else None, + approver_group=r.approver_group, + status=r.status, + comment=r.comment, + created_at=r.created_at.isoformat() if r.created_at else None, + resolved_at=r.resolved_at.isoformat() if r.resolved_at else None, + expires_at=r.expires_at.isoformat() if r.expires_at else None, + metadata=r.metadata or {}, + ) + + +def _parse_uuid(value: str, field: str) -> uuid.UUID: + try: + return uuid.UUID(value) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail=f"Invalid {field}") from None + + +# ─── Endpoints ─── + + +@router.post( + "", + dependencies=[Depends(require_permission("approvals:write"))], + response_model=ApprovalResponse, + status_code=201, +) +async def create_approval( + data: ApprovalCreate, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Create a new approval request.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + entity_id = _parse_uuid(data.entity_id, "entity_id") + requested_by = ( + _parse_uuid(data.requested_by, "requested_by") + if data.requested_by + else uuid.UUID(current_user["user_id"]) + ) + approver_id = _parse_uuid(data.approver_id, "approver_id") if data.approver_id else None + expires_at = None + if data.expires_at: + try: + expires_at = datetime.fromisoformat(data.expires_at.replace("Z", "+00:00")) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid expires_at") from None + + req = await create_approval_request( + db, + tenant_id, + entity_type=data.entity_type, + entity_id=entity_id, + action=data.action, + requested_by=requested_by, + requested_by_type=data.requested_by_type, + approver_id=approver_id, + approver_group=data.approver_group, + expires_at=expires_at, + metadata=data.metadata, + ) + await db.commit() + return _to_response(req) + + +@router.get( + "", + dependencies=[Depends(require_permission("approvals:read"))], + response_model=ApprovalListResponse, +) +async def list_approvals( + status: str | None = Query(None), + entity_type: str | None = Query(None), + entity_id: str | None = Query(None), + requested_by: str | None = Query(None), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List approval requests with optional filters.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + query = select(ApprovalRequest).where(ApprovalRequest.tenant_id == tenant_id) + count_query = ( + select(__import__("sqlalchemy").func.count()) + .select_from(ApprovalRequest) + .where(ApprovalRequest.tenant_id == tenant_id) + ) + + if status: + query = query.where(ApprovalRequest.status == status) + count_query = count_query.where(ApprovalRequest.status == status) + if entity_type: + query = query.where(ApprovalRequest.entity_type == entity_type) + count_query = count_query.where(ApprovalRequest.entity_type == entity_type) + if entity_id: + eid = _parse_uuid(entity_id, "entity_id") + query = query.where(ApprovalRequest.entity_id == eid) + count_query = count_query.where(ApprovalRequest.entity_id == eid) + if requested_by: + rid = _parse_uuid(requested_by, "requested_by") + query = query.where(ApprovalRequest.requested_by == rid) + count_query = count_query.where(ApprovalRequest.requested_by == rid) + + total = (await db.execute(count_query)).scalar() or 0 + result = await db.execute( + query.order_by(ApprovalRequest.created_at.desc()).limit(limit).offset(offset) + ) + items = list(result.scalars().all()) + return ApprovalListResponse(items=[_to_response(r) for r in items], total=total) + + +@router.get( + "/{request_id}", + dependencies=[Depends(require_permission("approvals:read"))], + response_model=ApprovalResponse, +) +async def get_approval( + request_id: str, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get a single approval request by ID.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + rid = _parse_uuid(request_id, "request_id") + result = await db.execute( + select(ApprovalRequest).where( + ApprovalRequest.id == rid, + ApprovalRequest.tenant_id == tenant_id, + ) + ) + req = result.scalar_one_or_none() + if req is None: + raise HTTPException(status_code=404, detail="Approval request not found") + return _to_response(req) + + +@router.post( + "/{request_id}/approve", + dependencies=[Depends(require_permission("approvals:approve"))], + response_model=ApprovalResponse, +) +async def approve_approval( + request_id: str, + body: ApprovalResolve, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Approve a pending approval request.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + rid = _parse_uuid(request_id, "request_id") + req = await resolve_approval_request( + db, + tenant_id, + rid, + decision="approved", + approver_id=uuid.UUID(current_user["user_id"]), + comment=body.comment, + ) + if req is None: + raise HTTPException(status_code=404, detail="Approval request not found or not pending") + await db.commit() + return _to_response(req) + + +@router.post( + "/{request_id}/reject", + dependencies=[Depends(require_permission("approvals:approve"))], + response_model=ApprovalResponse, +) +async def reject_approval( + request_id: str, + body: ApprovalResolve, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Reject a pending approval request.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + rid = _parse_uuid(request_id, "request_id") + req = await resolve_approval_request( + db, + tenant_id, + rid, + decision="rejected", + approver_id=uuid.UUID(current_user["user_id"]), + comment=body.comment, + ) + if req is None: + raise HTTPException(status_code=404, detail="Approval request not found or not pending") + await db.commit() + return _to_response(req) + + +@router.post( + "/{request_id}/expire", + dependencies=[Depends(require_permission("approvals:write"))], + response_model=ApprovalResponse, +) +async def expire_approval( + request_id: str, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Mark a pending approval request as expired (system only).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + rid = _parse_uuid(request_id, "request_id") + req = await expire_approval_request(db, tenant_id, rid) + if req is None: + raise HTTPException(status_code=404, detail="Approval request not found or not pending") + await db.commit() + return _to_response(req)