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,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
|
||||
Reference in New Issue
Block a user