Major additions: - marketplace/: Agent template registry with FTS5 search, ratings, versioning - observability/: Prometheus metrics, distributed tracing, structured logging - ledger/migrations/: Database migration scripts for multi-tenant support - tests/governance/: 15 new test files for phases 6-12 (295 total tests) - bin/validate-phases: Full 12-phase validation script New features: - Multi-tenant support with tenant isolation and quota enforcement - Agent marketplace with semantic versioning and search - Observability with metrics, tracing, and log correlation - Tier-1 agent bootstrap scripts Updated components: - ledger/api.py: Extended API for tenants, marketplace, observability - ledger/schema.sql: Added tenant, project, marketplace tables - testing/framework.ts: Enhanced test framework - checkpoint/checkpoint.py: Improved checkpoint management Archived: - External integrations (Slack/GitHub/PagerDuty) moved to .archive/ - Old checkpoint files cleaned up Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1286 lines
42 KiB
Python
Executable File
1286 lines
42 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Ledger API - FastAPI Service (Multi-Tenant)
|
|
============================================
|
|
RESTful API for agent governance ledger access with multi-tenant support.
|
|
|
|
Endpoints:
|
|
- /health - Health check
|
|
- /tenants - Tenant management
|
|
- /projects - Project management
|
|
- /agents - Agent metrics CRUD
|
|
- /actions - Agent action logs
|
|
- /violations - Violation records
|
|
- /promotions - Promotion history
|
|
- /orchestration - Orchestration logs
|
|
|
|
Authentication:
|
|
- Vault token in X-Vault-Token header
|
|
- API key in X-API-Key header
|
|
- Tenant context in X-Tenant-ID and X-Project-ID headers
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import secrets
|
|
import sqlite3
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional, List, Any
|
|
from contextlib import contextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException, Header, Query, Depends, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel, Field
|
|
import uvicorn
|
|
|
|
# =============================================================================
|
|
# Configuration
|
|
# =============================================================================
|
|
|
|
DB_PATH = Path("/opt/agent-governance/ledger/governance.db")
|
|
VAULT_ADDR = os.environ.get("VAULT_ADDR", "https://127.0.0.1:8200")
|
|
API_PORT = int(os.environ.get("LEDGER_API_PORT", "8080"))
|
|
REQUIRE_AUTH = os.environ.get("LEDGER_API_AUTH", "true").lower() == "true"
|
|
|
|
# =============================================================================
|
|
# Database
|
|
# =============================================================================
|
|
|
|
@contextmanager
|
|
def get_db():
|
|
"""Get database connection with row factory"""
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def row_to_dict(row: sqlite3.Row) -> dict:
|
|
"""Convert sqlite Row to dictionary"""
|
|
return dict(row)
|
|
|
|
|
|
def rows_to_list(rows: list) -> list:
|
|
"""Convert list of sqlite Rows to list of dicts"""
|
|
return [row_to_dict(row) for row in rows]
|
|
|
|
|
|
# =============================================================================
|
|
# Tenant Context
|
|
# =============================================================================
|
|
|
|
class TenantContext(BaseModel):
|
|
"""Tenant context extracted from request"""
|
|
tenant_id: str = "default"
|
|
project_id: str = "default"
|
|
user_id: Optional[str] = None
|
|
role: str = "viewer" # viewer, editor, admin, owner
|
|
authenticated: bool = False
|
|
auth_method: str = "none" # none, vault, api_key
|
|
|
|
|
|
async def get_tenant_context(
|
|
request: Request,
|
|
x_vault_token: Optional[str] = Header(None),
|
|
x_api_key: Optional[str] = Header(None),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
x_project_id: Optional[str] = Header(None)
|
|
) -> TenantContext:
|
|
"""Extract tenant context from request headers"""
|
|
|
|
ctx = TenantContext()
|
|
|
|
# Try API key auth first
|
|
if x_api_key:
|
|
key_hash = hashlib.sha256(x_api_key.encode()).hexdigest()
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT ak.tenant_id, ak.project_id, ak.permissions, t.is_active
|
|
FROM api_keys ak
|
|
JOIN tenants t ON ak.tenant_id = t.tenant_id
|
|
WHERE ak.key_hash = ? AND ak.is_active = 1
|
|
AND (ak.expires_at IS NULL OR ak.expires_at > datetime('now'))
|
|
""", (key_hash,))
|
|
row = cursor.fetchone()
|
|
|
|
if row and row["is_active"]:
|
|
ctx.tenant_id = row["tenant_id"]
|
|
ctx.project_id = row["project_id"] or x_project_id or "default"
|
|
ctx.authenticated = True
|
|
ctx.auth_method = "api_key"
|
|
ctx.role = "editor" # API keys get editor by default
|
|
|
|
# Update last_used_at
|
|
conn.execute("""
|
|
UPDATE api_keys SET last_used_at = datetime('now')
|
|
WHERE key_hash = ?
|
|
""", (key_hash,))
|
|
conn.commit()
|
|
|
|
return ctx
|
|
|
|
# Try Vault token auth
|
|
if x_vault_token and REQUIRE_AUTH:
|
|
try:
|
|
result = subprocess.run([
|
|
"curl", "-sk",
|
|
"-H", f"X-Vault-Token: {x_vault_token}",
|
|
f"{VAULT_ADDR}/v1/auth/token/lookup-self"
|
|
], capture_output=True, text=True, timeout=10)
|
|
|
|
data = json.loads(result.stdout)
|
|
|
|
if "errors" not in data:
|
|
ctx.authenticated = True
|
|
ctx.auth_method = "vault"
|
|
ctx.user_id = data.get("data", {}).get("display_name")
|
|
ctx.role = "admin" # Vault tokens get admin
|
|
|
|
# Use header tenant/project or default
|
|
ctx.tenant_id = x_tenant_id or "default"
|
|
ctx.project_id = x_project_id or "default"
|
|
|
|
return ctx
|
|
except:
|
|
pass
|
|
|
|
# No auth or auth not required
|
|
if not REQUIRE_AUTH:
|
|
ctx.authenticated = False
|
|
ctx.auth_method = "none"
|
|
ctx.role = "admin" # No auth = full access (dev mode)
|
|
ctx.tenant_id = x_tenant_id or "default"
|
|
ctx.project_id = x_project_id or "default"
|
|
return ctx
|
|
|
|
# Auth required but not provided
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
|
|
def require_role(minimum_role: str):
|
|
"""Dependency to require minimum role"""
|
|
role_levels = {"viewer": 0, "editor": 1, "admin": 2, "owner": 3}
|
|
|
|
async def check_role(ctx: TenantContext = Depends(get_tenant_context)):
|
|
if role_levels.get(ctx.role, 0) < role_levels.get(minimum_role, 0):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Requires {minimum_role} role, you have {ctx.role}"
|
|
)
|
|
return ctx
|
|
|
|
return check_role
|
|
|
|
|
|
# =============================================================================
|
|
# Quota Enforcement
|
|
# =============================================================================
|
|
|
|
async def check_quota(ctx: TenantContext, resource: str, amount: int = 1) -> bool:
|
|
"""Check if tenant has quota for resource"""
|
|
with get_db() as conn:
|
|
# Get quota limits
|
|
cursor = conn.execute("""
|
|
SELECT * FROM tenant_quotas WHERE tenant_id = ?
|
|
""", (ctx.tenant_id,))
|
|
quota = cursor.fetchone()
|
|
|
|
if not quota:
|
|
return True # No quota = unlimited
|
|
|
|
# Get current usage
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
cursor = conn.execute("""
|
|
SELECT * FROM tenant_usage
|
|
WHERE tenant_id = ? AND period_start = ?
|
|
""", (ctx.tenant_id, today))
|
|
usage = cursor.fetchone()
|
|
|
|
if not usage:
|
|
# Create usage record
|
|
conn.execute("""
|
|
INSERT INTO tenant_usage (tenant_id, period_start)
|
|
VALUES (?, ?)
|
|
""", (ctx.tenant_id, today))
|
|
conn.commit()
|
|
return True
|
|
|
|
# Check specific resource
|
|
if resource == "api_calls":
|
|
if usage["api_calls"] + amount > quota["max_api_calls_per_day"]:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"API call quota exceeded ({quota['max_api_calls_per_day']}/day)"
|
|
)
|
|
elif resource == "tokens":
|
|
if usage["tokens_used"] + amount > quota["max_tokens_per_day"]:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"Token quota exceeded ({quota['max_tokens_per_day']}/day)"
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
async def increment_usage(ctx: TenantContext, resource: str, amount: int = 1):
|
|
"""Increment usage counter for tenant"""
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
|
|
column_map = {
|
|
"api_calls": "api_calls",
|
|
"tokens": "tokens_used",
|
|
"executions": "executions"
|
|
}
|
|
|
|
column = column_map.get(resource)
|
|
if not column:
|
|
return
|
|
|
|
with get_db() as conn:
|
|
conn.execute(f"""
|
|
INSERT INTO tenant_usage (tenant_id, period_start, {column})
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(tenant_id, period_start) DO UPDATE SET
|
|
{column} = {column} + ?,
|
|
updated_at = datetime('now')
|
|
""", (ctx.tenant_id, today, amount, amount))
|
|
conn.commit()
|
|
|
|
|
|
# =============================================================================
|
|
# Models
|
|
# =============================================================================
|
|
|
|
class Tenant(BaseModel):
|
|
tenant_id: Optional[str] = None
|
|
name: str
|
|
slug: str
|
|
owner_email: Optional[str] = None
|
|
subscription_tier: str = "free"
|
|
|
|
|
|
class TenantUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
owner_email: Optional[str] = None
|
|
subscription_tier: Optional[str] = None
|
|
is_active: Optional[int] = None
|
|
|
|
|
|
class Project(BaseModel):
|
|
project_id: Optional[str] = None
|
|
name: str
|
|
slug: str
|
|
description: Optional[str] = None
|
|
|
|
|
|
class ProjectUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
is_active: Optional[int] = None
|
|
|
|
|
|
class APIKeyCreate(BaseModel):
|
|
name: str
|
|
project_id: Optional[str] = None
|
|
permissions: Optional[str] = None
|
|
expires_days: Optional[int] = None
|
|
|
|
|
|
class AgentMetrics(BaseModel):
|
|
agent_id: str
|
|
current_tier: int = 0
|
|
compliant_runs: int = 0
|
|
consecutive_compliant: int = 0
|
|
total_runs: int = 0
|
|
last_violation_at: Optional[str] = None
|
|
last_active_at: Optional[str] = None
|
|
promotion_eligible: int = 0
|
|
|
|
|
|
class AgentMetricsUpdate(BaseModel):
|
|
current_tier: Optional[int] = None
|
|
compliant_runs: Optional[int] = None
|
|
consecutive_compliant: Optional[int] = None
|
|
total_runs: Optional[int] = None
|
|
promotion_eligible: Optional[int] = None
|
|
|
|
|
|
class AgentAction(BaseModel):
|
|
agent_id: str
|
|
agent_version: str = "1.0"
|
|
tier: int
|
|
action: str
|
|
decision: str
|
|
confidence: float
|
|
target: Optional[str] = None
|
|
side_effects: Optional[str] = None
|
|
success: int
|
|
error_type: Optional[str] = None
|
|
error_message: Optional[str] = None
|
|
vault_token_accessor: Optional[str] = None
|
|
session_id: Optional[str] = None
|
|
|
|
|
|
class Violation(BaseModel):
|
|
agent_id: str
|
|
violation_type: str
|
|
severity: str # low, medium, high, critical
|
|
description: str
|
|
triggering_action: Optional[str] = None
|
|
evidence: Optional[str] = None
|
|
remediation: Optional[str] = None
|
|
|
|
|
|
class ViolationAcknowledge(BaseModel):
|
|
acknowledged_by: str
|
|
|
|
|
|
class Promotion(BaseModel):
|
|
agent_id: str
|
|
from_tier: int
|
|
to_tier: int
|
|
approved_by: str
|
|
rationale: Optional[str] = None
|
|
evidence: Optional[str] = None
|
|
|
|
|
|
class OrchestrationLog(BaseModel):
|
|
session_id: Optional[str] = None
|
|
agent_id: Optional[str] = None
|
|
orchestration_mode: str
|
|
model_id: Optional[str] = None
|
|
command_type: str
|
|
command: str
|
|
response: Optional[str] = None
|
|
tokens_used: Optional[int] = None
|
|
success: int
|
|
|
|
|
|
# =============================================================================
|
|
# FastAPI App
|
|
# =============================================================================
|
|
|
|
app = FastAPI(
|
|
title="Agent Governance Ledger API",
|
|
description="Multi-tenant RESTful API for agent governance ledger",
|
|
version="2.0.0",
|
|
docs_url="/docs",
|
|
redoc_url="/redoc"
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Health Endpoints
|
|
# =============================================================================
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
"""Health check endpoint"""
|
|
db_ok = DB_PATH.exists()
|
|
|
|
vault_ok = False
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "exec", "vault", "vault", "status", "-format=json"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
vault_ok = True
|
|
except:
|
|
pass
|
|
|
|
return {
|
|
"status": "healthy" if db_ok else "degraded",
|
|
"database": "ok" if db_ok else "missing",
|
|
"vault": "ok" if vault_ok else "unavailable",
|
|
"version": "2.0.0",
|
|
"multi_tenant": True,
|
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
|
}
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
"""API root"""
|
|
return {
|
|
"service": "Agent Governance Ledger API",
|
|
"version": "2.0.0",
|
|
"multi_tenant": True,
|
|
"endpoints": {
|
|
"health": "/health",
|
|
"tenants": "/tenants",
|
|
"projects": "/projects",
|
|
"agents": "/agents",
|
|
"actions": "/actions",
|
|
"violations": "/violations",
|
|
"promotions": "/promotions",
|
|
"orchestration": "/orchestration"
|
|
},
|
|
"docs": "/docs"
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Tenant Endpoints
|
|
# =============================================================================
|
|
|
|
@app.get("/tenants")
|
|
async def list_tenants(
|
|
limit: int = Query(100, le=1000),
|
|
ctx: TenantContext = Depends(require_role("admin"))
|
|
):
|
|
"""List all tenants (admin only)"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT t.*, tq.max_projects, tq.max_agents_per_project
|
|
FROM tenants t
|
|
LEFT JOIN tenant_quotas tq ON t.tenant_id = tq.tenant_id
|
|
ORDER BY t.created_at DESC LIMIT ?
|
|
""", (limit,))
|
|
tenants = rows_to_list(cursor.fetchall())
|
|
|
|
return {"tenants": tenants, "count": len(tenants)}
|
|
|
|
|
|
@app.get("/tenants/{tenant_id}")
|
|
async def get_tenant(
|
|
tenant_id: str,
|
|
ctx: TenantContext = Depends(get_tenant_context)
|
|
):
|
|
"""Get tenant by ID"""
|
|
# Only allow viewing own tenant or admin
|
|
if ctx.tenant_id != tenant_id and ctx.role != "admin":
|
|
raise HTTPException(status_code=403, detail="Cannot view other tenants")
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT t.*, tq.*
|
|
FROM tenants t
|
|
LEFT JOIN tenant_quotas tq ON t.tenant_id = tq.tenant_id
|
|
WHERE t.tenant_id = ?
|
|
""", (tenant_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
|
|
return row_to_dict(row)
|
|
|
|
|
|
@app.post("/tenants")
|
|
async def create_tenant(
|
|
tenant: Tenant,
|
|
ctx: TenantContext = Depends(require_role("admin"))
|
|
):
|
|
"""Create a new tenant"""
|
|
tenant_id = tenant.tenant_id or f"tenant-{secrets.token_hex(8)}"
|
|
|
|
with get_db() as conn:
|
|
try:
|
|
conn.execute("""
|
|
INSERT INTO tenants (tenant_id, name, slug, owner_email, subscription_tier)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (tenant_id, tenant.name, tenant.slug, tenant.owner_email, tenant.subscription_tier))
|
|
|
|
# Create default quotas
|
|
conn.execute("""
|
|
INSERT INTO tenant_quotas (tenant_id) VALUES (?)
|
|
""", (tenant_id,))
|
|
|
|
# Create default project
|
|
conn.execute("""
|
|
INSERT INTO projects (project_id, tenant_id, name, slug)
|
|
VALUES (?, ?, 'Default Project', 'default')
|
|
""", (f"{tenant_id}-default", tenant_id))
|
|
|
|
conn.commit()
|
|
except sqlite3.IntegrityError as e:
|
|
raise HTTPException(status_code=409, detail=f"Tenant already exists: {e}")
|
|
|
|
return {"status": "created", "tenant_id": tenant_id}
|
|
|
|
|
|
@app.patch("/tenants/{tenant_id}")
|
|
async def update_tenant(
|
|
tenant_id: str,
|
|
update: TenantUpdate,
|
|
ctx: TenantContext = Depends(require_role("admin"))
|
|
):
|
|
"""Update tenant"""
|
|
updates = []
|
|
params = []
|
|
|
|
if update.name is not None:
|
|
updates.append("name = ?")
|
|
params.append(update.name)
|
|
if update.owner_email is not None:
|
|
updates.append("owner_email = ?")
|
|
params.append(update.owner_email)
|
|
if update.subscription_tier is not None:
|
|
updates.append("subscription_tier = ?")
|
|
params.append(update.subscription_tier)
|
|
if update.is_active is not None:
|
|
updates.append("is_active = ?")
|
|
params.append(update.is_active)
|
|
|
|
if not updates:
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
updates.append("updated_at = datetime('now')")
|
|
params.append(tenant_id)
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute(
|
|
f"UPDATE tenants SET {', '.join(updates)} WHERE tenant_id = ?",
|
|
params
|
|
)
|
|
conn.commit()
|
|
|
|
if cursor.rowcount == 0:
|
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
|
|
return {"status": "updated", "tenant_id": tenant_id}
|
|
|
|
|
|
# =============================================================================
|
|
# Project Endpoints
|
|
# =============================================================================
|
|
|
|
@app.get("/projects")
|
|
async def list_projects(
|
|
limit: int = Query(100, le=1000),
|
|
ctx: TenantContext = Depends(get_tenant_context)
|
|
):
|
|
"""List projects for current tenant"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT * FROM projects
|
|
WHERE tenant_id = ? AND is_active = 1
|
|
ORDER BY created_at DESC LIMIT ?
|
|
""", (ctx.tenant_id, limit))
|
|
projects = rows_to_list(cursor.fetchall())
|
|
|
|
return {"projects": projects, "count": len(projects)}
|
|
|
|
|
|
@app.get("/projects/{project_id}")
|
|
async def get_project(
|
|
project_id: str,
|
|
ctx: TenantContext = Depends(get_tenant_context)
|
|
):
|
|
"""Get project by ID"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT * FROM projects
|
|
WHERE project_id = ? AND tenant_id = ?
|
|
""", (project_id, ctx.tenant_id))
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
return row_to_dict(row)
|
|
|
|
|
|
@app.post("/projects")
|
|
async def create_project(
|
|
project: Project,
|
|
ctx: TenantContext = Depends(require_role("admin"))
|
|
):
|
|
"""Create a new project"""
|
|
project_id = project.project_id or f"{ctx.tenant_id}-{secrets.token_hex(4)}"
|
|
|
|
# Check quota
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT COUNT(*) as count FROM projects WHERE tenant_id = ?
|
|
""", (ctx.tenant_id,))
|
|
count = cursor.fetchone()["count"]
|
|
|
|
cursor = conn.execute("""
|
|
SELECT max_projects FROM tenant_quotas WHERE tenant_id = ?
|
|
""", (ctx.tenant_id,))
|
|
quota = cursor.fetchone()
|
|
|
|
if quota and count >= quota["max_projects"]:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"Project quota exceeded ({quota['max_projects']} max)"
|
|
)
|
|
|
|
try:
|
|
conn.execute("""
|
|
INSERT INTO projects (project_id, tenant_id, name, slug, description)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (project_id, ctx.tenant_id, project.name, project.slug, project.description))
|
|
conn.commit()
|
|
except sqlite3.IntegrityError as e:
|
|
raise HTTPException(status_code=409, detail=f"Project already exists: {e}")
|
|
|
|
return {"status": "created", "project_id": project_id}
|
|
|
|
|
|
# =============================================================================
|
|
# API Key Endpoints
|
|
# =============================================================================
|
|
|
|
@app.get("/api-keys")
|
|
async def list_api_keys(
|
|
ctx: TenantContext = Depends(require_role("admin"))
|
|
):
|
|
"""List API keys for tenant"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT key_id, name, project_id, permissions, rate_limit_per_minute,
|
|
expires_at, last_used_at, is_active, created_at
|
|
FROM api_keys
|
|
WHERE tenant_id = ?
|
|
ORDER BY created_at DESC
|
|
""", (ctx.tenant_id,))
|
|
keys = rows_to_list(cursor.fetchall())
|
|
|
|
return {"api_keys": keys, "count": len(keys)}
|
|
|
|
|
|
@app.post("/api-keys")
|
|
async def create_api_key(
|
|
key_data: APIKeyCreate,
|
|
ctx: TenantContext = Depends(require_role("admin"))
|
|
):
|
|
"""Create a new API key"""
|
|
key_id = f"key-{secrets.token_hex(8)}"
|
|
raw_key = f"agk_{secrets.token_urlsafe(32)}"
|
|
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
|
|
expires_at = None
|
|
if key_data.expires_days:
|
|
from datetime import timedelta
|
|
expires_at = (datetime.now(timezone.utc) + timedelta(days=key_data.expires_days)).isoformat()
|
|
|
|
with get_db() as conn:
|
|
conn.execute("""
|
|
INSERT INTO api_keys (key_id, tenant_id, project_id, key_hash, name, permissions, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""", (key_id, ctx.tenant_id, key_data.project_id, key_hash, key_data.name, key_data.permissions, expires_at))
|
|
conn.commit()
|
|
|
|
return {
|
|
"status": "created",
|
|
"key_id": key_id,
|
|
"api_key": raw_key, # Only shown once!
|
|
"warning": "Save this key now - it cannot be retrieved again"
|
|
}
|
|
|
|
|
|
@app.delete("/api-keys/{key_id}")
|
|
async def revoke_api_key(
|
|
key_id: str,
|
|
ctx: TenantContext = Depends(require_role("admin"))
|
|
):
|
|
"""Revoke an API key"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
UPDATE api_keys SET is_active = 0
|
|
WHERE key_id = ? AND tenant_id = ?
|
|
""", (key_id, ctx.tenant_id))
|
|
conn.commit()
|
|
|
|
if cursor.rowcount == 0:
|
|
raise HTTPException(status_code=404, detail="API key not found")
|
|
|
|
return {"status": "revoked", "key_id": key_id}
|
|
|
|
|
|
# =============================================================================
|
|
# Agent Metrics Endpoints (Tenant-Scoped)
|
|
# =============================================================================
|
|
|
|
@app.get("/agents")
|
|
async def list_agents(
|
|
limit: int = Query(100, le=1000),
|
|
offset: int = Query(0, ge=0),
|
|
tier: Optional[int] = None,
|
|
ctx: TenantContext = Depends(get_tenant_context)
|
|
):
|
|
"""List all agents with metrics (tenant-scoped)"""
|
|
await check_quota(ctx, "api_calls")
|
|
|
|
with get_db() as conn:
|
|
query = "SELECT * FROM agent_metrics WHERE tenant_id = ? AND project_id = ?"
|
|
params = [ctx.tenant_id, ctx.project_id]
|
|
|
|
if tier is not None:
|
|
query += " AND current_tier = ?"
|
|
params.append(tier)
|
|
|
|
query += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
|
|
params.extend([limit, offset])
|
|
|
|
cursor = conn.execute(query, params)
|
|
agents = rows_to_list(cursor.fetchall())
|
|
|
|
await increment_usage(ctx, "api_calls")
|
|
return {"agents": agents, "count": len(agents)}
|
|
|
|
|
|
@app.get("/agents/{agent_id}")
|
|
async def get_agent(agent_id: str, ctx: TenantContext = Depends(get_tenant_context)):
|
|
"""Get agent metrics by ID (tenant-scoped)"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT * FROM agent_metrics WHERE agent_id = ? AND tenant_id = ? AND project_id = ?",
|
|
(agent_id, ctx.tenant_id, ctx.project_id)
|
|
)
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Agent not found")
|
|
|
|
return row_to_dict(row)
|
|
|
|
|
|
@app.post("/agents")
|
|
async def create_agent(agent: AgentMetrics, ctx: TenantContext = Depends(require_role("editor"))):
|
|
"""Create or update agent metrics (tenant-scoped)"""
|
|
# Check agent quota
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT COUNT(*) as count FROM agent_metrics
|
|
WHERE tenant_id = ? AND project_id = ?
|
|
""", (ctx.tenant_id, ctx.project_id))
|
|
count = cursor.fetchone()["count"]
|
|
|
|
cursor = conn.execute("""
|
|
SELECT max_agents_per_project FROM tenant_quotas WHERE tenant_id = ?
|
|
""", (ctx.tenant_id,))
|
|
quota = cursor.fetchone()
|
|
|
|
if quota and count >= quota["max_agents_per_project"]:
|
|
# Check if this is an update (agent exists)
|
|
cursor = conn.execute("""
|
|
SELECT 1 FROM agent_metrics WHERE agent_id = ? AND tenant_id = ? AND project_id = ?
|
|
""", (agent.agent_id, ctx.tenant_id, ctx.project_id))
|
|
if not cursor.fetchone():
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"Agent quota exceeded ({quota['max_agents_per_project']} per project)"
|
|
)
|
|
|
|
conn.execute("""
|
|
INSERT OR REPLACE INTO agent_metrics
|
|
(agent_id, tenant_id, project_id, current_tier, compliant_runs, consecutive_compliant,
|
|
total_runs, last_violation_at, last_active_at, promotion_eligible, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
""", (
|
|
agent.agent_id, ctx.tenant_id, ctx.project_id, agent.current_tier, agent.compliant_runs,
|
|
agent.consecutive_compliant, agent.total_runs,
|
|
agent.last_violation_at, agent.last_active_at, agent.promotion_eligible
|
|
))
|
|
conn.commit()
|
|
|
|
return {"status": "created", "agent_id": agent.agent_id}
|
|
|
|
|
|
@app.patch("/agents/{agent_id}")
|
|
async def update_agent(
|
|
agent_id: str,
|
|
update: AgentMetricsUpdate,
|
|
ctx: TenantContext = Depends(require_role("editor"))
|
|
):
|
|
"""Partially update agent metrics (tenant-scoped)"""
|
|
updates = []
|
|
params = []
|
|
|
|
if update.current_tier is not None:
|
|
updates.append("current_tier = ?")
|
|
params.append(update.current_tier)
|
|
if update.compliant_runs is not None:
|
|
updates.append("compliant_runs = ?")
|
|
params.append(update.compliant_runs)
|
|
if update.consecutive_compliant is not None:
|
|
updates.append("consecutive_compliant = ?")
|
|
params.append(update.consecutive_compliant)
|
|
if update.total_runs is not None:
|
|
updates.append("total_runs = ?")
|
|
params.append(update.total_runs)
|
|
if update.promotion_eligible is not None:
|
|
updates.append("promotion_eligible = ?")
|
|
params.append(update.promotion_eligible)
|
|
|
|
if not updates:
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
updates.append("updated_at = datetime('now')")
|
|
params.extend([agent_id, ctx.tenant_id, ctx.project_id])
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute(
|
|
f"UPDATE agent_metrics SET {', '.join(updates)} WHERE agent_id = ? AND tenant_id = ? AND project_id = ?",
|
|
params
|
|
)
|
|
conn.commit()
|
|
|
|
if cursor.rowcount == 0:
|
|
raise HTTPException(status_code=404, detail="Agent not found")
|
|
|
|
return {"status": "updated", "agent_id": agent_id}
|
|
|
|
|
|
# =============================================================================
|
|
# Actions Endpoints (Tenant-Scoped)
|
|
# =============================================================================
|
|
|
|
@app.get("/actions")
|
|
async def list_actions(
|
|
limit: int = Query(100, le=1000),
|
|
offset: int = Query(0, ge=0),
|
|
agent_id: Optional[str] = None,
|
|
success: Optional[int] = None,
|
|
ctx: TenantContext = Depends(get_tenant_context)
|
|
):
|
|
"""List agent actions (tenant-scoped)"""
|
|
with get_db() as conn:
|
|
query = "SELECT * FROM agent_actions WHERE tenant_id = ? AND project_id = ?"
|
|
params = [ctx.tenant_id, ctx.project_id]
|
|
|
|
if agent_id:
|
|
query += " AND agent_id = ?"
|
|
params.append(agent_id)
|
|
if success is not None:
|
|
query += " AND success = ?"
|
|
params.append(success)
|
|
|
|
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
|
params.extend([limit, offset])
|
|
|
|
cursor = conn.execute(query, params)
|
|
actions = rows_to_list(cursor.fetchall())
|
|
|
|
return {"actions": actions, "count": len(actions)}
|
|
|
|
|
|
@app.post("/actions")
|
|
async def create_action(action: AgentAction, ctx: TenantContext = Depends(require_role("editor"))):
|
|
"""Log an agent action (tenant-scoped)"""
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
INSERT INTO agent_actions
|
|
(tenant_id, project_id, timestamp, agent_id, agent_version, tier, action, decision, confidence,
|
|
target, side_effects, success, error_type, error_message,
|
|
vault_token_accessor, session_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
ctx.tenant_id, ctx.project_id, timestamp, action.agent_id, action.agent_version, action.tier,
|
|
action.action, action.decision, action.confidence, action.target,
|
|
action.side_effects, action.success, action.error_type,
|
|
action.error_message, action.vault_token_accessor, action.session_id
|
|
))
|
|
conn.commit()
|
|
action_id = cursor.lastrowid
|
|
|
|
return {"status": "created", "id": action_id, "timestamp": timestamp}
|
|
|
|
|
|
# =============================================================================
|
|
# Violations Endpoints (Tenant-Scoped)
|
|
# =============================================================================
|
|
|
|
@app.get("/violations")
|
|
async def list_violations(
|
|
limit: int = Query(100, le=1000),
|
|
offset: int = Query(0, ge=0),
|
|
agent_id: Optional[str] = None,
|
|
severity: Optional[str] = None,
|
|
acknowledged: Optional[int] = None,
|
|
ctx: TenantContext = Depends(get_tenant_context)
|
|
):
|
|
"""List violations (tenant-scoped)"""
|
|
with get_db() as conn:
|
|
query = "SELECT * FROM violations WHERE tenant_id = ? AND project_id = ?"
|
|
params = [ctx.tenant_id, ctx.project_id]
|
|
|
|
if agent_id:
|
|
query += " AND agent_id = ?"
|
|
params.append(agent_id)
|
|
if severity:
|
|
query += " AND severity = ?"
|
|
params.append(severity)
|
|
if acknowledged is not None:
|
|
query += " AND acknowledged = ?"
|
|
params.append(acknowledged)
|
|
|
|
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
|
params.extend([limit, offset])
|
|
|
|
cursor = conn.execute(query, params)
|
|
violations = rows_to_list(cursor.fetchall())
|
|
|
|
return {"violations": violations, "count": len(violations)}
|
|
|
|
|
|
@app.get("/violations/{violation_id}")
|
|
async def get_violation(violation_id: int, ctx: TenantContext = Depends(get_tenant_context)):
|
|
"""Get violation by ID (tenant-scoped)"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute(
|
|
"SELECT * FROM violations WHERE id = ? AND tenant_id = ? AND project_id = ?",
|
|
(violation_id, ctx.tenant_id, ctx.project_id)
|
|
)
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Violation not found")
|
|
|
|
return row_to_dict(row)
|
|
|
|
|
|
@app.post("/violations")
|
|
async def create_violation(
|
|
violation: Violation,
|
|
ctx: TenantContext = Depends(require_role("editor"))
|
|
):
|
|
"""Report a violation (tenant-scoped)"""
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
INSERT INTO violations
|
|
(tenant_id, project_id, timestamp, agent_id, violation_type, severity, description,
|
|
triggering_action, evidence, remediation)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
ctx.tenant_id, ctx.project_id, timestamp, violation.agent_id, violation.violation_type,
|
|
violation.severity, violation.description, violation.triggering_action,
|
|
violation.evidence, violation.remediation
|
|
))
|
|
conn.commit()
|
|
violation_id = cursor.lastrowid
|
|
|
|
return {"status": "created", "id": violation_id, "timestamp": timestamp}
|
|
|
|
|
|
@app.post("/violations/{violation_id}/acknowledge")
|
|
async def acknowledge_violation(
|
|
violation_id: int,
|
|
ack: ViolationAcknowledge,
|
|
ctx: TenantContext = Depends(require_role("admin"))
|
|
):
|
|
"""Acknowledge a violation (tenant-scoped)"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
UPDATE violations
|
|
SET acknowledged = 1, acknowledged_by = ?
|
|
WHERE id = ? AND tenant_id = ? AND project_id = ?
|
|
""", (ack.acknowledged_by, violation_id, ctx.tenant_id, ctx.project_id))
|
|
conn.commit()
|
|
|
|
if cursor.rowcount == 0:
|
|
raise HTTPException(status_code=404, detail="Violation not found")
|
|
|
|
return {"status": "acknowledged", "id": violation_id}
|
|
|
|
|
|
# =============================================================================
|
|
# Promotions Endpoints (Tenant-Scoped)
|
|
# =============================================================================
|
|
|
|
@app.get("/promotions")
|
|
async def list_promotions(
|
|
limit: int = Query(100, le=1000),
|
|
offset: int = Query(0, ge=0),
|
|
agent_id: Optional[str] = None,
|
|
ctx: TenantContext = Depends(get_tenant_context)
|
|
):
|
|
"""List promotions (tenant-scoped)"""
|
|
with get_db() as conn:
|
|
query = "SELECT * FROM promotions WHERE tenant_id = ? AND project_id = ?"
|
|
params = [ctx.tenant_id, ctx.project_id]
|
|
|
|
if agent_id:
|
|
query += " AND agent_id = ?"
|
|
params.append(agent_id)
|
|
|
|
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
|
params.extend([limit, offset])
|
|
|
|
cursor = conn.execute(query, params)
|
|
promotions = rows_to_list(cursor.fetchall())
|
|
|
|
return {"promotions": promotions, "count": len(promotions)}
|
|
|
|
|
|
@app.post("/promotions")
|
|
async def create_promotion(
|
|
promotion: Promotion,
|
|
ctx: TenantContext = Depends(require_role("admin"))
|
|
):
|
|
"""Record a promotion (tenant-scoped)"""
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
INSERT INTO promotions
|
|
(tenant_id, project_id, timestamp, agent_id, from_tier, to_tier, approved_by, rationale, evidence)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
ctx.tenant_id, ctx.project_id, timestamp, promotion.agent_id, promotion.from_tier, promotion.to_tier,
|
|
promotion.approved_by, promotion.rationale, promotion.evidence
|
|
))
|
|
conn.commit()
|
|
promotion_id = cursor.lastrowid
|
|
|
|
# Update agent metrics
|
|
conn.execute("""
|
|
UPDATE agent_metrics
|
|
SET current_tier = ?, updated_at = datetime('now')
|
|
WHERE agent_id = ? AND tenant_id = ? AND project_id = ?
|
|
""", (promotion.to_tier, promotion.agent_id, ctx.tenant_id, ctx.project_id))
|
|
conn.commit()
|
|
|
|
return {"status": "created", "id": promotion_id, "timestamp": timestamp}
|
|
|
|
|
|
# =============================================================================
|
|
# Orchestration Endpoints (Tenant-Scoped)
|
|
# =============================================================================
|
|
|
|
@app.get("/orchestration")
|
|
async def list_orchestration_logs(
|
|
limit: int = Query(100, le=1000),
|
|
offset: int = Query(0, ge=0),
|
|
mode: Optional[str] = None,
|
|
success: Optional[int] = None,
|
|
ctx: TenantContext = Depends(get_tenant_context)
|
|
):
|
|
"""List orchestration logs (tenant-scoped)"""
|
|
with get_db() as conn:
|
|
query = "SELECT * FROM orchestration_log WHERE tenant_id = ? AND project_id = ?"
|
|
params = [ctx.tenant_id, ctx.project_id]
|
|
|
|
if mode:
|
|
query += " AND orchestration_mode = ?"
|
|
params.append(mode)
|
|
if success is not None:
|
|
query += " AND success = ?"
|
|
params.append(success)
|
|
|
|
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
|
params.extend([limit, offset])
|
|
|
|
cursor = conn.execute(query, params)
|
|
logs = rows_to_list(cursor.fetchall())
|
|
|
|
return {"logs": logs, "count": len(logs)}
|
|
|
|
|
|
@app.get("/orchestration/summary")
|
|
async def get_orchestration_summary(ctx: TenantContext = Depends(get_tenant_context)):
|
|
"""Get orchestration summary by mode and model (tenant-scoped)"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT model, COUNT(*) as total, SUM(success) as successful, SUM(tokens_used) as tokens
|
|
FROM orchestration_log
|
|
WHERE tenant_id = ? AND project_id = ?
|
|
GROUP BY model
|
|
""", (ctx.tenant_id, ctx.project_id))
|
|
summary = rows_to_list(cursor.fetchall())
|
|
|
|
return {"summary": summary}
|
|
|
|
|
|
@app.post("/orchestration")
|
|
async def create_orchestration_log(
|
|
log: OrchestrationLog,
|
|
ctx: TenantContext = Depends(require_role("editor"))
|
|
):
|
|
"""Log an orchestration event (tenant-scoped)"""
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
|
# Track token usage
|
|
if log.tokens_used:
|
|
await check_quota(ctx, "tokens", log.tokens_used)
|
|
await increment_usage(ctx, "tokens", log.tokens_used)
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
INSERT INTO orchestration_log
|
|
(tenant_id, project_id, timestamp, model, instruction, response, tokens_used, success, error_message)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
ctx.tenant_id, ctx.project_id, timestamp, log.model_id or log.orchestration_mode,
|
|
log.command, log.response, log.tokens_used, log.success, None
|
|
))
|
|
conn.commit()
|
|
log_id = cursor.lastrowid
|
|
|
|
return {"status": "created", "id": log_id, "timestamp": timestamp}
|
|
|
|
|
|
# =============================================================================
|
|
# Stats Endpoint (Tenant-Scoped)
|
|
# =============================================================================
|
|
|
|
@app.get("/stats")
|
|
async def get_stats(ctx: TenantContext = Depends(get_tenant_context)):
|
|
"""Get overall ledger statistics (tenant-scoped)"""
|
|
with get_db() as conn:
|
|
stats = {"tenant_id": ctx.tenant_id, "project_id": ctx.project_id}
|
|
|
|
# Agent counts by tier
|
|
cursor = conn.execute("""
|
|
SELECT current_tier, COUNT(*) as count
|
|
FROM agent_metrics
|
|
WHERE tenant_id = ? AND project_id = ?
|
|
GROUP BY current_tier
|
|
""", (ctx.tenant_id, ctx.project_id))
|
|
stats["agents_by_tier"] = {row["current_tier"]: row["count"] for row in cursor.fetchall()}
|
|
|
|
# Total agents
|
|
cursor = conn.execute("""
|
|
SELECT COUNT(*) as count FROM agent_metrics
|
|
WHERE tenant_id = ? AND project_id = ?
|
|
""", (ctx.tenant_id, ctx.project_id))
|
|
stats["total_agents"] = cursor.fetchone()["count"]
|
|
|
|
# Violation counts by severity
|
|
cursor = conn.execute("""
|
|
SELECT severity, COUNT(*) as count
|
|
FROM violations
|
|
WHERE tenant_id = ? AND project_id = ?
|
|
GROUP BY severity
|
|
""", (ctx.tenant_id, ctx.project_id))
|
|
stats["violations_by_severity"] = {row["severity"]: row["count"] for row in cursor.fetchall()}
|
|
|
|
# Unacknowledged violations
|
|
cursor = conn.execute("""
|
|
SELECT COUNT(*) as count FROM violations
|
|
WHERE tenant_id = ? AND project_id = ? AND acknowledged = 0
|
|
""", (ctx.tenant_id, ctx.project_id))
|
|
stats["unacknowledged_violations"] = cursor.fetchone()["count"]
|
|
|
|
# Total promotions
|
|
cursor = conn.execute("""
|
|
SELECT COUNT(*) as count FROM promotions
|
|
WHERE tenant_id = ? AND project_id = ?
|
|
""", (ctx.tenant_id, ctx.project_id))
|
|
stats["total_promotions"] = cursor.fetchone()["count"]
|
|
|
|
# Recent activity (last 24h)
|
|
cursor = conn.execute("""
|
|
SELECT COUNT(*) as count FROM agent_actions
|
|
WHERE tenant_id = ? AND project_id = ? AND timestamp > datetime('now', '-1 day')
|
|
""", (ctx.tenant_id, ctx.project_id))
|
|
stats["actions_last_24h"] = cursor.fetchone()["count"]
|
|
|
|
# Get quota and usage
|
|
cursor = conn.execute("""
|
|
SELECT * FROM tenant_quotas WHERE tenant_id = ?
|
|
""", (ctx.tenant_id,))
|
|
quota = cursor.fetchone()
|
|
if quota:
|
|
stats["quotas"] = row_to_dict(quota)
|
|
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
cursor = conn.execute("""
|
|
SELECT * FROM tenant_usage WHERE tenant_id = ? AND period_start = ?
|
|
""", (ctx.tenant_id, today))
|
|
usage = cursor.fetchone()
|
|
if usage:
|
|
stats["usage_today"] = row_to_dict(usage)
|
|
|
|
return stats
|
|
|
|
|
|
# =============================================================================
|
|
# Main
|
|
# =============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
print(f"""
|
|
======================================================================
|
|
AGENT GOVERNANCE LEDGER API v2.0 (Multi-Tenant)
|
|
======================================================================
|
|
|
|
Database: {DB_PATH}
|
|
Auth Required: {REQUIRE_AUTH}
|
|
Port: {API_PORT}
|
|
|
|
New in v2.0:
|
|
- Multi-tenant support with tenant/project isolation
|
|
- API key authentication
|
|
- Quota enforcement and usage tracking
|
|
- Role-based access control
|
|
|
|
Endpoints:
|
|
GET /health Health check
|
|
GET / API info
|
|
GET /docs Swagger UI
|
|
|
|
Tenants (admin only):
|
|
GET /tenants List tenants
|
|
POST /tenants Create tenant
|
|
GET /tenants/:id Get tenant
|
|
PATCH /tenants/:id Update tenant
|
|
|
|
Projects:
|
|
GET /projects List projects
|
|
POST /projects Create project
|
|
GET /projects/:id Get project
|
|
|
|
API Keys:
|
|
GET /api-keys List API keys
|
|
POST /api-keys Create API key
|
|
DELETE /api-keys/:id Revoke API key
|
|
|
|
Agents (tenant-scoped):
|
|
GET /agents List agents
|
|
GET /agents/:id Get agent
|
|
POST /agents Create/update agent
|
|
PATCH /agents/:id Update agent
|
|
|
|
Actions (tenant-scoped):
|
|
GET /actions List actions
|
|
POST /actions Log action
|
|
|
|
Violations (tenant-scoped):
|
|
GET /violations List violations
|
|
GET /violations/:id Get violation
|
|
POST /violations Report violation
|
|
POST /violations/:id/ack Acknowledge
|
|
|
|
Promotions (tenant-scoped):
|
|
GET /promotions List promotions
|
|
POST /promotions Record promotion
|
|
|
|
Orchestration (tenant-scoped):
|
|
GET /orchestration List orchestration logs
|
|
GET /orchestration/summary Summary by mode
|
|
POST /orchestration Log orchestration
|
|
|
|
GET /stats Statistics (tenant-scoped)
|
|
|
|
Headers:
|
|
X-Vault-Token: <token> Vault authentication
|
|
X-API-Key: <key> API key authentication
|
|
X-Tenant-ID: <id> Tenant context (with Vault auth)
|
|
X-Project-ID: <id> Project context
|
|
======================================================================
|
|
""")
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=API_PORT)
|