AP1.7: Operational API und Actor Service Tokens fuer Vibe-Coder.
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>
This commit is contained in:
Lars 2026-07-12 12:21:16 +02:00
parent 2359cc8b59
commit 5dc372c043
13 changed files with 975 additions and 6 deletions

View File

@ -0,0 +1,74 @@
---
description: Vibe-Coder Agent — Kairo Operational API mit Actor Service Token
alwaysApply: false
globs:
- "**/*"
---
# Kairo — Vibe-Coder API (AP1.7)
Du arbeitest als **Agent-Actor** an Kairo. Nutze die **Operational API** — nicht die volle UI-REST-API.
## Einmal-Setup (Nutzer, einmalig)
1. In Kairo als Tenant-Admin einloggen (Browser).
2. Actor `Cursor Agent` anlegen (Typ `agent`) — oder vorhandenen aus Actor-Verzeichnis nutzen.
3. Service Token erstellen:
```http
POST /api/actors/{actor_id}/service-tokens
X-Auth-Token: {session}
Content-Type: application/json
{"label": "Cursor Agent", "capabilities": []}
```
Antwort enthält **`token`** (nur einmal sichtbar) — Format: `kairo_at_{prefix}_{secret}`.
4. In Cursor **Environment Variables** (oder `.env` lokal, nicht committen):
```text
KAIRO_API_BASE=http://192.168.2.144:3097
KAIRO_ACTOR_TOKEN=kairo_at_...
```
Optional für Dogfooding:
```text
KAIRO_INITIATIVE_ID={uuid von „Jinkendo Kairo“}
```
## Authentifizierung
Jeder Operational-Request:
```http
X-Actor-Token: {KAIRO_ACTOR_TOKEN}
```
Alternativ: `Authorization: Bearer {KAIRO_ACTOR_TOKEN}`
**Nicht** Session-Token in Agent-Skripten hardcoden — Service Token ist tenant-scoped, capability-gebunden, widerrufbar.
## Typischer Agent-Loop
```text
GET /api/operational/me
GET /api/operational/initiatives/{id}/context
GET /api/operational/next-action?initiative_id={id}&limit=5
… Arbeit …
PATCH /api/operational/actions/{id}/status {"status":"in_progress"}
PATCH /api/operational/actions/{id}/status {"status":"done","note":"Commit …"}
POST /api/operational/evidence {"initiative_id","action_id","title","body":"Commit-Link"}
```
## Guardrails
- Kein Gate `reached`, kein Lifecycle-Wechsel, kein Portfolio-Reorder über Operational API.
- Mutierende Calls werden auditiert (`actor_id` aus Token).
- Token-Leak → Token widerrufen: `DELETE /api/actors/service-tokens/{token_id}` (Session-Auth).
## Referenz
- `docs/architecture/ADP_Operational_Actor_Interface_Vibe_Coder_v0.1.md`
- OpenAPI: `{KAIRO_API_BASE}/api/docs` (Nicht-Prod)

View File

@ -79,6 +79,7 @@ from routers import ( # noqa: E402
steering, steering,
tasks, tasks,
workspace, workspace,
operational,
) )
app.include_router(auth.router) app.include_router(auth.router)
@ -109,6 +110,7 @@ app.include_router(recurring.router)
app.include_router(steering.router) app.include_router(steering.router)
app.include_router(actors.router) app.include_router(actors.router)
app.include_router(workspace.router) app.include_router(workspace.router)
app.include_router(operational.router)
@app.get("/api/health") @app.get("/api/health")

View File

@ -0,0 +1,22 @@
-- AP1.7c — Actor Service Tokens für Operational API (Vibe-Coder / Agenten)
CREATE TABLE actor_service_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE CASCADE,
label VARCHAR(255) NOT NULL,
token_prefix VARCHAR(16) NOT NULL,
token_hash VARCHAR(64) NOT NULL,
capabilities JSONB NOT NULL DEFAULT '[]'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
expires_at TIMESTAMPTZ NULL,
last_used_at TIMESTAMPTZ NULL,
created_by_user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ NULL,
UNIQUE (token_prefix)
);
CREATE INDEX idx_actor_service_tokens_tenant ON actor_service_tokens(tenant_id);
CREATE INDEX idx_actor_service_tokens_actor ON actor_service_tokens(actor_id);
CREATE INDEX idx_actor_service_tokens_hash ON actor_service_tokens(token_hash);

