2026-08-17 16:57:50 +02:00
|
|
|
"""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)
|
2026-09-18 08:53:54 +02:00
|
|
|
# F11: who actually decided — approver_id stays the ASSIGNMENT,
|
|
|
|
|
# resolved_by records the ACTUAL decider (previously the assignment
|
|
|
|
|
# was overwritten by whoever decided).
|
|
|
|
|
resolved_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
|
|
|
PGUUID(as_uuid=True), nullable=True
|
|
|
|
|
)
|
2026-08-17 16:57:50 +02:00
|
|
|
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
|
|
|
|
|
)
|
2026-08-17 19:36:42 +02:00
|
|
|
request_metadata: Mapped[dict[str, Any]] = mapped_column(
|
|
|
|
|
"metadata", JSONB, nullable=False, default=dict
|
2026-08-17 16:57:50 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-08-17 19:36:42 +02:00
|
|
|
request_metadata=metadata or {},
|
2026-08-17 16:57:50 +02:00
|
|
|
)
|
|
|
|
|
db.add(req)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return req
|
|
|
|
|
|
|
|
|
|
|
2026-09-18 08:53:54 +02:00
|
|
|
class ApprovalDecisionError(Exception):
|
|
|
|
|
"""Raised when an approval decision is invalid (F11/Astra).
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
code: machine-readable reason for the HTTP layer.
|
|
|
|
|
http_status: suggested HTTP status code.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, code: str, message: str, http_status: int = 403):
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
self.code = code
|
|
|
|
|
self.http_status = http_status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _user_in_approver_group(
|
|
|
|
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, group_name: str
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Check whether the user is a member of the named approver group."""
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
from app.models.group import Group, UserGroup
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(UserGroup.id)
|
|
|
|
|
.join(Group, UserGroup.group_id == Group.id)
|
|
|
|
|
.where(
|
|
|
|
|
UserGroup.user_id == user_id,
|
|
|
|
|
UserGroup.tenant_id == tenant_id,
|
|
|
|
|
Group.name == group_name,
|
|
|
|
|
)
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
return result.first() is not None
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 16:57:50 +02:00
|
|
|
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,
|
2026-09-18 08:53:54 +02:00
|
|
|
is_system_admin: bool = False,
|
2026-08-17 16:57:50 +02:00
|
|
|
) -> ApprovalRequest | None:
|
2026-09-18 08:53:54 +02:00
|
|
|
"""Approve or reject a pending approval request (F11 hardened).
|
|
|
|
|
|
|
|
|
|
Returns the updated request, or ``None`` if not found.
|
|
|
|
|
|
|
|
|
|
Raises ApprovalDecisionError when the decision is invalid:
|
|
|
|
|
- ``expired`` (410): the request's expires_at has passed — it is
|
|
|
|
|
marked expired and can no longer be decided.
|
|
|
|
|
- ``not_pending`` (409): the request was already decided concurrently.
|
|
|
|
|
- ``wrong_approver``(403): the acting user is neither the assigned
|
|
|
|
|
approver (approver_id) nor a member of the assigned approver_group.
|
|
|
|
|
Unassigned requests (no approver_id AND no approver_group) may be
|
|
|
|
|
decided by anyone holding approvals:approve; system admins may
|
|
|
|
|
decide any request (documented operations override).
|
2026-08-17 16:57:50 +02:00
|
|
|
|
2026-09-18 08:53:54 +02:00
|
|
|
The assignment (approver_id) is NEVER overwritten — the actual decider
|
|
|
|
|
is recorded in resolved_by (F11: assignment and decider are separate).
|
2026-08-17 16:57:50 +02:00
|
|
|
"""
|
2026-09-18 08:53:54 +02:00
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import select, update
|
|
|
|
|
|
|
|
|
|
if decision not in ("approved", "rejected"):
|
|
|
|
|
raise ValueError(f"invalid decision: {decision!r}")
|
2026-08-17 16:57:50 +02:00
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(ApprovalRequest).where(
|
|
|
|
|
ApprovalRequest.id == request_id,
|
|
|
|
|
ApprovalRequest.tenant_id == tenant_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
req = result.scalar_one_or_none()
|
2026-09-18 08:53:54 +02:00
|
|
|
if req is None:
|
2026-08-17 16:57:50 +02:00
|
|
|
return None
|
|
|
|
|
|
2026-09-18 08:53:54 +02:00
|
|
|
# 1. Expiry check — an expired request can no longer be decided.
|
|
|
|
|
if (
|
|
|
|
|
req.status == "pending"
|
|
|
|
|
and req.expires_at is not None
|
|
|
|
|
and req.expires_at < datetime.now(UTC)
|
|
|
|
|
):
|
|
|
|
|
req.status = "expired"
|
|
|
|
|
req.resolved_at = datetime.now(UTC)
|
|
|
|
|
await db.flush()
|
|
|
|
|
raise ApprovalDecisionError(
|
|
|
|
|
"expired", "Approval request has expired", http_status=410
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 2. Approver check — who may decide this request?
|
|
|
|
|
if not is_system_admin:
|
|
|
|
|
assigned_user = req.approver_id
|
|
|
|
|
assigned_group = req.approver_group
|
|
|
|
|
allowed = False
|
|
|
|
|
if assigned_user is not None:
|
|
|
|
|
allowed = assigned_user == approver_id
|
|
|
|
|
if not allowed and assigned_group:
|
|
|
|
|
allowed = await _user_in_approver_group(
|
|
|
|
|
db, tenant_id, approver_id, assigned_group
|
|
|
|
|
)
|
|
|
|
|
if not allowed and assigned_user is None and assigned_group is None:
|
|
|
|
|
# Unassigned request: anyone with approvals:approve may decide.
|
|
|
|
|
allowed = True
|
|
|
|
|
if not allowed:
|
|
|
|
|
raise ApprovalDecisionError(
|
|
|
|
|
"wrong_approver",
|
|
|
|
|
"This approval request is assigned to a different approver",
|
|
|
|
|
http_status=403,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 3. Atomic status transition — a concurrent decision must not win twice.
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
upd = await db.execute(
|
|
|
|
|
update(ApprovalRequest)
|
|
|
|
|
.where(
|
|
|
|
|
ApprovalRequest.id == request_id,
|
|
|
|
|
ApprovalRequest.tenant_id == tenant_id,
|
|
|
|
|
ApprovalRequest.status == "pending",
|
|
|
|
|
)
|
|
|
|
|
.values(
|
|
|
|
|
status=decision,
|
|
|
|
|
resolved_by=approver_id,
|
|
|
|
|
comment=comment,
|
|
|
|
|
resolved_at=now,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if upd.rowcount == 0:
|
|
|
|
|
raise ApprovalDecisionError(
|
|
|
|
|
"not_pending",
|
|
|
|
|
"Approval request was already decided",
|
|
|
|
|
http_status=409,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await db.refresh(req)
|
2026-08-17 16:57:50 +02:00
|
|
|
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
|