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
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:
parent
2359cc8b59
commit
5dc372c043
74
.cursor/rules/kairo-vibe-coder-api.mdc
Normal file
74
.cursor/rules/kairo-vibe-coder-api.mdc
Normal 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
22
backend/migrations/024_actor_service_tokens.sql
Normal file
22
backend/migrations/024_actor_service_tokens.sql
Normal 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);
|
||||
92
backend/operational_auth.py
Normal file
92
backend/operational_auth.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
307
backend/routers/operational.py
Normal file
307
backend/routers/operational.py
Normal 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)
|
||||
|
|
@ -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)")
|
||||
|
|
|
|||
278
backend/services/actor_service_tokens.py
Normal file
278
backend/services/actor_service_tokens.py
Normal 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()
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
124
backend/tests/test_ap17_operational_api.py
Normal file
124
backend/tests/test_ap17_operational_api.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user