View File

@ -0,0 +1,92 @@
"""Operational API auth — Session or Actor Service Token (AP1.7c)."""
from __future__ import annotations
from typing import Callable, Optional
from fastapi import Depends, Header, HTTPException
from auth import AUTH_HEADER, get_session
from capabilities import _enforce_capability
from services.actor_service_tokens import validate_service_token
from tenant_context import TenantContext, resolve_tenant_context
ACTOR_TOKEN_HEADER = "X-Actor-Token"
def _extract_actor_token(
x_actor_token: Optional[str],
authorization: Optional[str],
) -> Optional[str]:
if x_actor_token and x_actor_token.strip():
return x_actor_token.strip()
if authorization:
scheme, _, value = authorization.partition(" ")
if scheme.lower() == "bearer" and value.strip():
return value.strip()
return None
def resolve_service_token_context(raw_token: str) -> TenantContext:
resolved = validate_service_token(raw_token)
if not resolved:
raise HTTPException(status_code=401, detail="Ungültiger oder abgelaufener Actor-Token")
return TenantContext(
user_id="",
email="",
display_name=resolved["actor_name"],
portal_role="",
tenant_id=resolved["tenant_id"],
tenant_slug=resolved["tenant_slug"],
tenant_name=resolved["tenant_name"],
tenant_role=None,
actor_id=resolved["actor_id"],
actor_type=resolved["actor_type"],
session_token="",
capabilities=resolved["capabilities"],
auth_source="service_token",
service_token_id=resolved["token_id"],
)
def get_operational_context(
x_actor_token: Optional[str] = Header(default=None, alias=ACTOR_TOKEN_HEADER),
authorization: Optional[str] = Header(default=None),
x_auth_token: Optional[str] = Header(default=None, alias=AUTH_HEADER),
) -> TenantContext:
actor_token = _extract_actor_token(x_actor_token, authorization)
if actor_token:
return resolve_service_token_context(actor_token)
session_token = (x_auth_token or "").strip()
if not session_token:
raise HTTPException(
status_code=401,
detail="Authentifizierung erforderlich (X-Actor-Token oder X-Auth-Token)",
)
session = get_session(session_token)
if not session:
raise HTTPException(status_code=401, detail="Ungültige Session")
ctx = resolve_tenant_context(session)
if not ctx.tenant_id:
raise HTTPException(status_code=403, detail="Kein aktiver Tenant — Tenant wählen")
return ctx
def require_operational_capability(capability_key: str) -> Callable[..., TenantContext]:
"""Service tokens: always enforce. Sessions: probe/enforce per CAPABILITY_ENFORCE."""
def _dependency(
ctx: TenantContext = Depends(get_operational_context),
) -> TenantContext:
if ctx.auth_source == "service_token":
if capability_key not in ctx.capabilities:
raise HTTPException(
status_code=403,
detail=f"Capability fehlt auf Service Token: {capability_key}",
)
return ctx
return _enforce_capability(ctx, capability_key)
return _dependency

View File

