161 lines
5.0 KiB
Python
161 lines
5.0 KiB
Python
|
|
"""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
|