Files

274 lines
9.0 KiB
Python
Raw Permalink Normal View History

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