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
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>
279 lines
9.1 KiB
Python
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()
|