From 5dc372c04312efe0d1dbadfffb80f64d9a21670d Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 12 Jul 2026 12:21:16 +0200 Subject: [PATCH] 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 --- .cursor/rules/kairo-vibe-coder-api.mdc | 74 +++++ backend/main.py | 2 + .../migrations/024_actor_service_tokens.sql | 22 ++ backend/operational_auth.py | 92 ++++++ backend/routers/actors.py | 61 +++- backend/routers/operational.py | 307 ++++++++++++++++++ .../seed_004_dogfooding_kairo_jinkendo.py | 8 + backend/services/actor_service_tokens.py | 278 ++++++++++++++++ backend/tenant_context.py | 2 + backend/tests/test_ap17_operational_api.py | 124 +++++++ backend/version.py | 4 +- .../Kairo_Corrected_MVP_Roadmap_v0.2.md | 2 +- .../Kairo_Implementation_Truth_Table_v0.1.md | 5 +- 13 files changed, 975 insertions(+), 6 deletions(-) create mode 100644 .cursor/rules/kairo-vibe-coder-api.mdc create mode 100644 backend/migrations/024_actor_service_tokens.sql create mode 100644 backend/operational_auth.py create mode 100644 backend/routers/operational.py create mode 100644 backend/services/actor_service_tokens.py create mode 100644 backend/tests/test_ap17_operational_api.py diff --git a/.cursor/rules/kairo-vibe-coder-api.mdc b/.cursor/rules/kairo-vibe-coder-api.mdc new file mode 100644 index 0000000..8bdd137 --- /dev/null +++ b/.cursor/rules/kairo-vibe-coder-api.mdc @@ -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) diff --git a/backend/main.py b/backend/main.py index 0b85aef..d2ff092 100644 --- a/backend/main.py +++ b/backend/main.py @@ -79,6 +79,7 @@ from routers import ( # noqa: E402 steering, tasks, workspace, + operational, ) app.include_router(auth.router) @@ -109,6 +110,7 @@ app.include_router(recurring.router) app.include_router(steering.router) app.include_router(actors.router) app.include_router(workspace.router) +app.include_router(operational.router) @app.get("/api/health") diff --git a/backend/migrations/024_actor_service_tokens.sql b/backend/migrations/024_actor_service_tokens.sql new file mode 100644 index 0000000..3367c6f --- /dev/null +++ b/backend/migrations/024_actor_service_tokens.sql @@ -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); diff --git a/backend/operational_auth.py b/backend/operational_auth.py new file mode 100644 index 0000000..049b9d6 --- /dev/null +++ b/backend/operational_auth.py @@ -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 diff --git a/backend/routers/actors.py b/backend/routers/actors.py index 5c26b30..d0a6ee1 100644 --- a/backend/routers/actors.py +++ b/backend/routers/actors.py @@ -1,4 +1,4 @@ -"""Actor directory API — AP0.7.""" +"""Actor directory API — AP0.7 + AP1.7c Service Tokens.""" from __future__ import annotations @@ -6,12 +6,20 @@ from typing import Literal, Optional from capabilities import require_capability 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 tenant_context import TenantContext 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("") def list_actors( actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None, @@ -39,3 +47,54 @@ def get_actor( if not item: raise HTTPException(status_code=404, detail="Actor nicht gefunden") 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") diff --git a/backend/routers/operational.py b/backend/routers/operational.py new file mode 100644 index 0000000..6b3f474 --- /dev/null +++ b/backend/routers/operational.py @@ -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) diff --git a/backend/seeds/seed_004_dogfooding_kairo_jinkendo.py b/backend/seeds/seed_004_dogfooding_kairo_jinkendo.py index 96933fa..da3ca8f 100644 --- a/backend/seeds/seed_004_dogfooding_kairo_jinkendo.py +++ b/backend/seeds/seed_004_dogfooding_kairo_jinkendo.py @@ -440,4 +440,12 @@ def run() -> None: ) _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)") diff --git a/backend/services/actor_service_tokens.py b/backend/services/actor_service_tokens.py new file mode 100644 index 0000000..3b24ec4 --- /dev/null +++ b/backend/services/actor_service_tokens.py @@ -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() diff --git a/backend/tenant_context.py b/backend/tenant_context.py index ea9a7e9..2aa79f9 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -28,6 +28,8 @@ class TenantContext: actor_type: Optional[str] session_token: str capabilities: frozenset[str] + auth_source: str = "session" + service_token_id: Optional[str] = None def to_dict(self) -> dict[str, Any]: return { diff --git a/backend/tests/test_ap17_operational_api.py b/backend/tests/test_ap17_operational_api.py new file mode 100644 index 0000000..912a611 --- /dev/null +++ b/backend/tests/test_ap17_operational_api.py @@ -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 diff --git a/backend/version.py b/backend/version.py index 834bf98..6ed8da6 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.18.0-ap2.0" -DB_SCHEMA_VERSION = "023" +APP_VERSION = "0.19.0-ap1.7" +DB_SCHEMA_VERSION = "024" APP_NAME = "jinkendo-kairo" diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md index 0fb2edf..aee8641 100644 --- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md +++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md @@ -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 | | 4 | AP1.16c–d Plan-Outline-Kanten + Planning Debt | **◐ Code** | Remote-Verifikation nach Deploy | | 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 | | 8 | AP1.7 Operational API | offen | MCP-Voraussetzung | | 9 | Gitea-Webhook + MCP | deferred | Schicht 4 | diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 2ef6f99..e493a9d 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -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 | | Referenz-Ausprägung product.kairo_dev | ◐ | Code-Seed; Dogfooding manuell | | 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 | --- @@ -208,7 +209,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1 | AP2.0d | Strategien ◐ Code | | Dogfooding R1 | Referenz-Vorhaben in Alltag ◐ | Seed R2 sync nach Deploy | | AP2.0f | work_cycle ○→◐ | -| AP1.7 | Operational API ✗→◐ | +| AP1.7 | Operational API ◐ Code | ---