#!/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: Vault authentication X-API-Key: API key authentication X-Tenant-ID: Tenant context (with Vault auth) X-Project-ID: Project context ====================================================================== """) uvicorn.run(app, host="0.0.0.0", port=API_PORT)