"""Actor Service Tokens — AP1.7c (machine auth for Operational API).""" from __future__ import annotations import hashlib import json import secrets from datetime import datetime, timedelta, timezone from typing import Any, Optional from psycopg2.extras import RealDictCursor from db import get_connection from services.audit import log_audit TOKEN_PREFIX = "kairo_at_" DEFAULT_AGENT_CAPABILITIES: tuple[str, ...] = ( "kairo.initiative.read", "kairo.action.read", "kairo.action.manage", "kairo.blocker.read", "kairo.blocker.manage", "kairo.evidence.read", "kairo.evidence.manage", "kairo.backlog.read", "kairo.backlog.manage", "kairo.decision.read", "kairo.decision.manage", ) ALLOWED_TOKEN_CAPABILITIES = frozenset(DEFAULT_AGENT_CAPABILITIES) def _hash_token(raw_token: str) -> str: return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() def _generate_token_parts() -> tuple[str, str, str]: prefix = secrets.token_hex(6) secret = secrets.token_urlsafe(24) raw = f"{TOKEN_PREFIX}{prefix}_{secret}" return prefix, secret, raw def _validate_capabilities(capabilities: list[str]) -> list[str]: if not capabilities: return list(DEFAULT_AGENT_CAPABILITIES) invalid = [cap for cap in capabilities if cap not in ALLOWED_TOKEN_CAPABILITIES] if invalid: raise ValueError(f"Capabilities nicht erlaubt für Service Token: {', '.join(invalid)}") return sorted(set(capabilities)) def _serialize_token_row(row: dict[str, Any], *, include_actor: bool = False) -> dict[str, Any]: item = { "id": str(row["id"]), "tenant_id": str(row["tenant_id"]), "actor_id": str(row["actor_id"]), "label": row["label"], "token_prefix": row["token_prefix"], "capabilities": list(row.get("capabilities") or []), "is_active": bool(row["is_active"]), "expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None, "last_used_at": row["last_used_at"].isoformat() if row.get("last_used_at") else None, "created_at": row["created_at"].isoformat() if row.get("created_at") else None, "revoked_at": row["revoked_at"].isoformat() if row.get("revoked_at") else None, } if include_actor and row.get("actor_name"): item["actor_name"] = row["actor_name"] item["actor_type"] = row.get("actor_type") return item def create_service_token( *, tenant_id: str, actor_id: str, label: str, created_by_user_id: Optional[str] = None, capabilities: Optional[list[str]] = None, expires_in_days: Optional[int] = 365, ) -> dict[str, Any]: label = label.strip() if not label: raise ValueError("Label ist erforderlich") caps = _validate_capabilities(list(capabilities or [])) prefix, _secret, raw_token = _generate_token_parts() token_hash = _hash_token(raw_token) expires_at = None if expires_in_days and expires_in_days > 0: expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, actor_type, name, is_active FROM actors WHERE id = %s AND tenant_id = %s """, (actor_id, tenant_id), ) actor = cur.fetchone() if not actor: raise ValueError("Actor nicht gefunden") if not actor["is_active"]: raise ValueError("Actor ist inaktiv") cur.execute( """ INSERT INTO actor_service_tokens ( tenant_id, actor_id, label, token_prefix, token_hash, capabilities, expires_at, created_by_user_id ) VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s) RETURNING id, tenant_id, actor_id, label, token_prefix, capabilities, is_active, expires_at, last_used_at, created_at, revoked_at """, ( tenant_id, actor_id, label, prefix, token_hash, json.dumps(caps), expires_at, created_by_user_id, ), ) row = dict(cur.fetchone()) conn.commit() finally: conn.close() log_audit( "actor_service_token.created", user_id=created_by_user_id, tenant_id=tenant_id, details={"token_id": str(row["id"]), "actor_id": actor_id, "label": label}, ) result = _serialize_token_row(row) result["token"] = raw_token return result def list_service_tokens_for_actor( *, tenant_id: str, actor_id: str, include_revoked: bool = False ) -> list[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: conditions = ["t.tenant_id = %s", "t.actor_id = %s"] params: list[Any] = [tenant_id, actor_id] if not include_revoked: conditions.append("t.revoked_at IS NULL") where = " AND ".join(conditions) cur.execute( f""" SELECT t.id, t.tenant_id, t.actor_id, t.label, t.token_prefix, t.capabilities, t.is_active, t.expires_at, t.last_used_at, t.created_at, t.revoked_at FROM actor_service_tokens t WHERE {where} ORDER BY t.created_at DESC """, params, ) return [_serialize_token_row(dict(row)) for row in cur.fetchall()] finally: conn.close() def revoke_service_token( *, tenant_id: str, token_id: str, user_id: Optional[str] = None ) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE actor_service_tokens SET is_active = FALSE, revoked_at = NOW() WHERE id = %s AND tenant_id = %s AND revoked_at IS NULL """, (token_id, tenant_id), ) updated = cur.rowcount > 0 conn.commit() finally: conn.close() if updated: log_audit( "actor_service_token.revoked", user_id=user_id, tenant_id=tenant_id, details={"token_id": token_id}, ) return updated def validate_service_token(raw_token: str) -> Optional[dict[str, Any]]: """Resolve token → auth context dict or None.""" token = (raw_token or "").strip() if not token.startswith(TOKEN_PREFIX) or len(token) < 20: return None token_hash = _hash_token(token) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT t.id, t.tenant_id, t.actor_id, t.capabilities, t.is_active, t.expires_at, t.revoked_at, a.name AS actor_name, a.actor_type, a.is_active AS actor_is_active, tn.slug AS tenant_slug, tn.name AS tenant_name FROM actor_service_tokens t JOIN actors a ON a.id = t.actor_id AND a.tenant_id = t.tenant_id JOIN tenants tn ON tn.id = t.tenant_id WHERE t.token_hash = %s LIMIT 1 """, (token_hash,), ) row = cur.fetchone() if not row: return None row = dict(row) if not row["is_active"] or row.get("revoked_at"): return None if not row["actor_is_active"] or not row.get("tenant_slug"): return None if row.get("expires_at"): expires = row["expires_at"] if expires.tzinfo is None: expires = expires.replace(tzinfo=timezone.utc) if expires <= datetime.now(timezone.utc): return None cur.execute( """ UPDATE actor_service_tokens SET last_used_at = NOW() WHERE id = %s """, (str(row["id"]),), ) conn.commit() caps = row.get("capabilities") or [] if isinstance(caps, str): caps = json.loads(caps) return { "token_id": str(row["id"]), "tenant_id": str(row["tenant_id"]), "tenant_slug": row["tenant_slug"], "tenant_name": row["tenant_name"], "actor_id": str(row["actor_id"]), "actor_name": row["actor_name"], "actor_type": row["actor_type"], "capabilities": frozenset(str(c) for c in caps), } finally: conn.close()