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
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:
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"))],
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user