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,
|
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")
|
||||||
|
|
|
||||||
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
|
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")
|
||||||
|
|
|
||||||
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)
|
_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)")
|
||||||
|
|
|
||||||
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]
|
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 {
|
||||||
|
|
|
||||||
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"
|
APP_VERSION = "0.19.0-ap1.7"
|
||||||
DB_SCHEMA_VERSION = "023"
|
DB_SCHEMA_VERSION = "024"
|
||||||
APP_NAME = "jinkendo-kairo"
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user