Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions autobot-backend/api/approval_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

from api.schemas_workflows import (
from api.schemas_approvals import (
ApprovalAddCommentRequest,
ApprovalCommentResponse,
ApprovalGateResponse,
Expand All @@ -27,10 +27,12 @@
from api.user_management.dependencies import get_db_session
from api.user_management.human_decider import classify_author_type, require_interactive_human
from auth_middleware import get_current_user
from autobot_shared.auth.permissions import is_admin_role
from autobot_shared.error_boundaries import ErrorCategory, with_error_handling
from autobot_shared.logging_manager import get_logger
from autobot_shared.models.pagination import PaginationParams
from models.approval import ApprovalStatus, ApprovalType
from autobot_shared.risk_tier import RiskTier
from models.approval import ApprovalSource, ApprovalStatus, ApprovalType
from services.approval_execution import is_registered_action
from services.approval_gate_service import ApprovalGateService
from services.knowledge.collection_space_stamp_action import register_stamp_action
Expand Down Expand Up @@ -91,6 +93,11 @@ def _to_response(approval) -> ApprovalGateResponse:
decided_at=(approval.decided_at.isoformat() if approval.decided_at else None),
created_at=(approval.created_at.isoformat() if approval.created_at else None),
updated_at=(approval.updated_at.isoformat() if approval.updated_at else None),
source=approval.source,
risk_tier=approval.risk_tier,
expires_at=(approval.expires_at.isoformat() if approval.expires_at else None),
payload_hash=approval.payload_hash,
resume_ref=approval.resume_ref,
comments=_build_comments(approval),
task_links=_build_task_links(approval),
)
Expand Down Expand Up @@ -174,11 +181,13 @@ async def list_approvals(
approval_type: ApprovalType | None = None,
workflow_id: str | None = None,
agent_id: str | None = None,
source: ApprovalSource | None = None,
risk_tier: RiskTier | None = None,
pagination: PaginationParams = Depends(),
current_user: dict = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
):
"""List approval gates with optional filters (#1402)."""
"""List approval gates with optional filters (#1402, #13421)."""
svc = ApprovalGateService(session)
approvals = await svc.list_approvals(
status=status_filter.value if status_filter else None,
Expand All @@ -187,6 +196,38 @@ async def list_approvals(
agent_id=agent_id,
limit=pagination.limit,
offset=pagination.offset,
source=source.value if source else None,
risk_tier=risk_tier.value if risk_tier else None,
)
return [_to_response(a) for a in approvals]


# Declared before ``/approval-gates/{approval_id}`` so "pending" is never parsed as a UUID.
@router.get(
"/approval-gates/pending",
response_model=List[ApprovalGateResponse],
)
@with_error_handling(
category=ErrorCategory.SERVER_ERROR,
operation="list_pending_approvals",
error_code_prefix="APPROVAL_GATES",
)
async def list_pending_approvals(
source: ApprovalSource | None = None,
risk_tier: RiskTier | None = None,
pagination: PaginationParams = Depends(),
current_user: dict = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
):
"""Every pending approval across all sources -- the one approval inbox (#13421)."""
svc = ApprovalGateService(session)
approvals = await svc.list_pending(
viewer=current_user.get("username"),
is_admin=is_admin_role(current_user.get("role")),
source=source.value if source else None,
risk_tier=risk_tier.value if risk_tier else None,
limit=pagination.limit,
offset=pagination.offset,
)
return [_to_response(a) for a in approvals]

Expand Down
117 changes: 117 additions & 0 deletions autobot-backend/api/approval_gates_inbox_13421_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
# AutoBot - AI-Powered Automation Platform
# Author: mrveiss
"""GET /approval-gates/pending lists every source, with the risk tier in the schema (#13421 S1)."""

import uuid
from datetime import datetime, timezone
from types import SimpleNamespace

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

import api.approval_gates as route
from api.user_management.dependencies import get_db_session
from auth_middleware import get_current_user
from services.approval_gate_service import ApprovalGateService

_USER = {"username": "u1", "role": "user", "user_id": "u1"}


def _row(source, tier, **over):
base = dict(
id=uuid.uuid4(), title="t", description=None, approval_type="workflow_gate", status="pending",
requested_by_agent=None, decided_by_user=None, workflow_id=None, workflow_step=None, context={},
decided_at=None, created_at=None, updated_at=None, source=source, risk_tier=tier,
expires_at=datetime(2030, 1, 1, tzinfo=timezone.utc), payload_hash="h" * 64, resume_ref="r",
comments=[], task_links=[],
) # fmt: skip
return SimpleNamespace(**{**base, **over})


@pytest.fixture
def client(monkeypatch):
calls = []

async def list_pending(self, **kwargs):
calls.append(kwargs)
return [_row("agent_tool", "high"), _row("plugin_capability", "low"), _row(None, None)]

monkeypatch.setattr(ApprovalGateService, "list_pending", list_pending)
app = FastAPI()
app.include_router(route.router)
app.dependency_overrides[get_current_user] = lambda: _USER
app.dependency_overrides[get_db_session] = lambda: object()
test_client = TestClient(app)
test_client.app = app
test_client.calls = calls
return test_client


def test_pending_is_routed_before_the_uuid_route_and_returns_every_source(client):
resp = client.get("/approval-gates/pending")
assert resp.status_code == 200, resp.text
body = resp.json()
assert [(r["source"], r["risk_tier"]) for r in body] == [
("agent_tool", "high"),
("plugin_capability", "low"),
(None, None),
]
assert body[0]["expires_at"].startswith("2030-01-01") and body[0]["payload_hash"] == "h" * 64


def test_the_caller_identity_is_forwarded_so_the_service_can_scope_the_list(client):
"""#18413: the route passes the username and admin-ness; scoping itself is tested on Postgres."""
assert client.get("/approval-gates/pending").status_code == 200
assert (client.calls[-1]["viewer"], client.calls[-1]["is_admin"]) == ("u1", False)
client.app.dependency_overrides[get_current_user] = lambda: {**_USER, "role": "admin"}
assert client.get("/approval-gates/pending").status_code == 200
assert client.calls[-1]["is_admin"] is True


def test_filters_are_forwarded_as_plain_values(client):
assert client.get("/approval-gates/pending?source=agent_tool&risk_tier=critical").status_code == 200
assert client.calls[-1]["source"] == "agent_tool" and client.calls[-1]["risk_tier"] == "critical"


@pytest.mark.parametrize("query", ["source=nope", "risk_tier=HIGH", "risk_tier=moderate"])
def test_values_outside_the_shared_vocabularies_are_a_422(client, query):
assert client.get(f"/approval-gates/pending?{query}").status_code == 422


def test_the_response_schema_publishes_source_and_risk_tier():
from api.schemas_approvals import ApprovalGateResponse

schema = ApprovalGateResponse.model_json_schema()
tier_ref = str(schema["properties"]["risk_tier"])
assert "RiskTier" in tier_ref
assert {"source", "expires_at", "payload_hash", "resume_ref"} <= set(schema["properties"])
assert schema["$defs"]["RiskTier"]["enum"] == ["low", "medium", "high", "critical"]


def test_the_http_create_route_cannot_set_source_or_resume_ref(monkeypatch):
seen = {}

async def create_approval(self, **kwargs):
seen.update(kwargs)
return _row(None, None)

monkeypatch.setattr(ApprovalGateService, "create_approval", create_approval)
app = FastAPI()
app.include_router(route.router)
app.dependency_overrides[get_current_user] = lambda: _USER
app.dependency_overrides[get_db_session] = lambda: object()
resp = TestClient(app).post(
"/approval-gates",
json={
"title": "t",
"approval_type": "workflow_gate",
"source": "terminal_command",
"risk_tier": "low",
"resume_ref": "x",
},
)
assert resp.status_code == 201, resp.text
assert not {"source", "risk_tier", "resume_ref", "expires_at", "payload_hash"} & set(seen)
9 changes: 9 additions & 0 deletions autobot-backend/api/orphan_storage_approval_chain_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,20 @@ async def refresh(self, obj):

async def execute(self, _stmt):
approval = self.approval
if getattr(_stmt, "is_update", False):
# the atomic claim is one UPDATE ... RETURNING: apply its SET values to the row
params = _stmt.compile().params
for column in ("status", "decided_by_user", "decided_at"):
setattr(approval, column, params[column])

class _Result:
def scalar_one_or_none(self):
return approval

def first(self):
# the atomic claim's UPDATE ... RETURNING id: a row means this caller won
return (approval.id,)

return _Result()


Expand Down
110 changes: 110 additions & 0 deletions autobot-backend/api/schemas_approvals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
# AutoBot - AI-Powered Automation Platform
# Author: mrveiss
"""Request and response schemas for ``api/approval_gates.py`` (#6042, #13421).
Split out of ``schemas_workflows.py``, which is over the file-size ceiling and may not grow.
"""

from enum import Enum
from typing import Any, Dict, List

from pydantic import BaseModel, Field

from autobot_shared.risk_tier import RiskTier
from models.approval import ApprovalSource, ApprovalType


class AuthorTypeEnum(str, Enum):
"""Valid author types for approval comments."""

HUMAN = "human"
AGENT = "agent"
SYSTEM = "system"


class CreateApprovalRequest(BaseModel):
"""Request body for creating an approval gate."""

title: str
approval_type: ApprovalType
description: str | None = None
requested_by_agent: str | None = None
workflow_id: str | None = None
workflow_step: str | None = None
context: Dict[str, Any] | None = None
task_ids: List[str] | None = None


class ApprovalTransitionRequest(BaseModel):
"""Request body for approve / reject / request-revision."""

comment: str | None = None


class ApprovalResubmitRequest(BaseModel):
"""Request body for resubmitting after revision."""

description: str | None = None
context: Dict[str, Any] | None = None


class ApprovalAddCommentRequest(BaseModel):
"""Comment on an approval gate. author_type is ignored (#17056) -- kept only to log an old client sending it."""

body: str
author_type: AuthorTypeEnum | None = None


class ApprovalLinkTaskRequest(BaseModel):
"""Request body for linking a task to an approval gate."""

task_id: str
task_type: str = "github_issue"


class TaskApprovalLinkResponse(BaseModel):
"""A task or issue linked to an approval, nested in ``ApprovalGateResponse``."""

id: str
approval_id: str
task_id: str
task_type: str


class ApprovalCommentResponse(BaseModel):
"""Response for an approval gate comment."""

id: str
approval_id: str
author: str
author_type: str
body: str
created_at: str | None = None


class ApprovalGateResponse(BaseModel):
"""One approval record as the API returns it, including the #13421 source, risk tier and expiry."""

id: str
title: str
description: str | None = None
approval_type: str
status: str
requested_by_agent: str | None = None
decided_by_user: str | None = None
workflow_id: str | None = None
workflow_step: str | None = None
context: Dict[str, Any] | None = None
decided_at: str | None = None
created_at: str | None = None
updated_at: str | None = None
# One-approval-record fields (#13421); None for approvals that predate them.
source: ApprovalSource | None = None
risk_tier: RiskTier | None = None
expires_at: str | None = None
payload_hash: str | None = None
resume_ref: str | None = None
comments: List[ApprovalCommentResponse] = Field(default_factory=list)
task_links: List[TaskApprovalLinkResponse] = Field(default_factory=list)
Loading
Loading