Kairo-Jinkendo/backend/services/actor_service_tokens.py
Lars 5dc372c043
Some checks failed
Deploy Development / deploy (push) Failing after 42s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
AP1.7: Operational API und Actor Service Tokens fuer Vibe-Coder.
Agenten authentifizieren per X-Actor-Token; /api/operational/ Fassade mit context, next-action, status, evidence; Token-Verwaltung unter /api/actors/{id}/service-tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:21:16 +02:00

279 lines
9.1 KiB
Python

"""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()