profit 8c6e7831e9 Add Phase 10-12 implementation: multi-tenant, marketplace, observability
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>
2026-01-24 18:39:47 -05:00

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)