Files
leocrm/app/core/approval.py
T
Agent Zero 638e3f3e1e
Check Cross-Plugin Imports / check (push) Has been cancelled
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
- 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
2026-08-17 16:57:50 +02:00

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