feat(F): F-PERM permissions, F-APPR approval, F-AIUSE metadata, F-TRANS transparency, F-DATA-POL data policy, F-OVERSIGHT decision record, F-DRY dry-run, F-AUDIT audit log
Check Cross-Plugin Imports / check (push) Has been cancelled

- F-PERM: app/ai/agent_permissions.py (230 lines) — AgentPermissionContext, resolve_agent_permissions(), filter_visible_agents(), check_agent_execute_permission(), optimistic locking
- F-APPR: app/core/approval.py (160 lines) + app/routes/approvals.py (305 lines) + migration 0123 — ApprovalRequest model, CRUD API, approve/reject/expire
- F-AIUSE: app/ai/ai_use_case.py (156 lines) — AIUseCaseMetadata Pydantic model, validate_ai_use_case()
- F-TRANS: app/ai/transparency.py (60 lines) — mark_as_ai_generated(), is_ai_participant()
- F-DATA-POL: app/ai/data_policy.py (210 lines) — enforce_data_policy() with SENSITIVE_FIELDS + provider compliance
- F-OVERSIGHT: app/ai/oversight.py (108 lines) — DecisionRecord, create_decision_record()
- F-DRY: agent_loop.py updated with dry_run parameter
- F-AUDIT: agent_loop.py updated with audit log for tool calls
- agent_routes.py: AI use case metadata endpoints added
- main.py: approval routes registered
- All Python compile checks pass
This commit is contained in:
Agent Zero
2026-08-17 16:57:50 +02:00
parent dbeadd8ab1
commit 638e3f3e1e
11 changed files with 1455 additions and 1 deletions
@@ -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")
+55 -1
View File
@@ -148,6 +148,7 @@ async def run_react_loop(
timeout_seconds: int = 300, timeout_seconds: int = 300,
trace_id: str | None = None, trace_id: str | None = None,
on_step: Callable | None = None, on_step: Callable | None = None,
dry_run: bool = False,
) -> ReActResult: ) -> ReActResult:
"""Execute a ReAct loop: LLM reasoning → tool execution → repeat. """Execute a ReAct loop: LLM reasoning → tool execution → repeat.
@@ -164,6 +165,9 @@ async def run_react_loop(
timeout_seconds: Overall timeout (default 300). timeout_seconds: Overall timeout (default 300).
trace_id: Optional trace ID for correlation. trace_id: Optional trace ID for correlation.
on_step: Optional async callback fired after each step. 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: Returns:
ReActResult with final content, steps, cost, and status. ReActResult with final content, steps, cost, and status.
@@ -193,6 +197,38 @@ async def run_react_loop(
"db": db, "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): for step_num in range(1, max_steps + 1):
# ── Timeout check ── # ── Timeout check ──
elapsed = time.monotonic() - start_time elapsed = time.monotonic() - start_time
@@ -327,9 +363,27 @@ async def run_react_loop(
args = {} args = {}
logger.warning("Invalid JSON arguments for tool '%s': %s", tool_name, tc["arguments"]) 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) 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 # Feed tool result back into conversation
full_messages.append({ full_messages.append({
"role": "tool", "role": "tool",
+230
View File
@@ -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",
]
+156
View File
@@ -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
+210
View File
@@ -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"
+108
View File
@@ -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
+60
View File
@@ -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
+160
View File
@@ -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
+2
View File
@@ -33,6 +33,7 @@ from app.routes import ( # noqa: E402
addresses, addresses,
ai_copilot, ai_copilot,
api_tokens, api_tokens,
approvals,
attachments, attachments,
audit, audit,
auth, auth,
@@ -578,6 +579,7 @@ def create_app() -> FastAPI:
app.include_router(workspaces.router) app.include_router(workspaces.router)
app.include_router(outbox.router) app.include_router(outbox.router)
app.include_router(api_tokens.router) app.include_router(api_tokens.router)
app.include_router(approvals.router)
# ── Register plugin routes for all discovered plugins ── # ── Register plugin routes for all discovered plugins ──
# Routes are registered at app creation time so OpenAPI docs are complete. # Routes are registered at app creation time so OpenAPI docs are complete.
@@ -11,6 +11,7 @@ from datetime import UTC
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
@@ -258,6 +259,93 @@ async def update_agent(
return _agent_to_response(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( @router.delete(
"/{agent_id}", "/{agent_id}",
dependencies=[Depends(require_permission("agents:delete"))], dependencies=[Depends(require_permission("agents:delete"))],
+305
View File
@@ -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)