@ -1,4 +1,4 @@
"""Actor directory API — AP0.7.""" """Actor directory API — AP0.7 + AP1.7c Service Tokens."""
from __future__ import annotations from __future__ import annotations
@ -6,12 +6,20 @@ from typing import Literal, Optional
from capabilities import require_capability from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from services import actor_service_tokens
from services import actors as actor_service from services import actors as actor_service
from tenant_context import TenantContext from tenant_context import TenantContext
router = APIRouter(prefix="/api/actors", tags=["actors"]) router = APIRouter(prefix="/api/actors", tags=["actors"])
class ServiceTokenCreateRequest(BaseModel):
label: str = Field(min_length=1, max_length=255)
capabilities: list[str] = Field(default_factory=list)
expires_in_days: Optional[int] = Field(default=365, ge=1, le=730)
@router.get("") @router.get("")
def list_actors( def list_actors(
actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None, actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None,
@ -39,3 +47,54 @@ def get_actor(
if not item: if not item:
raise HTTPException(status_code=404, detail="Actor nicht gefunden") raise HTTPException(status_code=404, detail="Actor nicht gefunden")
return item return item
@router.get("/{actor_id}/service-tokens")
def list_actor_service_tokens(
actor_id: str,
include_revoked: bool = Query(default=False),
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
):
actor = actor_service.get_actor(tenant_id=ctx.tenant_id, actor_id=actor_id)
if not actor:
raise HTTPException(status_code=404, detail="Actor nicht gefunden")
return actor_service_tokens.list_service_tokens_for_actor(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
include_revoked=include_revoked,
)
@router.post("/{actor_id}/service-tokens", status_code=201)
def create_actor_service_token(
actor_id: str,
body: ServiceTokenCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
):
try:
return actor_service_tokens.create_service_token(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
label=body.label,
created_by_user_id=ctx.user_id,
capabilities=body.capabilities,
expires_in_days=body.expires_in_days,
)
except ValueError as exc:
detail = str(exc)
if detail == "Actor nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.delete("/service-tokens/{token_id}", status_code=204)
def revoke_actor_service_token(
token_id: str,
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
):
if not actor_service_tokens.revoke_service_token(
tenant_id=ctx.tenant_id,
token_id=token_id,
user_id=ctx.user_id,
):
raise HTTPException(status_code=404, detail="Token nicht gefunden")

View File

@ -0,0 +1,307 @@
"""Operational Actor API — AP1.7 (Vibe-Coder / Agenten)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from data_layer.attention import get_next_action_candidates_for_initiative
from data_layer.initiative_snapshot import get_initiative_steering_snapshot
from fastapi import APIRouter, Depends, HTTPException, Query
from operational_auth import require_operational_capability
from pydantic import BaseModel, Field
from services import actions as action_service
from services import backlog as backlog_service
from services import blockers as blocker_service
from services import decisions as decision_service
from services import evidence as evidence_service
from services.audit import log_audit
from tenant_context import TenantContext
router = APIRouter(prefix="/api/operational", tags=["operational"])
def _ok(ctx: TenantContext, data: Any) -> dict[str, Any]:
return {
"ok": True,
"data": data,
"actor_id": ctx.actor_id,
}
class ActionStatusPatch(BaseModel):
status: Literal[
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
]
note: Optional[str] = None
class ActionAssignmentsBody(BaseModel):
actor_ids: list[str] = Field(default_factory=list)
class BlockerCreateBody(BaseModel):
initiative_id: str
title: str = Field(min_length=1, max_length=255)
description: str = ""
action_id: Optional[str] = None
status: Literal["open", "in_progress"] = "open"
class BlockerPatchBody(BaseModel):
status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
class EvidenceCreateBody(BaseModel):
initiative_id: str
title: str = Field(min_length=1, max_length=255)
body: str = ""
action_id: Optional[str] = None
roadmap_item_id: Optional[str] = None
status: Literal["submitted", "accepted", "rejected"] = "submitted"
class DecisionProposalBody(BaseModel):
initiative_id: str
title: str = Field(min_length=1, max_length=255)
rationale: str = ""
proposed_status: Literal["proposed"] = "proposed"
class BacklogProposalBody(BaseModel):
initiative_id: str
title: str = Field(min_length=1, max_length=255)
description: str = ""
roadmap_item_id: Optional[str] = None
@router.get("/me")
def operational_me(ctx: TenantContext = Depends(require_operational_capability("kairo.action.read"))):
return _ok(
ctx,
{
"auth_source": ctx.auth_source,
"tenant_id": ctx.tenant_id,
"tenant_slug": ctx.tenant_slug,
"actor_id": ctx.actor_id,
"actor_type": ctx.actor_type,
"display_name": ctx.display_name,
"capabilities": sorted(ctx.capabilities),
},
)
@router.get("/initiatives/{initiative_id}/context")
def operational_context(
initiative_id: str,
ctx: TenantContext = Depends(require_operational_capability("kairo.initiative.read")),
):
snapshot = get_initiative_steering_snapshot(ctx, initiative_id=initiative_id)
if not snapshot:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
subset = {
"initiative_id": snapshot.get("initiative_id"),
"title": snapshot.get("title"),
"status": snapshot.get("status"),
"method_key": snapshot.get("method_key"),
"archetype_key": snapshot.get("archetype_key"),
"archetype_label": snapshot.get("archetype_label"),
"steering_guidance": snapshot.get("steering_guidance"),
"lifecycle": snapshot.get("lifecycle"),
"signals": snapshot.get("signals"),
"attention": snapshot.get("attention"),
}
return _ok(ctx, subset)
@router.get("/next-action")
def operational_next_action(
initiative_id: str = Query(...),
limit: int = Query(default=5, ge=1, le=20),
ctx: TenantContext = Depends(require_operational_capability("kairo.initiative.read")),
):
items = get_next_action_candidates_for_initiative(
ctx, initiative_id=initiative_id, limit=limit
)
return _ok(ctx, items)
@router.get("/actions/{action_id}")
def operational_get_action(
action_id: str,
ctx: TenantContext = Depends(require_operational_capability("kairo.action.read")),
):
item = action_service.get_action(tenant_id=ctx.tenant_id, action_id=action_id)
if not item:
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
return _ok(ctx, item)
@router.patch("/actions/{action_id}/status")
def operational_patch_action_status(
action_id: str,
body: ActionStatusPatch,
ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")),
):
description = None
if body.note:
description = body.note.strip()
try:
item = action_service.update_action(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id or None,
status=body.status,
description=description,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
log_audit(
"operational.action.status",
user_id=ctx.user_id or None,
tenant_id=ctx.tenant_id,
details={
"action_id": action_id,
"status": body.status,
"actor_id": ctx.actor_id,
"auth_source": ctx.auth_source,
},
)
return _ok(ctx, item)
@router.post("/actions/{action_id}/assignments")
def operational_set_assignments(
action_id: str,
body: ActionAssignmentsBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")),
):
try:
item = action_service.set_action_assignments(
tenant_id=ctx.tenant_id,
action_id=action_id,
actor_ids=body.actor_ids,
user_id=ctx.user_id or None,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
return _ok(ctx, item)
@router.post("/blockers", status_code=201)
def operational_create_blocker(
body: BlockerCreateBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.blocker.manage")),
):
try:
item = blocker_service.create_blocker(
tenant_id=ctx.tenant_id,
initiative_id=body.initiative_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
reported_by_actor_id=ctx.actor_id,
user_id=ctx.user_id or None,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return _ok(ctx, item)
@router.patch("/blockers/{blocker_id}")
def operational_patch_blocker(
blocker_id: str,
body: BlockerPatchBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.blocker.manage")),
):
try:
item = blocker_service.update_blocker(
tenant_id=ctx.tenant_id,
blocker_id=blocker_id,
user_id=ctx.user_id or None,
status=body.status,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
return _ok(ctx, item)
@router.post("/evidence", status_code=201)
def operational_create_evidence(
body: EvidenceCreateBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.evidence.manage")),
):
try:
item = evidence_service.create_evidence(
tenant_id=ctx.tenant_id,
initiative_id=body.initiative_id,
title=body.title,
description=body.body,
status=body.status,
action_id=body.action_id,
roadmap_item_id=body.roadmap_item_id,
submitted_by_actor_id=ctx.actor_id,
user_id=ctx.user_id or None,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return _ok(ctx, item)
@router.post("/decisions/proposals", status_code=201)
def operational_decision_proposal(
body: DecisionProposalBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.decision.manage")),
):
try:
item = decision_service.create_decision(
tenant_id=ctx.tenant_id,
initiative_id=body.initiative_id,
title=body.title,
description=body.rationale,
status=body.proposed_status,
decided_by_actor_id=ctx.actor_id,
user_id=ctx.user_id or None,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return _ok(ctx, item)
@router.post("/backlog/proposals", status_code=201)
def operational_backlog_proposal(
body: BacklogProposalBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.backlog.manage")),
):
try:
item = backlog_service.create_backlog_item(
tenant_id=ctx.tenant_id,
initiative_id=body.initiative_id,
title=body.title,
description=body.description,
status="new",
roadmap_item_id=body.roadmap_item_id,
user_id=ctx.user_id or None,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return _ok(ctx, item)

View File

@ -440,4 +440,12 @@ def run() -> None:
) )
_ensure_portfolio_first(tenant_id=tenant_id, initiative_id=initiative_id) _ensure_portfolio_first(tenant_id=tenant_id, initiative_id=initiative_id)
from services.actors import create_actor, list_actors
agents = list_actors(tenant_id=tenant_id, actor_type="agent", q="Cursor")
if not any(a["name"] == "Cursor Agent" for a in agents):
create_actor(tenant_id=tenant_id, actor_type="agent", name="Cursor Agent")
print("[dogfooding] Agent „Cursor Agent“ angelegt — Service Token manuell erstellen")
print("[dogfooding] R2 sync — Ist-Stand gespiegelt (AP1.9c, AP1.16, AP2.0d done)") print("[dogfooding] R2 sync — Ist-Stand gespiegelt (AP1.9c, AP1.16, AP2.0d done)")

View File

@ -0,0 +1,278 @@
"""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()

View File

@ -28,6 +28,8 @@ class TenantContext:
actor_type: Optional[str] actor_type: Optional[str]
session_token: str session_token: str
capabilities: frozenset[str] capabilities: frozenset[str]
auth_source: str = "session"
service_token_id: Optional[str] = None
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {

View File

@ -0,0 +1,124 @@
"""AP1.7 — Operational API and Actor Service Tokens."""
from __future__ import annotations
import hashlib
from auth import AUTH_HEADER
from services import actors as actor_service
from services.actor_service_tokens import (
TOKEN_PREFIX,
_hash_token,
create_service_token,
validate_service_token,
)
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def test_token_hash_roundtrip():
raw = f"{TOKEN_PREFIX}abc123_secretpart"
assert _hash_token(raw) == hashlib.sha256(raw.encode()).hexdigest()
def test_validate_service_token_rejects_garbage():
assert validate_service_token("not-a-token") is None
assert validate_service_token("") is None
def test_operational_api_with_service_token(client):
admin = provision_user_in_tenant(tenant_role="admin")
token = _login(client, admin)
agent = actor_service.create_actor(
tenant_id=admin["tenant_id"],
actor_type="agent",
name="Cursor Agent",
)
created = create_service_token(
tenant_id=admin["tenant_id"],
actor_id=agent["id"],
label="Test Agent",
created_by_user_id=admin["id"],
)
actor_token = created["token"]
me = client.get(
"/api/operational/me",
headers={"X-Actor-Token": actor_token},
)
assert me.status_code == 200
body = me.json()
assert body["ok"] is True
assert body["actor_id"] == agent["id"]
assert body["data"]["auth_source"] == "service_token"
initiative = _create_initiative(client, token, title="Agent Test Initiative")
initiative_id = initiative.json()["id"]
ctx = client.get(
f"/api/operational/initiatives/{initiative_id}/context",
headers={"X-Actor-Token": actor_token},
)
assert ctx.status_code == 200
assert ctx.json()["data"]["title"] == "Agent Test Initiative"
next_action = client.get(
f"/api/operational/next-action?initiative_id={initiative_id}&limit=3",
headers={"X-Actor-Token": actor_token},
)
assert next_action.status_code == 200
assert isinstance(next_action.json()["data"], list)
def test_operational_token_missing_capability(client):
admin = provision_user_in_tenant(tenant_role="admin")
agent = actor_service.create_actor(
tenant_id=admin["tenant_id"],
actor_type="agent",
name="Limited Agent",
)
created = create_service_token(
tenant_id=admin["tenant_id"],
actor_id=agent["id"],
label="Read-only",
created_by_user_id=admin["id"],
capabilities=["kairo.action.read"],
)
res = client.post(
"/api/operational/evidence",
json={
"initiative_id": "00000000-0000-0000-0000-000000000001",
"title": "Should fail",
},
headers={"X-Actor-Token": created["token"]},
)
assert res.status_code == 403
def test_create_service_token_via_api(client):
admin = provision_user_in_tenant(tenant_role="admin")
token = _login(client, admin)
agent = actor_service.create_actor(
tenant_id=admin["tenant_id"],
actor_type="agent",
name="API Token Agent",
)
res = client.post(
f"/api/actors/{agent['id']}/service-tokens",
json={"label": "Cursor"},
headers=_auth(token),
)
assert res.status_code == 201
body = res.json()
assert body["token"].startswith(TOKEN_PREFIX)
assert body["actor_id"] == agent["id"]
listed = client.get(
f"/api/actors/{agent['id']}/service-tokens",
headers=_auth(token),
)
assert listed.status_code == 200
assert len(listed.json()) >= 1

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.18.0-ap2.0" APP_VERSION = "0.19.0-ap1.7"
DB_SCHEMA_VERSION = "023" DB_SCHEMA_VERSION = "024"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -185,7 +185,7 @@ Siehe **`Kairo_Status_Review_and_Next_Steps_v0.1.md` §5** für vollständige Ro
| 3 | AP2.0d Next-Action-Strategien | **◐ Code** | Remote-Verifikation nach Deploy | | 3 | AP2.0d Next-Action-Strategien | **◐ Code** | Remote-Verifikation nach Deploy |
| 4 | AP1.16cd Plan-Outline-Kanten + Planning Debt | **◐ Code** | Remote-Verifikation nach Deploy | | 4 | AP1.16cd Plan-Outline-Kanten + Planning Debt | **◐ Code** | Remote-Verifikation nach Deploy |
| 5 | Dogfooding R2 — Ist + Gitea-Evidence | **→ nächstes** | Seed sync + manuelle Abnahfe | | 5 | Dogfooding R2 — Ist + Gitea-Evidence | **→ nächstes** | Seed sync + manuelle Abnahfe |
| 6 | AP0.10d / AP2.1 Validation B2b | offen | MVP-Urteil | | 6 | AP1.7 Operational Actor API | **◐ Code** | `/api/operational/` + Service Tokens |
| 7 | AP2.0f work_cycle (B3 minimal) | offen | Sprint-Zeitbox | | 7 | AP2.0f work_cycle (B3 minimal) | offen | Sprint-Zeitbox |
| 8 | AP1.7 Operational API | offen | MCP-Voraussetzung | | 8 | AP1.7 Operational API | offen | MCP-Voraussetzung |
| 9 | Gitea-Webhook + MCP | deferred | Schicht 4 | | 9 | Gitea-Webhook + MCP | deferred | Schicht 4 |

View File

@ -154,7 +154,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| maturity / sequential / queue / recurring Strategien | ◐ | AP2.0d Code; Remote-Verifikation nach Deploy | | maturity / sequential / queue / recurring Strategien | ◐ | AP2.0d Code; Remote-Verifikation nach Deploy |
| Referenz-Ausprägung product.kairo_dev | ◐ | Code-Seed; Dogfooding manuell | | Referenz-Ausprägung product.kairo_dev | ◐ | Code-Seed; Dogfooding manuell |
| MVP-Abnahfe Stufe A validiert | ✗ | AP0.10d / AP2.1 offen | | MVP-Abnahfe Stufe A validiert | ✗ | AP0.10d / AP2.1 offen |
| Operational Actor API | ✗ | AP1.7 | | Operational Actor API | ◐ | AP1.7 Code: `/api/operational/` + Service Tokens; Remote-Verifikation |
| Actor Service Token | ◐ | AP1.7c; Tenant-scoped, capability-gebunden |
| MCP produktiv | ✗ | nach AP1.7 | | MCP produktiv | ✗ | nach AP1.7 |
--- ---
@ -208,7 +209,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1
| AP2.0d | Strategien ◐ Code | | AP2.0d | Strategien ◐ Code |
| Dogfooding R1 | Referenz-Vorhaben in Alltag ◐ | Seed R2 sync nach Deploy | | Dogfooding R1 | Referenz-Vorhaben in Alltag ◐ | Seed R2 sync nach Deploy |
| AP2.0f | work_cycle ○→◐ | | AP2.0f | work_cycle ○→◐ |
| AP1.7 | Operational API ✗→◐ | | AP1.7 | Operational API ◐ Code |
--- ---