Compare commits

...

6 Commits

Author SHA1 Message Date
Patrick Buckley 087f5b49f6 Bump version to 0.5.4 2026-03-10 20:47:40 -07:00
Patrick Buckley fd507c6a3c feat: generation cancellation — stop button, cancel API, cooperative … (#40)
* feat: generation cancellation — stop button, cancel API, cooperative cancel

Add cooperative cancellation via threading.Event on ChatSession. The cancel
signal is set from outside the worker thread (HTTP handler, MQ bridge, or
Escape key) and checked at defined checkpoints: per streaming chunk, before
tool execution, inside bash commands, and at each sub-agent turn.

Core: GenerationCancelled(BaseException) exception, cancel()/_check_cancelled()
methods, partial content preservation in _stream_response, clean rollback in
send() with idle state emission (no re-raise).

Server: POST /v1/api/cancel endpoint, CancelledEvent SSE emission, worker
thread safety net.

Frontend: Stop button (■ Stop) with send/stop swap via setBusy(), Escape key
shortcut, cancelled event handler. Accessible: aria-label, focus-visible
override, light theme contrast, non-color differentiation.

MQ: CancelMessage inbound type, bridge _handle_cancel routed handler.

SDK: cancel() on Python async+sync clients, CancelledEvent in Python+TypeScript
event registries, isCancelledEvent type guard.

OpenAPI: CancelRequest schema + endpoint spec.

Docs: API reference, architecture, SDK docs updated. Diagrams: conversation
turn, tool pipeline, MQ protocol, workstream states, SDK architecture.

* fix: address PR #40 review feedback

- setBusy() now resets stopBtn.disabled so stop button is re-enabled on
  next generation after a successful cancel
- Gate cancel side effects (resolve_approval, resolve_plan, cancelled SSE
  event) on worker_thread.is_alive() to avoid spurious events when idle
- Add /v1/api/cancel endpoint and CancelRequest schema to TypeScript
  openapi-server.json to keep it in sync with Python-generated spec
2026-03-10 20:43:52 -07:00
Patrick Buckley 562c3c8ab7 docs: add governance section and missing diagrams to README 2026-03-10 19:50:07 -07:00
Patrick Buckley 4773535bb8 docs: add governance architecture diagram PNG 2026-03-10 19:41:35 -07:00
Patrick Buckley 7492816ab2 feat: governance — RBAC, tool policies, prompt templates, usage track… (#39)
* feat: governance — RBAC, tool policies, prompt templates, usage tracking, audit logging

Add comprehensive governance layer for the admin console:

- RBAC with 15 granular permissions, 3 builtin roles (admin, operator, viewer),
  custom role CRUD, user-role assignment with privilege escalation prevention
- Tool policies with glob pattern matching, priority-ordered evaluation
  (allow/deny/ask), enforced before auto-approve in WebUI.approve_tools()
- Prompt templates with variable substitution, categories, default flag
- Usage tracking: per-LLM-request token/tool metrics, aggregated queries
  (group by day/model/user), automatic 90-day pruning via scheduler
- Audit logging: append-only event trail for all admin mutations,
  filterable/paginated queries, automatic 365-day pruning, X-Forwarded-For
  aware IP extraction
- require_permission() enforced on all 35+ admin endpoints (users, tokens,
  channels, schedules, watches, roles, orgs, policies, templates, usage, audit)
- Field allowlists on storage update methods prevent mass-assignment bugs
- Self-deletion guard on admin_delete_user, delete_user cascades user_roles
- _row_to_dict helper eliminates ~400 lines of fragile positional row mapping
- _audit_context helper deduplicates 18 instances of audit boilerplate
- Migration 008: 7 new tables, 3 builtin roles, org_id on users
- Console admin panel: 5 new tabs (Roles, Policies, Templates, Usage, Audit)
  with permission-gated visibility, 7 modal dialogs, full keyboard accessibility
- Python + TypeScript SDK methods for all governance endpoints
- 120+ new tests (1554 total)

* fix: address PR #39 review feedback

- Rebuild serialized items after policy evaluation so denied/allowed
  verdicts are reflected in tool_info/approve_request SSE payloads
- Make `since` query param optional in usage OpenAPI spec (handler
  already defaults to last 7 days)
- Add response_model=StatusResponse to DELETE role/policy/template
  and POST/DELETE role assignment endpoints in OpenAPI spec
- Add missing org_id/created/updated fields to UserRoleInfo schema
- Add missing created field to AuditEventInfo schema
- Show "no permissions" empty state instead of loading inaccessible
  tab when all admin tabs are permission-gated
- Fix "13 permissions" → "15 permissions" in architecture.md and
  security.md
- Fix import sorting in test_audit.py and test_tool_policy.py

* fix: address PR #39 round 2 review feedback

- Clear stale permissions from sessionStorage on config-token login
  (auth.js _storePermissions)
- Only trust X-Forwarded-For when behind a proxy that sets
  X-Forwarded-Proto (conditional on is_secure_request trust model)
- Thread user_id from auth into WebUI.on_status for usage events
- Add created field to TS AuditEventInfo type
- Return typed Pydantic models from all SDK governance methods instead
  of dict[str, Any] — both async and sync clients
- Validate group_by param against allowed enum in admin_usage handler
- Add deterministic secondary sort (event_id DESC) to
  list_audit_events in both SQLite and PostgreSQL backends
2026-03-10 19:32:37 -07:00
Patrick Buckley d6ba1d5e25 fix: mypy no-any-return in agent context overflow handler 2026-03-10 14:11:09 -07:00
63 changed files with 8790 additions and 293 deletions
+20 -1
View File
@@ -17,6 +17,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
- **Queue-driven agents** — trigger workstreams via message queue, stream progress, approve or auto-approve tool use
- **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server
- **Cluster dashboard** — real-time view of all nodes and workstreams, workstream creation with node targeting, reverse proxy for server UIs (only the console port needs network access)
- **Governance & compliance** — role-based access control, tool policies, usage tracking, and append-only audit logs
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
<p align="center">
@@ -127,6 +128,23 @@ Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
| [Deployment](docs/diagrams/png/12-deployment.png) | Docker Compose service topology |
| [SDK Architecture](docs/diagrams/png/13-sdk-architecture.png) | Python + TypeScript client libraries |
| [Storage Architecture](docs/diagrams/png/14-storage-architecture.png) | Pluggable database backends (SQLite + PostgreSQL) |
| [Auth Architecture](docs/diagrams/png/15-auth-architecture.png) | JWT, scopes, token types, login flows |
| [Channel Architecture](docs/diagrams/png/16-channel-architecture.png) | Discord/Slack adapter protocol and routing |
| [Notify Flow](docs/diagrams/png/17-notify-flow.png) | Channel notification dispatch |
| [Watch Architecture](docs/diagrams/png/18-watch-architecture.png) | Periodic command polling daemon |
| [Governance Architecture](docs/diagrams/png/19-governance-architecture.png) | RBAC, policies, audit, usage enforcement flow |
### Governance
Turnstone includes a built-in governance layer for enterprise deployments — manage who can do what, which tools run unattended, and where every token goes.
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
- **Prompt templates** — reusable system messages with `{{variable}}` substitution and categories
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
- **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention
All governance features are managed through the console admin panel (10 tabs) and the full REST API. See [docs/governance.md](docs/governance.md) for setup and configuration.
## Multi-node routing
@@ -151,7 +169,7 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
## Tools
15 built-in tools, 2 agent tools, plus external tools via MCP:
16 built-in tools, 2 agent tools, plus external tools via MCP:
| Tool | Description | Auto-approved |
|------|-------------|:---:|
@@ -168,6 +186,7 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
| `recall` | Search memories and history | yes |
| `forget` | Remove a memory | yes |
| `notify` | Send notifications to linked channels | yes |
| `watch` | Periodic command polling with conditions | |
| `task` | Spawn autonomous sub-agent | |
| `plan` | Explore codebase, write .plan.md | |
| `mcp__*` | External tools from MCP servers | |
+45
View File
@@ -448,6 +448,14 @@ after `/clear` or `/new` commands).
{"type": "clear_ui"}
```
**`cancelled`** -- the generation was cancelled by the user (via the Stop
button or `POST /v1/api/cancel`). The client should finalize any in-progress
assistant message with whatever partial content was streamed.
```json
{"type": "cancelled"}
```
#### Keepalive
The server sends an SSE comment every 5 seconds when no events are pending:
@@ -701,6 +709,43 @@ containing the resumed session's messages.
---
### `POST /v1/api/cancel`
Cancels the active generation in a workstream. Sets a cooperative cancellation
flag that is checked at multiple points in the generation loop (per streaming
chunk, before tool execution, inside bash commands). The session transitions to
`idle` state and preserves any partial content already streamed.
If the workstream is waiting for tool approval or plan review, the pending
prompt is automatically denied/rejected to unblock the worker thread.
Calling this endpoint when the workstream is already idle is a harmless no-op.
**Request body:**
```json
{"ws_id": "abc123"}
```
| Field | Type | Required | Description |
|--------|--------|----------|----------------------|
| `ws_id`| string | yes | Target workstream ID |
**Response:**
```json
{"status": "ok"}
```
**Error responses:**
| Status | Body | Condition |
|--------|------------------------------------|------------------------|
| 400 | `{"error": "No session"}` | Session not initialized|
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found |
---
### `POST /v1/api/workstreams/new`
Creates a new workstream. The server supports up to 10 concurrent workstreams.
+37
View File
@@ -129,6 +129,7 @@ A user message flows through the system as follows:
| on_reasoning_token() / on_content_token()
| accumulate tool_calls from deltas
| track finish_reason
| _check_cancelled() per chunk (cooperative cancel)
v
finish_reason check:
+--- "length" --> warn, discard partial tool_calls
@@ -174,11 +175,13 @@ Phase 2: APPROVE (serial, blocking)
_emit_state("running")
Phase 3: EXECUTE (parallel)
_check_cancelled() <-- cancellation checkpoint before execution starts
if len(items) == 1:
run_one(items[0])
else:
ThreadPoolExecutor(max_workers=4).map(run_one, items)
Bash tool streams stdout line-by-line via ui.on_tool_output_chunk(call_id, line)
(cancel_event also checked per line — kills process group on cancel)
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
call_id links tool_info items → streaming chunks → final result
For plan tool: post-execution gate via ui.on_plan_review()
@@ -209,6 +212,11 @@ The engine emits state changes via `_emit_state()` which calls
"idle" ---> no more tool calls, turn complete
|
(or "error" ---> exception or KeyboardInterrupt)
cancel() may be called from any state. It sets a cooperative flag
checked at each streaming chunk, before tool execution, and inside
bash commands. The session transitions to "idle" with partial
content preserved, emitting on_info("[Generation cancelled]").
```
---
@@ -1173,6 +1181,10 @@ bridge auto-approves via `POST /v1/api/approve`. Otherwise, it publishes an
`BLPOP` of a Redis response queue (`turnstone:resp:{request_id}`) until the client pushes
a response or the approval timeout (default 3600s / 1 hour) expires.
**Cancellation:** The `CancelMessage` (type `"cancel"`) is a routed inbound message.
The bridge dispatches it to `POST /v1/api/cancel` on the server owning the workstream,
which sets the cooperative cancel flag and unblocks any pending approval/plan waits.
**Completion detection:** The bridge tracks which `correlation_id` maps to which
`ws_id` for active sends. When the global SSE reports `ws_state → idle` for a tracked
workstream, the bridge emits a synthetic `TurnCompleteEvent` with the correlation ID.
@@ -1340,3 +1352,28 @@ gateway validates the JWT, resolves the target (username lookup via
the appropriate `ChannelAdapter.send()`. Delivery retries up to 3 times
with backoff, re-querying the service registry on each attempt. See
[Notification Flow diagram](diagrams/png/17-notify-flow.png).
---
## Governance
> See also: [Governance documentation](governance.md) | [Governance Architecture diagram](diagrams/19-governance-architecture.puml)
Turnstone governance extends the Phase 1 auth system with role-based access
control (RBAC), tool execution policies, prompt templates, usage tracking,
and audit logging. The permission model has two layers: legacy scopes
(`read`, `write`, `approve`) checked by `AuthMiddleware`, and 15 granular
permissions checked per-endpoint by `require_permission()`. Three built-in
roles (admin, operator, viewer) are seeded by migration 008; custom roles
can be created with any permission subset. JWTs carry both `scopes` and
`permissions` claims for backward compatibility.
Tool policies use glob pattern matching (`fnmatch`) with priority-ordered
first-match-wins evaluation to control tool execution (allow/deny/ask).
Prompt templates provide reusable system messages with `{{variable}}`
substitution. Usage events are recorded per-LLM-request for token
accounting. An append-only audit log captures all admin mutations.
The console admin panel adds 5 governance tabs (Roles, Policies, Templates,
Usage, Audit) for a total of 10 tabs, all permission-gated. Both Python
and TypeScript SDKs expose governance methods on the console client.
+14
View File
@@ -57,6 +57,14 @@ group loop [while tool_calls present]
end
end
note right of CS
**Cancellation checkpoint:**
_check_cancelled() runs per chunk.
If cancel_event is set, raises
GenerationCancelled — preserves
partial content, emits idle state.
end note
LLM --> CS : stream complete (usage stats)
deactivate LLM
@@ -144,6 +152,12 @@ group loop [while tool_calls present]
end
note right of CS : Loop back for next LLM call
else GenerationCancelled
CS -> CS : Preserve partial content\nor roll back incomplete tools
CS -> UI : on_info("[Generation cancelled]")
CS -> UI : on_state_change("idle")
CS --> User : return (no re-raise)
end
end
+2
View File
@@ -88,6 +88,8 @@ partition "Phase 2: Approve" #FFF3E0 {
}
partition "Phase 3: Execute" #E3F2FD {
:_check_cancelled();
note right: Cancellation checkpoint:\nraises GenerationCancelled if\ncancel event is set
if (single tool call?) then (yes)
:Execute sequentially:\nrun_one(items[0]);
else (multiple)
+7
View File
@@ -79,6 +79,12 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
type = "list_nodes"
}
class CancelMessage {
type = "cancel"
--
+ ws_id: str
}
IM <|-- SendMessage
IM <|-- ApproveMessage
IM <|-- PlanFeedbackMessage
@@ -88,6 +94,7 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
IM <|-- ListWorkstreamsMessage
IM <|-- HealthMessage
IM <|-- ListNodesMessage
IM <|-- CancelMessage
}
package "Outbound Events (Bridge → Client)" #E3F2FD {
+6
View File
@@ -40,6 +40,12 @@ running --> error : Exception during\ntool execution
error --> thinking : New send() call\n_emit_state("thinking")
thinking --> idle : cancel() called\n_emit_state("idle")
running --> idle : cancel() called\n_emit_state("idle")
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
note right of thinking
**Emitted via:**
session._emit_state(state)
+1
View File
@@ -32,6 +32,7 @@ package "turnstone/sdk/ (Python)" {
+ approve()
+ plan_feedback()
+ command()
+ cancel(ws_id)
+ stream_events(ws_id)
+ stream_global_events()
+ send_and_wait()
@@ -0,0 +1,69 @@
@startuml
!theme plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "IBM Plex Mono"
skinparam componentStyle rectangle
title Turnstone Governance Architecture
package "Auth Flow" {
[Login/Token Auth] as auth
[_load_user_permissions()] as perms
[_permissions_to_scopes()] as scopes
[create_jwt()] as jwt
}
package "Middleware" {
[AuthMiddleware\n(scope check)] as mw
[require_permission()\n(granular check)] as rp
}
package "Governance Storage" {
database "roles" as roles_db
database "user_roles" as ur_db
database "orgs" as orgs_db
database "tool_policies" as tp_db
database "prompt_templates" as pt_db
database "usage_events" as ue_db
database "audit_events" as ae_db
}
package "Runtime Enforcement" {
[evaluate_tool_policies_batch()] as eval
[WebUI.approve_tools()] as approve
[record_usage_event()] as usage
[record_audit()] as audit
}
package "Console UI" {
[Admin Panel\n10 tabs] as ui
[governance.js] as govjs
[sessionStorage\npermissions] as ss
}
auth --> perms : user_id
perms --> roles_db : JOIN user_roles + roles
perms --> scopes : permission set
scopes --> jwt : scopes + permissions
jwt --> mw : JWT in cookie/header
mw --> rp : scope OK → check permission
rp --> ui : 403 or allow
eval --> tp_db : list_tool_policies()
approve --> eval : tool names
approve --> ae_db : (via audit)
usage --> ue_db : on_status()
audit --> ae_db : admin handlers
govjs --> roles_db : /v1/api/admin/roles
govjs --> tp_db : /v1/api/admin/policies
govjs --> pt_db : /v1/api/admin/templates
govjs --> ue_db : /v1/api/admin/usage
govjs --> ae_db : /v1/api/admin/audit
auth -[hidden]-> mw
mw -[hidden]-> approve
@enduml
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e3044c738d6d6853aab5c4990e6c67bab0165eba991a4f5bebdfc4d4a0b305ee
size 289165
oid sha256:da9d32000e3d92d92ce621661ced60f276f9b5be652f5ed6123b400505415f4a
size 319702
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:282820fe416961e735d050f86ecdc079e29824d2b3c4d5c8c174d0533d41f211
size 258045
oid sha256:a3ffd93ccb634f76560f1dd65242b89cd34443b37e355f25f29a1c63a22001be
size 265259
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:32a0665cceffcc0517265bde12cfb227688aa8585284b5e946ab23bcc52daee6
size 187650
oid sha256:d17f3feacf7bc9f64dfea19464143bc9b6ef0da5d55e6d57c0bc5a73d5724eba
size 184466
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e0a3f48cca1b8408862dc4ba04fd340703346f44d84048c99e9900f48e9c7e22
size 158867
oid sha256:7896c6e041b6dbb89d034468fa980c8fe645df5eb969d45ef966ccc6399edac2
size 200083
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:435a58aa09d0e6615e78c0be62e5fd9aa6d7329b1e96619744355c42ade649c9
size 196502
oid sha256:e7c3e40c10425d721f833390ae3531c09af501157fd3142531ba4eba86ff719d
size 197112
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b68663599922f72d7ca21be820523a5b472c268897d194e5237bba2441c004ec
size 124497
+158
View File
@@ -0,0 +1,158 @@
# Governance
Turnstone governance provides role-based access control (RBAC), tool execution
policies, prompt templates, usage tracking, and audit logging for the admin
console.
## Architecture
See [diagram: 19-governance-architecture.puml](diagrams/19-governance-architecture.puml).
### RBAC (Roles & Permissions)
The permission model has two layers:
1. **Scopes** (legacy) — `read`, `write`, `approve`. Checked by `AuthMiddleware`
on every request based on URL path classification.
2. **Permissions** (granular) — 15 permission strings checked per-endpoint by
`require_permission()`.
**Built-in roles** (seeded by migration 008):
| Role | Permissions |
|------|-------------|
| admin | read, write, approve, admin.users, admin.roles, admin.orgs, admin.policies, admin.templates, admin.audit, admin.usage, admin.schedules, admin.watches, tools.approve, workstreams.create, workstreams.close |
| operator | read, write, workstreams.create, workstreams.close |
| viewer | read |
Custom roles can be created with any subset of the 15 valid permissions.
**Auth flow:**
1. User logs in (password or API token) → `_load_user_permissions()` aggregates
permissions from all assigned roles
2. `_permissions_to_scopes()` derives legacy scopes (any `admin.*``approve`)
3. JWT created with both `scopes` and `permissions` claims
4. Middleware checks scope → handler checks permission via `require_permission()`
### Tool Policies
Admin-defined rules that control tool execution:
- **Pattern matching**: Glob syntax via `fnmatch` (e.g., `bash*`, `file_write`, `*`)
- **Actions**: `allow` (auto-approve), `deny` (block), `ask` (normal approval flow)
- **Priority**: Higher priority evaluated first, first match wins
- **Enforcement**: `evaluate_tool_policies_batch()` called in `WebUI.approve_tools()`
before the `auto_approve` check
### Prompt Templates
Reusable system message templates with variable substitution:
- **Variables**: `{{variable_name}}` placeholders in content
- **Categories**: general, engineering, support, custom
- **Default flag**: `is_default=true` templates intended for new workstreams
- **Storage**: `prompt_templates` table with JSON `variables` array
### Usage Tracking
Per-LLM-request token and tool call metrics:
- **Recording**: `on_status()` in `WebUI` records a `usage_event` after each
LLM response with prompt/completion tokens, tool call count, model, ws_id
- **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user)
and time range filtering
- **Pruning**: `prune_usage_events(retention_days=90)` and
`prune_audit_events(retention_days=365)` run automatically via the
console scheduler's periodic cleanup cycle
### Audit Logging
Append-only trail of admin actions:
- **Recording**: `record_audit()` helper called from all admin mutation handlers
- **Events captured**: user.create, user.delete, token.create, token.revoke,
channel.link, channel.unlink, role.create, role.update, role.delete,
role.assign, role.unassign, policy.create, policy.update, policy.delete,
template.create, template.update, template.delete, org.update
- **Querying**: `GET /v1/api/admin/audit` with action/user/time filters + pagination
## Database Schema
Migration 008 adds 7 tables:
| Table | Purpose |
|-------|---------|
| `orgs` | Organizations (single default org for now) |
| `roles` | Named permission bundles (3 builtin + custom) |
| `user_roles` | User-to-role assignments (composite PK) |
| `tool_policies` | Per-tool approve/deny/ask rules |
| `prompt_templates` | Reusable system message templates |
| `usage_events` | Per-request token/tool metrics |
| `audit_events` | Admin action log |
Also adds `org_id` column to `users` table.
## API Endpoints
All under `/v1/api/admin/` (requires `approve` scope + granular permission).
| Group | Endpoints | Permission |
|-------|-----------|------------|
| Users / Tokens / Channels | 9 (CRUD) | `admin.users` |
| Roles | 7 (CRUD + assignment) | `admin.roles` / `admin.users` |
| Orgs | 3 (list, get, update) | `admin.orgs` |
| Tool Policies | 4 (CRUD) | `admin.policies` |
| Prompt Templates | 4 (CRUD) | `admin.templates` |
| Schedules | 6 (CRUD + runs) | `admin.schedules` |
| Watches | 3 (list, create, cancel) | `admin.watches` |
| Usage | 1 (aggregated query) | `admin.usage` |
| Audit | 1 (paginated, filtered) | `admin.audit` |
Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`.
## Admin Console UI
5 new tabs added to the admin panel (10 total):
- **Roles** — CRUD roles, permission checkbox grid, user role assignment modal
- **Policies** — CRUD tool policies with colored action badges (green/red/amber)
- **Templates** — CRUD prompt templates with wide modal, textarea editor
- **Usage** — Summary readouts + CSS bar chart, time range + group-by selectors
- **Audit** — Filterable log with relative timestamps, load-more pagination
Tabs are permission-gated: hidden if the user lacks the required permission.
## SDK
Both Python and TypeScript console SDKs expose governance methods:
**Python** (`TurnstoneConsole` / `AsyncTurnstoneConsole`):
- `list_roles()`, `create_role()`, `update_role()`, `delete_role()`
- `list_user_roles()`, `assign_role()`, `unassign_role()`
- `list_orgs()`, `get_org()`, `update_org()`
- `list_policies()`, `create_policy()`, `update_policy()`, `delete_policy()`
- `list_templates()`, `create_template()`, `update_template()`, `delete_template()`
- `get_usage(since, group_by=...)`, `get_audit(action=..., limit=...)`
**TypeScript** (`TurnstoneConsole`):
- Same methods with camelCase naming and typed interfaces
## Security Considerations
- **Privilege escalation prevented**: `admin_assign_role` blocks self-assignment
and requires caller to hold a superset of the target role's permissions
- **Permission validation**: Role create/update validates permissions against
a 15-item allowlist (`_VALID_PERMISSIONS`)
- **Self-deletion blocked**: `admin_delete_user` rejects attempts to delete
your own account (matching the self-assignment guard on role endpoints)
- **Field allowlists**: Storage `update_*` methods filter fields against
allowlists (`_ROLE_MUTABLE`, `_POLICY_MUTABLE`, etc.) — handler bugs
cannot overwrite `role_id`, `builtin`, `created`, or other protected columns
- **Bootstrap safety**: `handle_auth_setup` fails and rolls back if admin role
assignment fails, preventing locked-out first user
- **API token RBAC**: `_authenticate_api_token` loads permissions from user's
roles, ensuring API tokens are subject to RBAC enforcement
- **Policy evaluation is fail-open**: If storage is unavailable, tool policies
degrade to the existing approval flow (not auto-approve)
- **Audit IP resolution**: `_audit_context()` prefers `X-Forwarded-For` for
client IP when behind a reverse proxy, falling back to `request.client.host`
+2
View File
@@ -75,6 +75,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
| | `command(*, ws_id, command)` | `StatusResponse` |
| | `cancel(ws_id)` | `StatusResponse` |
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
| | `stream_global_events()` | `Iterator[ServerEvent]` |
| **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` |
@@ -129,6 +130,7 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
| `error` | `ErrorEvent` | `message` |
| `info` | `InfoEvent` | `message` |
| `stream_end` | `StreamEndEvent` | — |
| `cancelled` | `CancelledEvent` | — |
**Global events** (from `stream_global_events()`):
+28
View File
@@ -92,6 +92,34 @@ Public paths bypass authentication entirely: `/`, `/health`, `/metrics`,
`/static/*`, `/shared/*`, `/docs`, `/openapi.json`, `/api/auth/login`,
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`.
### RBAC (Granular Permissions)
> See also: [Governance documentation](governance.md)
Scopes provide coarse endpoint-level access control. For finer-grained
enforcement, the governance layer adds 15 named permissions checked
per-endpoint by `require_permission()`. Permissions are bundled into
roles; users are assigned roles via the `user_roles` join table.
At login, `_load_user_permissions()` aggregates all permissions from
the user's assigned roles. `_permissions_to_scopes()` derives legacy
scopes for backward compatibility (e.g., any `admin.*` permission
implies the `approve` scope). The JWT carries both `scopes` and
`permissions` claims.
Three built-in roles are seeded by migration 008:
| Role | Permissions |
|------|-------------|
| admin | All 15 permissions |
| operator | read, write, workstreams.create, workstreams.close |
| viewer | read |
Custom roles can be created with any subset of the valid permissions.
Role creation and update validate permissions against a static allowlist.
Self-assignment is blocked, and assigning a role requires the caller to
hold a superset of the target role's permissions.
---
## Login Flows
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.5.3"
version = "0.5.4"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+104 -154
View File
@@ -10,9 +10,7 @@
"get": {
"summary": "List active workstreams",
"operationId": "v1_api_workstreams_get",
"tags": [
"Workstreams"
],
"tags": ["Workstreams"],
"responses": {
"200": {
"description": "Success",
@@ -31,9 +29,7 @@
"get": {
"summary": "Dashboard with workstream details and aggregates",
"operationId": "v1_api_dashboard_get",
"tags": [
"Workstreams"
],
"tags": ["Workstreams"],
"responses": {
"200": {
"description": "Success",
@@ -52,9 +48,7 @@
"post": {
"summary": "Create a new workstream",
"operationId": "v1_api_workstreams_new_post",
"tags": [
"Workstreams"
],
"tags": ["Workstreams"],
"requestBody": {
"required": true,
"content": {
@@ -93,9 +87,7 @@
"post": {
"summary": "Close a workstream",
"operationId": "v1_api_workstreams_close_post",
"tags": [
"Workstreams"
],
"tags": ["Workstreams"],
"requestBody": {
"required": true,
"content": {
@@ -134,9 +126,7 @@
"post": {
"summary": "Send a user message",
"operationId": "v1_api_send_post",
"tags": [
"Chat"
],
"tags": ["Chat"],
"requestBody": {
"required": true,
"content": {
@@ -185,9 +175,7 @@
"post": {
"summary": "Approve or deny a tool call",
"operationId": "v1_api_approve_post",
"tags": [
"Chat"
],
"tags": ["Chat"],
"requestBody": {
"required": true,
"content": {
@@ -226,9 +214,7 @@
"post": {
"summary": "Respond to a plan review",
"operationId": "v1_api_plan_post",
"tags": [
"Chat"
],
"tags": ["Chat"],
"requestBody": {
"required": true,
"content": {
@@ -267,9 +253,7 @@
"post": {
"summary": "Execute a slash command",
"operationId": "v1_api_command_post",
"tags": [
"Chat"
],
"tags": ["Chat"],
"requestBody": {
"required": true,
"content": {
@@ -314,13 +298,60 @@
}
}
},
"/v1/api/cancel": {
"post": {
"summary": "Cancel the active generation in a workstream",
"operationId": "v1_api_cancel_post",
"tags": ["Chat"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CancelRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusResponse"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/events": {
"get": {
"summary": "Per-workstream SSE event stream",
"operationId": "v1_api_events_get",
"tags": [
"Streaming"
],
"tags": ["Streaming"],
"description": "Opens a Server-Sent Events stream scoped to a single workstream. Returns text/event-stream. See API reference for event types.",
"parameters": [
{
@@ -354,9 +385,7 @@
"get": {
"summary": "Global SSE event stream",
"operationId": "v1_api_events_global_get",
"tags": [
"Streaming"
],
"tags": ["Streaming"],
"description": "Global Server-Sent Events stream for state-change broadcasts across all workstreams. Returns text/event-stream.",
"responses": {
"200": {
@@ -369,9 +398,7 @@
"get": {
"summary": "List saved workstreams",
"operationId": "v1_api_workstreams_saved_get",
"tags": [
"Workstreams"
],
"tags": ["Workstreams"],
"responses": {
"200": {
"description": "Success",
@@ -390,9 +417,7 @@
"post": {
"summary": "Authenticate with a token",
"operationId": "v1_api_auth_login_post",
"tags": [
"Auth"
],
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": {
@@ -431,9 +456,7 @@
"post": {
"summary": "Create first admin user",
"operationId": "v1_api_auth_setup_post",
"tags": [
"Auth"
],
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": {
@@ -492,9 +515,7 @@
"get": {
"summary": "Return auth state",
"operationId": "v1_api_auth_status_get",
"tags": [
"Auth"
],
"tags": ["Auth"],
"responses": {
"200": {
"description": "Success",
@@ -513,9 +534,7 @@
"post": {
"summary": "Clear auth cookie",
"operationId": "v1_api_auth_logout_post",
"tags": [
"Auth"
],
"tags": ["Auth"],
"responses": {
"200": {
"description": "Success",
@@ -534,9 +553,7 @@
"get": {
"summary": "Server health check",
"operationId": "health_get",
"tags": [
"Observability"
],
"tags": ["Observability"],
"responses": {
"200": {
"description": "Success",
@@ -563,9 +580,7 @@
"type": "string"
}
},
"required": [
"error"
],
"required": ["error"],
"title": "ErrorResponse",
"type": "object"
},
@@ -574,9 +589,7 @@
"properties": {
"status": {
"default": "ok",
"examples": [
"ok"
],
"examples": ["ok"],
"title": "Status",
"type": "string"
}
@@ -625,19 +638,14 @@
},
"role": {
"description": "Legacy role",
"examples": [
"full",
"read"
],
"examples": ["full", "read"],
"title": "Role",
"type": "string"
},
"scopes": {
"default": "",
"description": "Comma-separated scopes",
"examples": [
"read,write,approve"
],
"examples": ["read,write,approve"],
"title": "Scopes",
"type": "string"
},
@@ -648,9 +656,7 @@
"type": "string"
}
},
"required": [
"role"
],
"required": ["role"],
"title": "AuthLoginResponse",
"type": "object"
},
@@ -673,11 +679,7 @@
"type": "string"
}
},
"required": [
"username",
"display_name",
"password"
],
"required": ["username", "display_name", "password"],
"title": "AuthSetupRequest",
"type": "object"
},
@@ -714,10 +716,7 @@
"type": "string"
}
},
"required": [
"user_id",
"username"
],
"required": ["user_id", "username"],
"title": "AuthSetupResponse",
"type": "object"
},
@@ -737,11 +736,7 @@
"type": "boolean"
}
},
"required": [
"auth_enabled",
"has_users",
"setup_required"
],
"required": ["auth_enabled", "has_users", "setup_required"],
"title": "AuthStatusResponse",
"type": "object"
},
@@ -758,10 +753,7 @@
"type": "string"
}
},
"required": [
"message",
"ws_id"
],
"required": ["message", "ws_id"],
"title": "SendRequest",
"type": "object"
},
@@ -769,17 +761,12 @@
"properties": {
"status": {
"description": "'ok' or 'busy'",
"examples": [
"ok",
"busy"
],
"examples": ["ok", "busy"],
"title": "Status",
"type": "string"
}
},
"required": [
"status"
],
"required": ["status"],
"title": "SendResponse",
"type": "object"
},
@@ -815,10 +802,7 @@
"type": "string"
}
},
"required": [
"approved",
"ws_id"
],
"required": ["approved", "ws_id"],
"title": "ApproveRequest",
"type": "object"
},
@@ -835,10 +819,7 @@
"type": "string"
}
},
"required": [
"feedback",
"ws_id"
],
"required": ["feedback", "ws_id"],
"title": "PlanFeedbackRequest",
"type": "object"
},
@@ -855,13 +836,22 @@
"type": "string"
}
},
"required": [
"command",
"ws_id"
],
"required": ["command", "ws_id"],
"title": "CommandRequest",
"type": "object"
},
"CancelRequest": {
"properties": {
"ws_id": {
"description": "Target workstream ID",
"title": "Ws Id",
"type": "string"
}
},
"required": ["ws_id"],
"title": "CancelRequest",
"type": "object"
},
"CreateWorkstreamRequest": {
"properties": {
"name": {
@@ -917,10 +907,7 @@
"type": "integer"
}
},
"required": [
"ws_id",
"name"
],
"required": ["ws_id", "name"],
"title": "CreateWorkstreamResponse",
"type": "object"
},
@@ -932,9 +919,7 @@
"type": "string"
}
},
"required": [
"ws_id"
],
"required": ["ws_id"],
"title": "CloseWorkstreamRequest",
"type": "object"
},
@@ -948,9 +933,7 @@
"type": "array"
}
},
"required": [
"workstreams"
],
"required": ["workstreams"],
"title": "ListWorkstreamsResponse",
"type": "object"
},
@@ -969,11 +952,7 @@
"type": "string"
}
},
"required": [
"id",
"name",
"state"
],
"required": ["id", "name", "state"],
"title": "WorkstreamInfo",
"type": "object"
},
@@ -990,10 +969,7 @@
"$ref": "#/components/schemas/DashboardAggregate"
}
},
"required": [
"workstreams",
"aggregate"
],
"required": ["workstreams", "aggregate"],
"title": "DashboardResponse",
"type": "object"
},
@@ -1093,11 +1069,7 @@
"type": "string"
}
},
"required": [
"id",
"name",
"state"
],
"required": ["id", "name", "state"],
"title": "DashboardWorkstream",
"type": "object"
},
@@ -1111,9 +1083,7 @@
"type": "array"
}
},
"required": [
"workstreams"
],
"required": ["workstreams"],
"title": "ListSavedWorkstreamsResponse",
"type": "object"
},
@@ -1160,22 +1130,14 @@
"type": "integer"
}
},
"required": [
"ws_id",
"created",
"updated",
"message_count"
],
"required": ["ws_id", "created", "updated", "message_count"],
"title": "SavedWorkstreamInfo",
"type": "object"
},
"HealthResponse": {
"properties": {
"status": {
"examples": [
"ok",
"degraded"
],
"examples": ["ok", "degraded"],
"title": "Status",
"type": "string"
},
@@ -1217,36 +1179,24 @@
"default": null
}
},
"required": [
"status"
],
"required": ["status"],
"title": "HealthResponse",
"type": "object"
},
"BackendStatus": {
"properties": {
"status": {
"examples": [
"up",
"down"
],
"examples": ["up", "down"],
"title": "Status",
"type": "string"
},
"circuit_state": {
"examples": [
"closed",
"open",
"half_open"
],
"examples": ["closed", "open", "half_open"],
"title": "Circuit State",
"type": "string"
}
},
"required": [
"status",
"circuit_state"
],
"required": ["status", "circuit_state"],
"title": "BackendStatus",
"type": "object"
},
+137
View File
@@ -1,6 +1,8 @@
import { BaseClient, type ClientOptions } from "./base.js";
import type { ClusterEvent } from "./events.js";
import type {
AuditQueryOptions,
AuditResponse,
AuthLoginResponse,
AuthSetupResponse,
AuthStatusResponse,
@@ -11,14 +13,28 @@ import type {
ConsoleCreateWsRequest,
ConsoleCreateWsResponse,
ConsoleHealthResponse,
CreatePolicyOptions,
CreateRoleOptions,
CreateScheduleRequest,
CreateTemplateOptions,
ListScheduleRunsResponse,
ListSchedulesResponse,
NodeDetailResponse,
NodesOptions,
OrgInfo,
PromptTemplateInfo,
RoleInfo,
ScheduleInfo,
StatusResponse,
ToolPolicyInfo,
UpdateOrgOptions,
UpdatePolicyOptions,
UpdateRoleOptions,
UpdateScheduleRequest,
UpdateTemplateOptions,
UsageQueryOptions,
UsageResponse,
UserRoleInfo,
WorkstreamsOptions,
} from "./types.js";
@@ -157,4 +173,125 @@ export class TurnstoneConsole extends BaseClient {
params: { limit: opts?.limit ?? 50 },
});
}
// -- Governance: Roles ------------------------------------------------------
async listRoles(): Promise<{ roles: RoleInfo[] }> {
return this.request("GET", "/v1/api/admin/roles");
}
async createRole(opts: CreateRoleOptions): Promise<RoleInfo> {
return this.request("POST", "/v1/api/admin/roles", { json: opts });
}
async updateRole(roleId: string, opts: UpdateRoleOptions): Promise<RoleInfo> {
return this.request("PUT", `/v1/api/admin/roles/${roleId}`, {
json: opts,
});
}
async deleteRole(roleId: string): Promise<StatusResponse> {
return this.request("DELETE", `/v1/api/admin/roles/${roleId}`);
}
async listUserRoles(userId: string): Promise<{ roles: UserRoleInfo[] }> {
return this.request("GET", `/v1/api/admin/users/${userId}/roles`);
}
async assignRole(userId: string, roleId: string): Promise<StatusResponse> {
return this.request("POST", `/v1/api/admin/users/${userId}/roles`, {
json: { role_id: roleId },
});
}
async unassignRole(userId: string, roleId: string): Promise<StatusResponse> {
return this.request(
"DELETE",
`/v1/api/admin/users/${userId}/roles/${roleId}`,
);
}
// -- Governance: Organizations ----------------------------------------------
async listOrgs(): Promise<{ orgs: OrgInfo[] }> {
return this.request("GET", "/v1/api/admin/orgs");
}
async getOrg(orgId: string): Promise<OrgInfo> {
return this.request("GET", `/v1/api/admin/orgs/${orgId}`);
}
async updateOrg(orgId: string, opts: UpdateOrgOptions): Promise<OrgInfo> {
return this.request("PUT", `/v1/api/admin/orgs/${orgId}`, { json: opts });
}
// -- Governance: Tool Policies ----------------------------------------------
async listPolicies(): Promise<{ policies: ToolPolicyInfo[] }> {
return this.request("GET", "/v1/api/admin/policies");
}
async createPolicy(opts: CreatePolicyOptions): Promise<ToolPolicyInfo> {
return this.request("POST", "/v1/api/admin/policies", { json: opts });
}
async updatePolicy(
policyId: string,
opts: UpdatePolicyOptions,
): Promise<ToolPolicyInfo> {
return this.request("PUT", `/v1/api/admin/policies/${policyId}`, {
json: opts,
});
}
async deletePolicy(policyId: string): Promise<StatusResponse> {
return this.request("DELETE", `/v1/api/admin/policies/${policyId}`);
}
// -- Governance: Prompt Templates -------------------------------------------
async listTemplates(): Promise<{ templates: PromptTemplateInfo[] }> {
return this.request("GET", "/v1/api/admin/templates");
}
async createTemplate(
opts: CreateTemplateOptions,
): Promise<PromptTemplateInfo> {
return this.request("POST", "/v1/api/admin/templates", { json: opts });
}
async updateTemplate(
templateId: string,
opts: UpdateTemplateOptions,
): Promise<PromptTemplateInfo> {
return this.request("PUT", `/v1/api/admin/templates/${templateId}`, {
json: opts,
});
}
async deleteTemplate(templateId: string): Promise<StatusResponse> {
return this.request("DELETE", `/v1/api/admin/templates/${templateId}`);
}
// -- Governance: Usage & Audit ----------------------------------------------
async getUsage(opts: UsageQueryOptions): Promise<UsageResponse> {
const params: Record<string, string> = { since: opts.since };
if (opts.until) params.until = opts.until;
if (opts.user_id) params.user_id = opts.user_id;
if (opts.model) params.model = opts.model;
if (opts.group_by) params.group_by = opts.group_by;
return this.request("GET", "/v1/api/admin/usage", { params });
}
async getAudit(opts?: AuditQueryOptions): Promise<AuditResponse> {
const params: Record<string, string> = {};
if (opts?.action) params.action = opts.action;
if (opts?.user_id) params.user_id = opts.user_id;
if (opts?.since) params.since = opts.since;
if (opts?.until) params.until = opts.until;
if (opts?.limit !== undefined) params.limit = String(opts.limit);
if (opts?.offset !== undefined) params.offset = String(opts.offset);
return this.request("GET", "/v1/api/admin/audit", { params });
}
}
+9
View File
@@ -95,6 +95,10 @@ export interface ClearUiEvent {
type: "clear_ui";
}
export interface CancelledEvent {
type: "cancelled";
}
// Global events
export interface WsStateEvent {
@@ -145,6 +149,7 @@ export type ServerEvent =
| ErrorEvent
| BusyErrorEvent
| ClearUiEvent
| CancelledEvent
| WsStateEvent
| WsActivityEvent
| WsRenameEvent
@@ -247,3 +252,7 @@ export function isApproveRequestEvent(
export function isPlanReviewEvent(e: ServerEvent): e is PlanReviewEvent {
return e.type === "plan_review";
}
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
return e.type === "cancelled";
}
+20
View File
@@ -45,6 +45,7 @@ export type {
ErrorEvent,
BusyErrorEvent,
ClearUiEvent,
CancelledEvent,
WsStateEvent,
WsActivityEvent,
WsRenameEvent,
@@ -67,6 +68,7 @@ export {
isWsStateEvent,
isApproveRequestEvent,
isPlanReviewEvent,
isCancelledEvent,
} from "./events.js";
// Request/response types
@@ -112,6 +114,24 @@ export type {
ScheduleRunInfo,
ListSchedulesResponse,
ListScheduleRunsResponse,
RoleInfo,
CreateRoleOptions,
UpdateRoleOptions,
UserRoleInfo,
OrgInfo,
UpdateOrgOptions,
ToolPolicyInfo,
CreatePolicyOptions,
UpdatePolicyOptions,
PromptTemplateInfo,
CreateTemplateOptions,
UpdateTemplateOptions,
UsageBreakdownItem,
UsageResponse,
UsageQueryOptions,
AuditEventInfo,
AuditQueryOptions,
AuditResponse,
TurnResult,
SendAndWaitOptions,
NodesOptions,
+6
View File
@@ -86,6 +86,12 @@ export class TurnstoneServer extends BaseClient {
});
}
async cancel(wsId: string): Promise<StatusResponse> {
return this.request("POST", "/v1/api/cancel", {
json: { ws_id: wsId },
});
}
// -- Streaming ------------------------------------------------------------
async *streamEvents(wsId: string): AsyncIterableIterator<ServerEvent> {
+167
View File
@@ -354,6 +354,173 @@ export interface ListScheduleRunsResponse {
runs: ScheduleRunInfo[];
}
// ---------------------------------------------------------------------------
// Console API — Governance: Roles
// ---------------------------------------------------------------------------
export interface RoleInfo {
role_id: string;
name: string;
display_name: string;
permissions: string;
builtin: boolean;
org_id: string;
created: string;
updated: string;
}
export interface CreateRoleOptions {
name: string;
display_name?: string;
permissions?: string;
}
export interface UpdateRoleOptions {
display_name?: string;
permissions?: string;
}
export interface UserRoleInfo extends RoleInfo {
assigned_by: string;
assignment_created: string;
}
// ---------------------------------------------------------------------------
// Console API — Governance: Orgs
// ---------------------------------------------------------------------------
export interface OrgInfo {
org_id: string;
name: string;
display_name: string;
settings: string;
created: string;
updated: string;
}
export interface UpdateOrgOptions {
display_name?: string;
settings?: string;
}
// ---------------------------------------------------------------------------
// Console API — Governance: Tool Policies
// ---------------------------------------------------------------------------
export interface ToolPolicyInfo {
policy_id: string;
name: string;
tool_pattern: string;
action: string;
priority: number;
org_id: string;
enabled: boolean;
created_by: string;
created: string;
updated: string;
}
export interface CreatePolicyOptions {
name: string;
tool_pattern: string;
action: string;
priority?: number;
org_id?: string;
enabled?: boolean;
}
export interface UpdatePolicyOptions {
name?: string;
tool_pattern?: string;
action?: string;
priority?: number;
enabled?: boolean;
}
// ---------------------------------------------------------------------------
// Console API — Governance: Prompt Templates
// ---------------------------------------------------------------------------
export interface PromptTemplateInfo {
template_id: string;
name: string;
category: string;
content: string;
variables: string;
is_default: boolean;
org_id: string;
created_by: string;
created: string;
updated: string;
}
export interface CreateTemplateOptions {
name: string;
content: string;
category?: string;
variables?: string;
is_default?: boolean;
org_id?: string;
}
export interface UpdateTemplateOptions {
name?: string;
content?: string;
category?: string;
variables?: string;
is_default?: boolean;
}
// ---------------------------------------------------------------------------
// Console API — Governance: Usage & Audit
// ---------------------------------------------------------------------------
export interface UsageBreakdownItem {
key?: string;
prompt_tokens: number;
completion_tokens: number;
tool_calls_count: number;
}
export interface UsageResponse {
summary: UsageBreakdownItem[];
breakdown: UsageBreakdownItem[];
}
export interface UsageQueryOptions {
since: string;
until?: string;
user_id?: string;
model?: string;
group_by?: string;
}
export interface AuditEventInfo {
event_id: string;
timestamp: string;
user_id: string;
action: string;
resource_type: string;
resource_id: string;
detail: string;
ip_address: string;
created: string;
}
export interface AuditQueryOptions {
action?: string;
user_id?: string;
since?: string;
until?: string;
limit?: number;
offset?: number;
}
export interface AuditResponse {
events: AuditEventInfo[];
total: number;
}
// ---------------------------------------------------------------------------
// SDK-specific types
// ---------------------------------------------------------------------------
+58
View File
@@ -0,0 +1,58 @@
"""Tests for turnstone.core.audit."""
import json
import pytest
from turnstone.core.audit import record_audit
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def storage(tmp_path):
path = str(tmp_path / "test.db")
backend = SQLiteBackend(path)
yield backend
backend.close()
def test_record_audit_basic(storage):
record_audit(
storage, "user-1", "user.create", "user", "u123", {"username": "alice"}, "127.0.0.1"
)
events = storage.list_audit_events()
assert len(events) == 1
ev = events[0]
assert ev["user_id"] == "user-1"
assert ev["action"] == "user.create"
assert ev["resource_type"] == "user"
assert ev["resource_id"] == "u123"
assert ev["ip_address"] == "127.0.0.1"
detail = json.loads(ev["detail"])
assert detail["username"] == "alice"
def test_record_audit_no_detail(storage):
record_audit(storage, "user-1", "token.revoke", "token", "t456")
events = storage.list_audit_events()
assert len(events) == 1
assert events[0]["detail"] == "{}"
def test_record_audit_silent_on_failure():
"""record_audit should not raise even if storage is broken."""
class BrokenStorage:
def record_audit_event(self, **kw):
raise RuntimeError("boom")
# Should not raise
record_audit(BrokenStorage(), "u1", "test.action")
def test_record_audit_generates_unique_ids(storage):
record_audit(storage, "u1", "a.one")
record_audit(storage, "u1", "a.two")
events = storage.list_audit_events()
assert len(events) == 2
assert events[0]["event_id"] != events[1]["event_id"]
+337
View File
@@ -0,0 +1,337 @@
"""Tests for generation cancellation (cooperative cancel via threading.Event)."""
import threading
import time
from dataclasses import dataclass, field
from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.session import ChatSession, GenerationCancelled
class NullUI:
"""UI adapter that records state changes and discards other output."""
def __init__(self):
self.states = []
self.infos = []
self.stream_ends = 0
def on_thinking_start(self):
pass
def on_thinking_stop(self):
pass
def on_reasoning_token(self, text):
pass
def on_content_token(self, text):
pass
def on_stream_end(self):
self.stream_ends += 1
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
pass
def on_tool_output_chunk(self, call_id, chunk):
pass
def on_status(self, usage, context_window, effort):
pass
def on_plan_review(self, content):
return ""
def on_info(self, message):
self.infos.append(message)
def on_error(self, message):
pass
def on_state_change(self, state):
self.states.append(state)
def on_rename(self, name):
pass
def _make_session(ui=None, **kwargs):
"""Helper to construct a ChatSession with minimal setup."""
defaults = dict(
client=MagicMock(),
model="test-model",
ui=ui or NullUI(),
instructions=None,
temperature=0.5,
max_tokens=4096,
tool_timeout=30,
)
defaults.update(kwargs)
return ChatSession(**defaults)
class TestCancelEvent:
"""Basic cancel event mechanics."""
def test_cancel_sets_event(self, tmp_db):
session = _make_session()
assert not session._cancel_event.is_set()
session.cancel()
assert session._cancel_event.is_set()
def test_check_cancelled_raises_when_set(self, tmp_db):
session = _make_session()
session.cancel()
with pytest.raises(GenerationCancelled):
session._check_cancelled()
def test_check_cancelled_noop_when_clear(self, tmp_db):
session = _make_session()
session._check_cancelled() # Should not raise
def test_cancel_is_idempotent(self, tmp_db):
session = _make_session()
session.cancel()
session.cancel() # Double call is harmless
assert session._cancel_event.is_set()
def test_cancel_event_cleared_on_send_start(self, tmp_db):
"""send() clears a stale cancel flag before starting."""
ui = NullUI()
session = _make_session(ui=ui)
session.cancel() # Set stale flag
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = "stop"
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
fake_stream = iter([FakeChunk(content_delta="Hello", finish_reason="stop")])
with (
patch.object(session, "_create_stream_with_retry", return_value=fake_stream),
patch.object(session, "_full_messages", return_value=[]),
):
session.send("test")
# Should complete normally — cancel flag was cleared
assert "idle" in ui.states
class TestCancelDuringStreaming:
"""Cancel while _stream_response is iterating chunks."""
def test_preserves_partial_content(self, tmp_db):
"""Partial content already streamed should be preserved in messages."""
ui = NullUI()
session = _make_session(ui=ui)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = ""
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
def cancelling_stream():
"""Yield a few chunks then cancel."""
yield FakeChunk(content_delta="Hello ")
yield FakeChunk(content_delta="world")
session.cancel()
yield FakeChunk(content_delta=" — this should not appear")
with (
patch.object(session, "_create_stream_with_retry", return_value=cancelling_stream()),
patch.object(session, "_full_messages", return_value=[]),
):
session.send("test")
# Session should be idle (not error)
assert ui.states[-1] == "idle"
# Check that "[Generation cancelled]" was emitted
assert any("cancelled" in i.lower() for i in ui.infos)
# The partial content should be preserved as an assistant message
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
assert len(assistant_msgs) == 1
assert assistant_msgs[0]["content"] == "Hello world"
# No tool_calls in the partial message
assert "tool_calls" not in assistant_msgs[0]
class TestCancelDuringToolExecution:
"""Cancel while tools are being executed."""
def test_rollback_incomplete_tool_results(self, tmp_db):
"""When cancelled during tool execution, incomplete results are rolled back."""
ui = NullUI()
session = _make_session(ui=ui)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = ""
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
@dataclass
class FakeToolDelta:
index: int = 0
id: str = ""
name: str = ""
arguments_delta: str = ""
# First call: return content with a tool call
def stream_with_tool():
yield FakeChunk(
tool_call_deltas=[FakeToolDelta(index=0, id="tc_1", name="bash")],
finish_reason="",
)
yield FakeChunk(
tool_call_deltas=[FakeToolDelta(index=0, arguments_delta='{"command":"echo hi"}')],
finish_reason="tool_calls",
)
call_count = 0
def fake_create_stream(msgs):
nonlocal call_count
call_count += 1
if call_count == 1:
return stream_with_tool()
# Should not be called a second time since cancel happens before phase 3
raise AssertionError("Should not stream again after cancel")
def cancel_before_execute(tool_calls):
"""Simulate cancel happening before tool execution."""
session.cancel()
raise GenerationCancelled()
with (
patch.object(session, "_create_stream_with_retry", side_effect=fake_create_stream),
patch.object(session, "_full_messages", return_value=[]),
patch.object(session, "_execute_tools", side_effect=cancel_before_execute),
):
session.send("run something")
# Session should be idle
assert ui.states[-1] == "idle"
# No tool result messages should remain (rolled back)
roles = [m["role"] for m in session.messages]
assert "tool" not in roles
# The assistant message with tool_calls should also be rolled back
for m in session.messages:
if m["role"] == "assistant":
assert "tool_calls" not in m or not m["tool_calls"]
class TestCancelWhenIdle:
"""Cancelling when no generation is active is harmless."""
def test_cancel_when_idle_is_noop(self, tmp_db):
session = _make_session()
session.cancel()
# Next send should work normally (cancel cleared at start)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = "stop"
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
fake_stream = iter([FakeChunk(content_delta="ok", finish_reason="stop")])
with (
patch.object(session, "_create_stream_with_retry", return_value=fake_stream),
patch.object(session, "_full_messages", return_value=[]),
):
session.send("hello")
# Should complete normally
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
assert len(assistant_msgs) == 1
assert assistant_msgs[0]["content"] == "ok"
class TestCancelThreadSafety:
"""Cancel from a different thread while generation is running."""
def test_cancel_from_another_thread(self, tmp_db):
ui = NullUI()
session = _make_session(ui=ui)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = ""
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
barrier = threading.Event()
def slow_stream():
yield FakeChunk(content_delta="Start")
barrier.set() # Signal that streaming has started
time.sleep(2) # Simulate slow streaming
yield FakeChunk(content_delta=" end", finish_reason="stop")
with (
patch.object(session, "_create_stream_with_retry", return_value=slow_stream()),
patch.object(session, "_full_messages", return_value=[]),
):
# Run send() in a thread
error = []
def run():
try:
session.send("test")
except Exception as e:
error.append(e)
t = threading.Thread(target=run)
t.start()
barrier.wait(timeout=5)
# Cancel from main thread
session.cancel()
t.join(timeout=5)
assert not error
assert ui.states[-1] == "idle"
assert any("cancelled" in i.lower() for i in ui.infos)
class TestGenerationCancelledException:
"""GenerationCancelled is a BaseException, not Exception."""
def test_is_base_exception(self):
assert issubclass(GenerationCancelled, BaseException)
def test_not_caught_by_except_exception(self):
"""Verify GenerationCancelled is NOT caught by except Exception."""
with pytest.raises(GenerationCancelled):
try:
raise GenerationCancelled()
except Exception:
pytest.fail("GenerationCancelled was caught by except Exception")
+761
View File
@@ -0,0 +1,761 @@
"""Tests for governance admin API endpoints (roles, orgs, policies, templates, usage, audit)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
from turnstone.console.server import (
admin_assign_role,
admin_audit,
admin_create_policy,
admin_create_role,
admin_create_template,
admin_delete_policy,
admin_delete_role,
admin_delete_template,
admin_delete_user,
admin_get_org,
admin_list_orgs,
admin_list_policies,
admin_list_roles,
admin_list_templates,
admin_list_user_roles,
admin_unassign_role,
admin_update_org,
admin_update_policy,
admin_update_role,
admin_update_template,
admin_usage,
)
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
# ---------------------------------------------------------------------------
# Auth bypass middleware — injects a full-access AuthResult on every request.
# ---------------------------------------------------------------------------
class _InjectAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-admin",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset(
{
"read",
"write",
"approve",
"admin.roles",
"admin.users",
"admin.orgs",
"admin.policies",
"admin.templates",
"admin.usage",
"admin.audit",
"admin.schedules",
"admin.watches",
"tools.approve",
"workstreams.create",
"workstreams.close",
}
),
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def storage(tmp_path):
"""Fresh SQLite backend for each test, seeded with test users."""
backend = SQLiteBackend(str(tmp_path / "test.db"))
# Seed users required by role assignment tests
backend.create_user("test-admin", "testadmin", "Test Admin", "hash")
backend.create_user("user-1", "user1", "User One", "hash")
return backend
@pytest.fixture
def client(storage):
"""TestClient with storage and auth bypassed."""
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
# Roles
Route("/api/admin/roles", admin_list_roles),
Route("/api/admin/roles", admin_create_role, methods=["POST"]),
Route("/api/admin/roles/{role_id}", admin_update_role, methods=["PUT"]),
Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]),
# Users
Route(
"/api/admin/users/{user_id}",
admin_delete_user,
methods=["DELETE"],
),
# User-role assignments
Route("/api/admin/users/{user_id}/roles", admin_list_user_roles),
Route(
"/api/admin/users/{user_id}/roles",
admin_assign_role,
methods=["POST"],
),
Route(
"/api/admin/users/{user_id}/roles/{role_id}",
admin_unassign_role,
methods=["DELETE"],
),
# Orgs
Route("/api/admin/orgs", admin_list_orgs),
Route("/api/admin/orgs/{org_id}", admin_get_org),
Route("/api/admin/orgs/{org_id}", admin_update_org, methods=["PUT"]),
# Policies
Route("/api/admin/policies", admin_list_policies),
Route("/api/admin/policies", admin_create_policy, methods=["POST"]),
Route(
"/api/admin/policies/{policy_id}",
admin_update_policy,
methods=["PUT"],
),
Route(
"/api/admin/policies/{policy_id}",
admin_delete_policy,
methods=["DELETE"],
),
# Templates
Route("/api/admin/templates", admin_list_templates),
Route("/api/admin/templates", admin_create_template, methods=["POST"]),
Route(
"/api/admin/templates/{template_id}",
admin_update_template,
methods=["PUT"],
),
Route(
"/api/admin/templates/{template_id}",
admin_delete_template,
methods=["DELETE"],
),
# Usage & Audit
Route("/api/admin/usage", admin_usage),
Route("/api/admin/audit", admin_audit),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.auth_storage = storage
return TestClient(app)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _role_payload(**overrides: Any) -> dict[str, Any]:
defaults: dict[str, Any] = {
"name": "analyst",
"display_name": "Data Analyst",
"permissions": "read,write",
}
defaults.update(overrides)
return defaults
def _policy_payload(**overrides: Any) -> dict[str, Any]:
defaults: dict[str, Any] = {
"name": "Allow bash",
"tool_pattern": "bash_*",
"action": "allow",
"priority": 10,
}
defaults.update(overrides)
return defaults
def _template_payload(**overrides: Any) -> dict[str, Any]:
defaults: dict[str, Any] = {
"name": "Greeting",
"content": "Hello {{user}}, how can I help?",
"category": "system",
}
defaults.update(overrides)
return defaults
# ---------------------------------------------------------------------------
# Tests — Roles
# ---------------------------------------------------------------------------
class TestRoles:
def test_list_empty(self, client):
resp = client.get("/v1/api/admin/roles")
assert resp.status_code == 200
assert resp.json()["roles"] == []
def test_create_role(self, client):
resp = client.post("/v1/api/admin/roles", json=_role_payload())
assert resp.status_code == 200
role = resp.json()
assert role["name"] == "analyst"
assert role["display_name"] == "Data Analyst"
assert role["permissions"] == "read,write"
assert role["builtin"] is False
assert "role_id" in role
assert "created" in role
def test_create_role_missing_name(self, client):
resp = client.post("/v1/api/admin/roles", json=_role_payload(name=""))
assert resp.status_code == 400
assert "name" in resp.json()["error"].lower()
def test_create_role_invalid_name(self, client):
resp = client.post("/v1/api/admin/roles", json=_role_payload(name="bad name!@#"))
assert resp.status_code == 400
assert "name" in resp.json()["error"].lower()
def test_create_role_default_display_name(self, client):
resp = client.post(
"/v1/api/admin/roles",
json={"name": "ops", "permissions": ""},
)
assert resp.status_code == 200
role = resp.json()
# display_name defaults to name when not provided
assert role["display_name"] == "ops"
def test_list_after_create(self, client):
client.post("/v1/api/admin/roles", json=_role_payload())
resp = client.get("/v1/api/admin/roles")
assert resp.status_code == 200
roles = resp.json()["roles"]
assert len(roles) == 1
assert roles[0]["name"] == "analyst"
def test_update_role(self, client):
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
role_id = create_resp.json()["role_id"]
resp = client.put(
f"/v1/api/admin/roles/{role_id}",
json={"display_name": "Senior Analyst", "permissions": "read,write,approve"},
)
assert resp.status_code == 200
role = resp.json()
assert role["display_name"] == "Senior Analyst"
assert role["permissions"] == "read,write,approve"
def test_update_nonexistent_role(self, client):
resp = client.put(
"/v1/api/admin/roles/nonexistent",
json={"display_name": "Nope"},
)
assert resp.status_code == 404
def test_update_builtin_role_rejected(self, client, storage):
# Seed a builtin role directly via storage
storage.create_role(
role_id="builtin-admin",
name="admin",
display_name="Administrator",
permissions="*",
builtin=True,
)
resp = client.put(
"/v1/api/admin/roles/builtin-admin",
json={"display_name": "Hacked"},
)
assert resp.status_code == 400
assert "builtin" in resp.json()["error"].lower()
def test_delete_role(self, client):
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
role_id = create_resp.json()["role_id"]
resp = client.delete(f"/v1/api/admin/roles/{role_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Verify gone from listing
list_resp = client.get("/v1/api/admin/roles")
assert list_resp.json()["roles"] == []
def test_delete_nonexistent_role(self, client):
resp = client.delete("/v1/api/admin/roles/nonexistent")
assert resp.status_code == 404
def test_delete_builtin_role_rejected(self, client, storage):
storage.create_role(
role_id="builtin-viewer",
name="viewer",
display_name="Viewer",
permissions="read",
builtin=True,
)
resp = client.delete("/v1/api/admin/roles/builtin-viewer")
assert resp.status_code == 400
assert "builtin" in resp.json()["error"].lower()
# ---------------------------------------------------------------------------
# Tests — Role assignments
# ---------------------------------------------------------------------------
class TestRoleAssignments:
def test_list_user_roles_empty(self, client):
resp = client.get("/v1/api/admin/users/user-1/roles")
assert resp.status_code == 200
assert resp.json()["roles"] == []
def test_assign_role(self, client):
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
role_id = create_resp.json()["role_id"]
resp = client.post(
"/v1/api/admin/users/user-1/roles",
json={"role_id": role_id},
)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Verify listed
list_resp = client.get("/v1/api/admin/users/user-1/roles")
roles = list_resp.json()["roles"]
assert len(roles) >= 1
def test_assign_role_missing_role_id(self, client):
resp = client.post(
"/v1/api/admin/users/user-1/roles",
json={},
)
assert resp.status_code == 400
assert "role_id" in resp.json()["error"].lower()
def test_unassign_role(self, client):
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
role_id = create_resp.json()["role_id"]
# Assign first
client.post(
"/v1/api/admin/users/user-1/roles",
json={"role_id": role_id},
)
# Now unassign
resp = client.delete(f"/v1/api/admin/users/user-1/roles/{role_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Verify removed
list_resp = client.get("/v1/api/admin/users/user-1/roles")
assert list_resp.json()["roles"] == []
def test_unassign_nonexistent(self, client):
resp = client.delete("/v1/api/admin/users/user-1/roles/nonexistent")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Tests — Orgs
# ---------------------------------------------------------------------------
class TestOrgs:
def test_list_empty(self, client):
resp = client.get("/v1/api/admin/orgs")
assert resp.status_code == 200
assert resp.json()["orgs"] == []
def test_get_org(self, client, storage):
storage.create_org(
org_id="org-1",
name="acme",
display_name="Acme Corp",
settings='{"theme": "dark"}',
)
resp = client.get("/v1/api/admin/orgs/org-1")
assert resp.status_code == 200
org = resp.json()
assert org["org_id"] == "org-1"
assert org["name"] == "acme"
assert org["display_name"] == "Acme Corp"
def test_get_org_not_found(self, client):
resp = client.get("/v1/api/admin/orgs/nonexistent")
assert resp.status_code == 404
def test_update_org(self, client, storage):
storage.create_org(org_id="org-1", name="acme", display_name="Acme Corp")
resp = client.put(
"/v1/api/admin/orgs/org-1",
json={"display_name": "Acme Inc.", "settings": '{"theme": "light"}'},
)
assert resp.status_code == 200
org = resp.json()
assert org["display_name"] == "Acme Inc."
assert org["settings"] == '{"theme": "light"}'
def test_update_org_not_found(self, client):
resp = client.put(
"/v1/api/admin/orgs/nonexistent",
json={"display_name": "Nope"},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Tests — Tool policies
# ---------------------------------------------------------------------------
class TestPolicies:
def test_list_empty(self, client):
resp = client.get("/v1/api/admin/policies")
assert resp.status_code == 200
assert resp.json()["policies"] == []
def test_create_policy(self, client):
resp = client.post("/v1/api/admin/policies", json=_policy_payload())
assert resp.status_code == 200
policy = resp.json()
assert policy["name"] == "Allow bash"
assert policy["tool_pattern"] == "bash_*"
assert policy["action"] == "allow"
assert policy["priority"] == 10
assert "policy_id" in policy
assert "created" in policy
def test_create_policy_missing_name(self, client):
resp = client.post("/v1/api/admin/policies", json=_policy_payload(name=""))
assert resp.status_code == 400
assert "name" in resp.json()["error"].lower()
def test_create_policy_missing_tool_pattern(self, client):
resp = client.post(
"/v1/api/admin/policies",
json=_policy_payload(tool_pattern=""),
)
assert resp.status_code == 400
assert "tool_pattern" in resp.json()["error"].lower()
def test_create_policy_invalid_action(self, client):
resp = client.post(
"/v1/api/admin/policies",
json=_policy_payload(action="yolo"),
)
assert resp.status_code == 400
assert "action" in resp.json()["error"].lower()
def test_list_after_create(self, client):
client.post("/v1/api/admin/policies", json=_policy_payload())
resp = client.get("/v1/api/admin/policies")
assert resp.status_code == 200
policies = resp.json()["policies"]
assert len(policies) == 1
assert policies[0]["name"] == "Allow bash"
def test_update_policy(self, client):
create_resp = client.post("/v1/api/admin/policies", json=_policy_payload())
policy_id = create_resp.json()["policy_id"]
resp = client.put(
f"/v1/api/admin/policies/{policy_id}",
json={"name": "Deny bash", "action": "deny", "priority": 20},
)
assert resp.status_code == 200
policy = resp.json()
assert policy["name"] == "Deny bash"
assert policy["action"] == "deny"
assert policy["priority"] == 20
def test_update_policy_invalid_action(self, client):
create_resp = client.post("/v1/api/admin/policies", json=_policy_payload())
policy_id = create_resp.json()["policy_id"]
resp = client.put(
f"/v1/api/admin/policies/{policy_id}",
json={"action": "nope"},
)
assert resp.status_code == 400
assert "action" in resp.json()["error"].lower()
def test_update_policy_not_found(self, client):
resp = client.put(
"/v1/api/admin/policies/nonexistent",
json={"name": "Nope"},
)
assert resp.status_code == 404
def test_delete_policy(self, client):
create_resp = client.post("/v1/api/admin/policies", json=_policy_payload())
policy_id = create_resp.json()["policy_id"]
resp = client.delete(f"/v1/api/admin/policies/{policy_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Verify gone
list_resp = client.get("/v1/api/admin/policies")
assert list_resp.json()["policies"] == []
def test_delete_policy_not_found(self, client):
resp = client.delete("/v1/api/admin/policies/nonexistent")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Tests — Prompt templates
# ---------------------------------------------------------------------------
class TestTemplates:
def test_list_empty(self, client):
resp = client.get("/v1/api/admin/templates")
assert resp.status_code == 200
assert resp.json()["templates"] == []
def test_create_template(self, client):
resp = client.post("/v1/api/admin/templates", json=_template_payload())
assert resp.status_code == 200
tmpl = resp.json()
assert tmpl["name"] == "Greeting"
assert "{{user}}" in tmpl["content"]
assert tmpl["category"] == "system"
assert "template_id" in tmpl
assert "created" in tmpl
def test_create_template_missing_name(self, client):
resp = client.post(
"/v1/api/admin/templates",
json=_template_payload(name=""),
)
assert resp.status_code == 400
assert "name" in resp.json()["error"].lower()
def test_create_template_missing_content(self, client):
resp = client.post(
"/v1/api/admin/templates",
json=_template_payload(content=""),
)
assert resp.status_code == 400
assert "content" in resp.json()["error"].lower()
def test_list_after_create(self, client):
client.post("/v1/api/admin/templates", json=_template_payload())
resp = client.get("/v1/api/admin/templates")
assert resp.status_code == 200
templates = resp.json()["templates"]
assert len(templates) == 1
assert templates[0]["name"] == "Greeting"
def test_update_template(self, client):
create_resp = client.post("/v1/api/admin/templates", json=_template_payload())
template_id = create_resp.json()["template_id"]
resp = client.put(
f"/v1/api/admin/templates/{template_id}",
json={"name": "Welcome", "content": "Welcome, {{user}}!", "is_default": True},
)
assert resp.status_code == 200
tmpl = resp.json()
assert tmpl["name"] == "Welcome"
assert tmpl["content"] == "Welcome, {{user}}!"
assert tmpl["is_default"] is True
def test_update_template_not_found(self, client):
resp = client.put(
"/v1/api/admin/templates/nonexistent",
json={"name": "Nope"},
)
assert resp.status_code == 404
def test_delete_template(self, client):
create_resp = client.post("/v1/api/admin/templates", json=_template_payload())
template_id = create_resp.json()["template_id"]
resp = client.delete(f"/v1/api/admin/templates/{template_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Verify gone
list_resp = client.get("/v1/api/admin/templates")
assert list_resp.json()["templates"] == []
def test_delete_template_not_found(self, client):
resp = client.delete("/v1/api/admin/templates/nonexistent")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Tests — Usage
# ---------------------------------------------------------------------------
class TestUsage:
def test_usage_defaults(self, client):
"""Query usage with no params — should return summary and breakdown."""
resp = client.get("/v1/api/admin/usage")
assert resp.status_code == 200
data = resp.json()
assert "summary" in data
assert "breakdown" in data
# Summary is a list with at least one row
assert isinstance(data["summary"], list)
assert len(data["summary"]) >= 1
# All-zeros when no data
assert data["summary"][0]["prompt_tokens"] == 0
def test_usage_with_data(self, client, storage):
"""Seed usage events and verify they appear in the query."""
storage.record_usage_event(
event_id="evt-1",
user_id="user-1",
model="gpt-5",
prompt_tokens=100,
completion_tokens=50,
tool_calls_count=2,
)
storage.record_usage_event(
event_id="evt-2",
user_id="user-1",
model="gpt-5",
prompt_tokens=200,
completion_tokens=75,
tool_calls_count=1,
)
resp = client.get("/v1/api/admin/usage")
assert resp.status_code == 200
summary = resp.json()["summary"]
assert summary[0]["prompt_tokens"] == 300
assert summary[0]["completion_tokens"] == 125
assert summary[0]["tool_calls_count"] == 3
def test_usage_with_filters(self, client, storage):
storage.record_usage_event(
event_id="evt-f1",
user_id="user-a",
model="gpt-5",
prompt_tokens=100,
completion_tokens=10,
)
storage.record_usage_event(
event_id="evt-f2",
user_id="user-b",
model="claude-4",
prompt_tokens=200,
completion_tokens=20,
)
resp = client.get("/v1/api/admin/usage?user_id=user-a")
assert resp.status_code == 200
summary = resp.json()["summary"]
assert summary[0]["prompt_tokens"] == 100
resp2 = client.get("/v1/api/admin/usage?model=claude-4")
assert resp2.status_code == 200
summary2 = resp2.json()["summary"]
assert summary2[0]["prompt_tokens"] == 200
# ---------------------------------------------------------------------------
# Tests — Audit
# ---------------------------------------------------------------------------
class TestAudit:
def test_audit_empty(self, client):
resp = client.get("/v1/api/admin/audit")
assert resp.status_code == 200
data = resp.json()
assert data["events"] == []
assert data["total"] == 0
def test_audit_populated_by_mutations(self, client):
"""Creating a role should produce an audit event."""
client.post("/v1/api/admin/roles", json=_role_payload())
resp = client.get("/v1/api/admin/audit")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
actions = [e["action"] for e in data["events"]]
assert "role.create" in actions
def test_audit_filter_by_action(self, client):
# Create a role and a policy to produce different audit actions
client.post("/v1/api/admin/roles", json=_role_payload())
client.post("/v1/api/admin/policies", json=_policy_payload())
resp = client.get("/v1/api/admin/audit?action=policy.create")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
assert all(e["action"] == "policy.create" for e in data["events"])
def test_audit_filter_by_user_id(self, client):
client.post("/v1/api/admin/roles", json=_role_payload())
resp = client.get("/v1/api/admin/audit?user_id=test-admin")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
assert all(e["user_id"] == "test-admin" for e in data["events"])
def test_audit_pagination(self, client):
# Create several resources to produce multiple audit events
for i in range(5):
client.post(
"/v1/api/admin/roles",
json=_role_payload(name=f"role-{i}"),
)
resp = client.get("/v1/api/admin/audit?limit=2&offset=0")
assert resp.status_code == 200
data = resp.json()
assert len(data["events"]) == 2
assert data["total"] >= 5
resp2 = client.get("/v1/api/admin/audit?limit=2&offset=2")
assert resp2.status_code == 200
data2 = resp2.json()
assert len(data2["events"]) == 2
# The two pages should not overlap
ids_page1 = {e["event_id"] for e in data["events"]}
ids_page2 = {e["event_id"] for e in data2["events"]}
assert ids_page1.isdisjoint(ids_page2)
# ---------------------------------------------------------------------------
# Tests — User self-deletion guard
# ---------------------------------------------------------------------------
class TestUserSelfDeletion:
def test_cannot_delete_self(self, client):
"""Admin should not be able to delete their own account."""
resp = client.delete("/v1/api/admin/users/test-admin")
assert resp.status_code == 400
assert "own account" in resp.json()["error"].lower()
def test_can_delete_other_user(self, client):
resp = client.delete("/v1/api/admin/users/user-1")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
+746
View File
@@ -0,0 +1,746 @@
"""Tests for governance storage operations (SQLite backend).
Covers RBAC roles, organizations, tool policies, prompt templates,
usage events, and audit events.
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
import sqlalchemy as sa
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture()
def db(tmp_path):
"""Create a fresh SQLite backend for each test."""
return SQLiteBackend(str(tmp_path / "test.db"))
# ---------------------------------------------------------------------------
# Roles
# ---------------------------------------------------------------------------
class TestRoleCRUD:
def test_create_role(self, db):
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
role = db.get_role("r1")
assert role is not None
assert role["role_id"] == "r1"
assert role["name"] == "editor"
assert role["display_name"] == "Editor"
assert role["permissions"] == "read,write"
assert role["builtin"] is False
assert role["org_id"] == ""
assert "created" in role
assert "updated" in role
def test_create_role_idempotent(self, db):
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
# Second insert with same role_id should be silently ignored.
db.create_role("r1", "editor2", "Editor 2", "read", builtin=True, org_id="org1")
role = db.get_role("r1")
assert role is not None
# Original values preserved.
assert role["name"] == "editor"
assert role["display_name"] == "Editor"
def test_get_role_by_name(self, db):
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
role = db.get_role_by_name("editor")
assert role is not None
assert role["role_id"] == "r1"
def test_get_role_by_name_nonexistent(self, db):
assert db.get_role_by_name("nope") is None
def test_list_roles(self, db):
db.create_role("r2", "beta", "Beta Role", "read", builtin=False, org_id="")
db.create_role("r1", "alpha", "Alpha Role", "write", builtin=False, org_id="")
roles = db.list_roles()
assert len(roles) == 2
# Ordered by name ascending.
assert roles[0]["name"] == "alpha"
assert roles[1]["name"] == "beta"
def test_list_roles_filter_org(self, db):
db.create_role("r1", "role_a", "A", "read", builtin=False, org_id="org1")
db.create_role("r2", "role_b", "B", "read", builtin=False, org_id="org2")
db.create_role("r3", "role_c", "C", "read", builtin=False, org_id="org1")
result = db.list_roles(org_id="org1")
assert len(result) == 2
assert {r["role_id"] for r in result} == {"r1", "r3"}
def test_update_role(self, db):
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
ok = db.update_role("r1", permissions="read,write,approve", display_name="Senior Editor")
assert ok is True
role = db.get_role("r1")
assert role is not None
assert role["permissions"] == "read,write,approve"
assert role["display_name"] == "Senior Editor"
def test_update_role_nonexistent(self, db):
assert db.update_role("missing", permissions="read") is False
def test_delete_role(self, db):
db.create_role("r1", "editor", "Editor", "read", builtin=False, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.assign_role("u1", "r1")
# Verify assignment exists.
assert len(db.list_user_roles("u1")) == 1
ok = db.delete_role("r1")
assert ok is True
assert db.get_role("r1") is None
# Cascade: user_roles for this role should be gone.
assert len(db.list_user_roles("u1")) == 0
def test_delete_role_nonexistent(self, db):
assert db.delete_role("missing") is False
def test_assign_role(self, db):
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.assign_role("u1", "r1", assigned_by="admin")
roles = db.list_user_roles("u1")
assert len(roles) == 1
assert roles[0]["role_id"] == "r1"
assert roles[0]["assigned_by"] == "admin"
def test_assign_role_idempotent(self, db):
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.assign_role("u1", "r1")
# Second assign should not raise.
db.assign_role("u1", "r1")
roles = db.list_user_roles("u1")
assert len(roles) == 1
def test_unassign_role(self, db):
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.assign_role("u1", "r1")
ok = db.unassign_role("u1", "r1")
assert ok is True
assert len(db.list_user_roles("u1")) == 0
def test_unassign_role_nonexistent(self, db):
assert db.unassign_role("u1", "r1") is False
def test_list_user_roles(self, db):
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
db.create_role("r2", "viewer", "Viewer", "read", builtin=True, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.assign_role("u1", "r1", assigned_by="admin")
db.assign_role("u1", "r2", assigned_by="system")
roles = db.list_user_roles("u1")
assert len(roles) == 2
# Each entry should have joined role fields plus assignment metadata.
for r in roles:
assert "role_id" in r
assert "name" in r
assert "permissions" in r
assert "assigned_by" in r
assert "assignment_created" in r
def test_get_user_permissions(self, db):
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
db.create_role("r2", "approver", "Approver", "approve,read", builtin=False, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.assign_role("u1", "r1")
db.assign_role("u1", "r2")
perms = db.get_user_permissions("u1")
assert perms == {"read", "write", "approve"}
def test_get_user_permissions_no_roles(self, db):
db.create_user("u1", "alice", "Alice", "$2b$hash")
assert db.get_user_permissions("u1") == set()
# ---------------------------------------------------------------------------
# Organizations
# ---------------------------------------------------------------------------
class TestOrgCRUD:
def test_create_org(self, db):
db.create_org("org1", "acme", "Acme Corp", '{"plan":"pro"}')
org = db.get_org("org1")
assert org is not None
assert org["org_id"] == "org1"
assert org["name"] == "acme"
assert org["display_name"] == "Acme Corp"
assert org["settings"] == '{"plan":"pro"}'
assert "created" in org
assert "updated" in org
def test_get_org_nonexistent(self, db):
assert db.get_org("nope") is None
def test_create_org_idempotent(self, db):
db.create_org("org1", "acme", "Acme Corp")
db.create_org("org1", "acme2", "Acme 2")
org = db.get_org("org1")
assert org is not None
assert org["name"] == "acme"
def test_list_orgs(self, db):
db.create_org("o2", "beta", "Beta Inc")
db.create_org("o1", "alpha", "Alpha LLC")
orgs = db.list_orgs()
assert len(orgs) == 2
# Ordered by name ascending.
assert orgs[0]["name"] == "alpha"
assert orgs[1]["name"] == "beta"
def test_update_org(self, db):
db.create_org("org1", "acme", "Acme Corp")
ok = db.update_org(
"org1", display_name="Acme Corp Global", settings='{"plan":"enterprise"}'
)
assert ok is True
org = db.get_org("org1")
assert org is not None
assert org["display_name"] == "Acme Corp Global"
assert org["settings"] == '{"plan":"enterprise"}'
def test_update_org_nonexistent(self, db):
assert db.update_org("missing", display_name="X") is False
# ---------------------------------------------------------------------------
# Tool Policies
# ---------------------------------------------------------------------------
class TestToolPolicyCRUD:
def test_create_tool_policy(self, db):
db.create_tool_policy(
"p1",
"deny-bash",
"bash*",
"deny",
priority=100,
org_id="org1",
enabled=True,
created_by="admin",
)
pol = db.get_tool_policy("p1")
assert pol is not None
assert pol["policy_id"] == "p1"
assert pol["name"] == "deny-bash"
assert pol["tool_pattern"] == "bash*"
assert pol["action"] == "deny"
assert pol["priority"] == 100
assert pol["org_id"] == "org1"
assert pol["enabled"] is True
assert pol["created_by"] == "admin"
def test_get_tool_policy_nonexistent(self, db):
assert db.get_tool_policy("missing") is None
def test_list_tool_policies_ordered_by_priority(self, db):
db.create_tool_policy("p1", "low", "*", "allow", priority=10)
db.create_tool_policy("p2", "high", "*", "deny", priority=100)
db.create_tool_policy("p3", "mid", "*", "ask", priority=50)
policies = db.list_tool_policies()
assert len(policies) == 3
# DESC priority order.
assert policies[0]["priority"] == 100
assert policies[1]["priority"] == 50
assert policies[2]["priority"] == 10
def test_update_tool_policy(self, db):
db.create_tool_policy("p1", "deny-bash", "bash*", "deny", priority=100)
ok = db.update_tool_policy("p1", action="allow", priority=50)
assert ok is True
pol = db.get_tool_policy("p1")
assert pol is not None
assert pol["action"] == "allow"
assert pol["priority"] == 50
def test_update_tool_policy_nonexistent(self, db):
assert db.update_tool_policy("missing", action="deny") is False
def test_delete_tool_policy(self, db):
db.create_tool_policy("p1", "deny-bash", "bash*", "deny", priority=100)
ok = db.delete_tool_policy("p1")
assert ok is True
assert db.get_tool_policy("p1") is None
def test_delete_tool_policy_nonexistent(self, db):
assert db.delete_tool_policy("missing") is False
def test_enabled_as_bool(self, db):
db.create_tool_policy("p1", "on", "*", "allow", priority=0, enabled=True)
db.create_tool_policy("p2", "off", "*", "deny", priority=0, enabled=False)
p1 = db.get_tool_policy("p1")
p2 = db.get_tool_policy("p2")
assert p1 is not None
assert p2 is not None
assert p1["enabled"] is True
assert isinstance(p1["enabled"], bool)
assert p2["enabled"] is False
assert isinstance(p2["enabled"], bool)
def test_list_policies_filter_org(self, db):
db.create_tool_policy("p1", "a", "*", "allow", priority=0, org_id="org1")
db.create_tool_policy("p2", "b", "*", "deny", priority=0, org_id="org2")
db.create_tool_policy("p3", "c", "*", "ask", priority=0, org_id="org1")
result = db.list_tool_policies(org_id="org1")
assert len(result) == 2
assert {r["policy_id"] for r in result} == {"p1", "p3"}
# ---------------------------------------------------------------------------
# Prompt Templates
# ---------------------------------------------------------------------------
class TestPromptTemplateCRUD:
def test_create_prompt_template(self, db):
db.create_prompt_template(
"t1",
"greeting",
"general",
"Hello {{name}}!",
variables='["name"]',
is_default=True,
org_id="org1",
created_by="admin",
)
tpl = db.get_prompt_template("t1")
assert tpl is not None
assert tpl["template_id"] == "t1"
assert tpl["name"] == "greeting"
assert tpl["category"] == "general"
assert tpl["content"] == "Hello {{name}}!"
assert tpl["variables"] == '["name"]'
assert tpl["is_default"] is True
assert tpl["org_id"] == "org1"
assert tpl["created_by"] == "admin"
def test_get_prompt_template_nonexistent(self, db):
assert db.get_prompt_template("missing") is None
def test_list_prompt_templates_ordered_by_name(self, db):
db.create_prompt_template("t2", "beta", "general", "B")
db.create_prompt_template("t1", "alpha", "general", "A")
templates = db.list_prompt_templates()
assert len(templates) == 2
assert templates[0]["name"] == "alpha"
assert templates[1]["name"] == "beta"
def test_list_prompt_templates_filter_org(self, db):
db.create_prompt_template("t1", "a", "general", "A", org_id="org1")
db.create_prompt_template("t2", "b", "general", "B", org_id="org2")
result = db.list_prompt_templates(org_id="org1")
assert len(result) == 1
assert result[0]["template_id"] == "t1"
def test_update_prompt_template(self, db):
db.create_prompt_template("t1", "greeting", "general", "Hello!")
ok = db.update_prompt_template("t1", content="Hi there!", category="custom")
assert ok is True
tpl = db.get_prompt_template("t1")
assert tpl is not None
assert tpl["content"] == "Hi there!"
assert tpl["category"] == "custom"
def test_update_prompt_template_nonexistent(self, db):
assert db.update_prompt_template("missing", content="x") is False
def test_delete_prompt_template(self, db):
db.create_prompt_template("t1", "greeting", "general", "Hello!")
ok = db.delete_prompt_template("t1")
assert ok is True
assert db.get_prompt_template("t1") is None
def test_delete_prompt_template_nonexistent(self, db):
assert db.delete_prompt_template("missing") is False
def test_is_default_as_bool(self, db):
db.create_prompt_template("t1", "default_one", "general", "D", is_default=True)
db.create_prompt_template("t2", "not_default", "general", "N", is_default=False)
t1 = db.get_prompt_template("t1")
t2 = db.get_prompt_template("t2")
assert t1 is not None
assert t2 is not None
assert t1["is_default"] is True
assert isinstance(t1["is_default"], bool)
assert t2["is_default"] is False
assert isinstance(t2["is_default"], bool)
# ---------------------------------------------------------------------------
# Usage Events
# ---------------------------------------------------------------------------
class TestUsageEvents:
def test_record_usage_event(self, db):
db.record_usage_event(
"ev1",
user_id="u1",
ws_id="ws1",
node_id="n1",
model="gpt-5",
prompt_tokens=100,
completion_tokens=50,
tool_calls_count=2,
)
# Verify via query_usage (no group_by returns summary).
result = db.query_usage(since="2000-01-01T00:00:00")
assert len(result) == 1
assert result[0]["prompt_tokens"] == 100
assert result[0]["completion_tokens"] == 50
assert result[0]["tool_calls_count"] == 2
def test_query_usage_summary(self, db):
db.record_usage_event("ev1", model="gpt-5", prompt_tokens=100, completion_tokens=50)
db.record_usage_event("ev2", model="gpt-5", prompt_tokens=200, completion_tokens=75)
result = db.query_usage(since="2000-01-01T00:00:00")
assert len(result) == 1
assert result[0]["prompt_tokens"] == 300
assert result[0]["completion_tokens"] == 125
def test_query_usage_by_day(self, db):
# Insert events with known timestamps by directly inserting rows.
from turnstone.core.storage._schema import usage_events
with db._engine.connect() as conn:
conn.execute(
sa.insert(usage_events),
[
{
"event_id": "e1",
"timestamp": "2026-03-01T10:00:00",
"user_id": "",
"ws_id": "",
"node_id": "",
"model": "gpt-5",
"prompt_tokens": 100,
"completion_tokens": 50,
"tool_calls_count": 0,
"created": "2026-03-01T10:00:00",
},
{
"event_id": "e2",
"timestamp": "2026-03-01T14:00:00",
"user_id": "",
"ws_id": "",
"node_id": "",
"model": "gpt-5",
"prompt_tokens": 50,
"completion_tokens": 25,
"tool_calls_count": 0,
"created": "2026-03-01T14:00:00",
},
{
"event_id": "e3",
"timestamp": "2026-03-02T08:00:00",
"user_id": "",
"ws_id": "",
"node_id": "",
"model": "gpt-5",
"prompt_tokens": 200,
"completion_tokens": 100,
"tool_calls_count": 0,
"created": "2026-03-02T08:00:00",
},
],
)
conn.commit()
result = db.query_usage(since="2026-03-01T00:00:00", group_by="day")
assert len(result) == 2
assert result[0]["key"] == "2026-03-01"
assert result[0]["prompt_tokens"] == 150
assert result[1]["key"] == "2026-03-02"
assert result[1]["prompt_tokens"] == 200
def test_query_usage_by_model(self, db):
from turnstone.core.storage._schema import usage_events
with db._engine.connect() as conn:
conn.execute(
sa.insert(usage_events),
[
{
"event_id": "e1",
"timestamp": "2026-03-01T10:00:00",
"user_id": "",
"ws_id": "",
"node_id": "",
"model": "gpt-5",
"prompt_tokens": 100,
"completion_tokens": 50,
"tool_calls_count": 0,
"created": "2026-03-01T10:00:00",
},
{
"event_id": "e2",
"timestamp": "2026-03-01T10:00:00",
"user_id": "",
"ws_id": "",
"node_id": "",
"model": "claude-4",
"prompt_tokens": 200,
"completion_tokens": 100,
"tool_calls_count": 1,
"created": "2026-03-01T10:00:00",
},
],
)
conn.commit()
result = db.query_usage(since="2026-03-01T00:00:00", group_by="model")
assert len(result) == 2
keys = [r["key"] for r in result]
assert "gpt-5" in keys
assert "claude-4" in keys
def test_query_usage_by_user(self, db):
from turnstone.core.storage._schema import usage_events
with db._engine.connect() as conn:
conn.execute(
sa.insert(usage_events),
[
{
"event_id": "e1",
"timestamp": "2026-03-01T10:00:00",
"user_id": "u1",
"ws_id": "",
"node_id": "",
"model": "",
"prompt_tokens": 100,
"completion_tokens": 50,
"tool_calls_count": 0,
"created": "2026-03-01T10:00:00",
},
{
"event_id": "e2",
"timestamp": "2026-03-01T10:00:00",
"user_id": "u2",
"ws_id": "",
"node_id": "",
"model": "",
"prompt_tokens": 300,
"completion_tokens": 150,
"tool_calls_count": 2,
"created": "2026-03-01T10:00:00",
},
],
)
conn.commit()
result = db.query_usage(since="2026-03-01T00:00:00", group_by="user")
assert len(result) == 2
by_key = {r["key"]: r for r in result}
assert by_key["u1"]["prompt_tokens"] == 100
assert by_key["u2"]["prompt_tokens"] == 300
def test_query_usage_filter_model(self, db):
from turnstone.core.storage._schema import usage_events
with db._engine.connect() as conn:
conn.execute(
sa.insert(usage_events),
[
{
"event_id": "e1",
"timestamp": "2026-03-01T10:00:00",
"user_id": "",
"ws_id": "",
"node_id": "",
"model": "gpt-5",
"prompt_tokens": 100,
"completion_tokens": 50,
"tool_calls_count": 0,
"created": "2026-03-01T10:00:00",
},
{
"event_id": "e2",
"timestamp": "2026-03-01T10:00:00",
"user_id": "",
"ws_id": "",
"node_id": "",
"model": "claude-4",
"prompt_tokens": 200,
"completion_tokens": 100,
"tool_calls_count": 0,
"created": "2026-03-01T10:00:00",
},
],
)
conn.commit()
result = db.query_usage(since="2026-03-01T00:00:00", model="gpt-5")
assert len(result) == 1
assert result[0]["prompt_tokens"] == 100
def test_prune_usage_events(self, db):
from turnstone.core.storage._schema import usage_events
old_ts = "2020-01-01T00:00:00"
now_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with db._engine.connect() as conn:
conn.execute(
sa.insert(usage_events),
[
{
"event_id": "old",
"timestamp": old_ts,
"user_id": "",
"ws_id": "",
"node_id": "",
"model": "",
"prompt_tokens": 10,
"completion_tokens": 5,
"tool_calls_count": 0,
"created": old_ts,
},
{
"event_id": "new",
"timestamp": now_ts,
"user_id": "",
"ws_id": "",
"node_id": "",
"model": "",
"prompt_tokens": 20,
"completion_tokens": 10,
"tool_calls_count": 0,
"created": now_ts,
},
],
)
conn.commit()
pruned = db.prune_usage_events(retention_days=30)
assert pruned == 1
# Only the recent event should remain.
result = db.query_usage(since="2000-01-01T00:00:00")
assert result[0]["prompt_tokens"] == 20
# ---------------------------------------------------------------------------
# Audit Events
# ---------------------------------------------------------------------------
class TestAuditEvents:
def test_record_audit_event(self, db):
db.record_audit_event(
"a1",
user_id="u1",
action="role.create",
resource_type="role",
resource_id="r1",
detail='{"name":"editor"}',
ip_address="127.0.0.1",
)
events = db.list_audit_events()
assert len(events) == 1
ev = events[0]
assert ev["event_id"] == "a1"
assert ev["user_id"] == "u1"
assert ev["action"] == "role.create"
assert ev["resource_type"] == "role"
assert ev["resource_id"] == "r1"
assert ev["detail"] == '{"name":"editor"}'
assert ev["ip_address"] == "127.0.0.1"
def test_list_audit_events(self, db):
db.record_audit_event("a1", action="login")
db.record_audit_event("a2", action="logout")
events = db.list_audit_events()
assert len(events) == 2
# Ordered by timestamp DESC — most recent first.
# Both created in quick succession with same-second granularity,
# but the order should still be deterministic (DESC).
assert {e["event_id"] for e in events} == {"a1", "a2"}
def test_list_audit_events_filter_action(self, db):
db.record_audit_event("a1", action="login")
db.record_audit_event("a2", action="logout")
db.record_audit_event("a3", action="login")
events = db.list_audit_events(action="login")
assert len(events) == 2
assert all(e["action"] == "login" for e in events)
def test_list_audit_events_filter_user(self, db):
db.record_audit_event("a1", user_id="u1", action="login")
db.record_audit_event("a2", user_id="u2", action="login")
events = db.list_audit_events(user_id="u1")
assert len(events) == 1
assert events[0]["user_id"] == "u1"
def test_list_audit_events_pagination(self, db):
for i in range(5):
db.record_audit_event(f"a{i}", action="test")
page1 = db.list_audit_events(limit=2, offset=0)
page2 = db.list_audit_events(limit=2, offset=2)
page3 = db.list_audit_events(limit=2, offset=4)
assert len(page1) == 2
assert len(page2) == 2
assert len(page3) == 1
# No overlap.
ids = [e["event_id"] for e in page1 + page2 + page3]
assert len(set(ids)) == 5
def test_count_audit_events(self, db):
db.record_audit_event("a1", action="login")
db.record_audit_event("a2", action="logout")
db.record_audit_event("a3", action="login")
assert db.count_audit_events() == 3
assert db.count_audit_events(action="login") == 2
assert db.count_audit_events(action="logout") == 1
def test_count_audit_events_filter_user(self, db):
db.record_audit_event("a1", user_id="u1", action="login")
db.record_audit_event("a2", user_id="u2", action="login")
assert db.count_audit_events(user_id="u1") == 1
def test_prune_audit_events(self, db):
from turnstone.core.storage._schema import audit_events
old_ts = "2020-01-01T00:00:00"
now_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with db._engine.connect() as conn:
conn.execute(
sa.insert(audit_events),
[
{
"event_id": "old",
"timestamp": old_ts,
"user_id": "",
"action": "test",
"resource_type": "",
"resource_id": "",
"detail": "{}",
"ip_address": "",
"created": old_ts,
},
{
"event_id": "new",
"timestamp": now_ts,
"user_id": "",
"action": "test",
"resource_type": "",
"resource_id": "",
"detail": "{}",
"ip_address": "",
"created": now_ts,
},
],
)
conn.commit()
pruned = db.prune_audit_events(retention_days=30)
assert pruned == 1
assert db.count_audit_events() == 1
+2
View File
@@ -8,6 +8,7 @@ from turnstone.mq.protocol import (
AckEvent,
ApprovalRequestEvent,
ApproveMessage,
CancelMessage,
CloseWorkstreamMessage,
CommandMessage,
ContentEvent,
@@ -68,6 +69,7 @@ INBOUND_TYPES = [
(ListWorkstreamsMessage, {}),
(HealthMessage, {}),
(ListNodesMessage, {}),
(CancelMessage, {"ws_id": "abc"}),
]
+21
View File
@@ -2,11 +2,19 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
from turnstone.console.server import (
admin_create_schedule,
admin_delete_schedule,
@@ -15,9 +23,21 @@ from turnstone.console.server import (
admin_list_schedules,
admin_update_schedule,
)
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
class _InjectAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-admin",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset({"admin.schedules"}),
)
return await call_next(request)
@pytest.fixture
def storage(tmp_path):
"""Fresh SQLite backend for each test."""
@@ -52,6 +72,7 @@ def client(storage):
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.auth_storage = storage
return TestClient(app)
+86
View File
@@ -0,0 +1,86 @@
"""Tests for turnstone.core.policy."""
import pytest
from turnstone.core.policy import evaluate_tool_policies_batch, evaluate_tool_policy
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def storage(tmp_path):
path = str(tmp_path / "test.db")
backend = SQLiteBackend(path)
yield backend
backend.close()
def test_no_policies_returns_none(storage):
result = evaluate_tool_policy(storage, "bash")
assert result is None
def test_exact_match_allow(storage):
storage.create_tool_policy("p1", "allow-read", "read_file", "allow", 0)
assert evaluate_tool_policy(storage, "read_file") == "allow"
assert evaluate_tool_policy(storage, "write_file") is None
def test_glob_match_deny(storage):
storage.create_tool_policy("p1", "block-bash", "bash*", "deny", 0)
assert evaluate_tool_policy(storage, "bash") == "deny"
assert evaluate_tool_policy(storage, "bash_exec") == "deny"
assert evaluate_tool_policy(storage, "read_file") is None
def test_wildcard_match(storage):
storage.create_tool_policy("p1", "ask-all", "*", "ask", 0)
assert evaluate_tool_policy(storage, "anything") == "ask"
def test_priority_ordering(storage):
# Higher priority wins
storage.create_tool_policy("p1", "allow-all", "*", "allow", 0)
storage.create_tool_policy("p2", "deny-bash", "bash*", "deny", 100)
assert evaluate_tool_policy(storage, "bash") == "deny" # p2 matches first (higher priority)
assert evaluate_tool_policy(storage, "read_file") == "allow" # p1 matches
def test_disabled_policy_skipped(storage):
storage.create_tool_policy("p1", "block-bash", "bash*", "deny", 100, enabled=False)
storage.create_tool_policy("p2", "allow-all", "*", "allow", 0)
assert evaluate_tool_policy(storage, "bash") == "allow" # p1 disabled, falls through to p2
def test_batch_evaluation(storage):
storage.create_tool_policy("p1", "block-bash", "bash*", "deny", 100)
storage.create_tool_policy("p2", "allow-read", "read_*", "allow", 50)
results = evaluate_tool_policies_batch(storage, ["bash", "read_file", "write_file"])
assert results["bash"] == "deny"
assert results["read_file"] == "allow"
assert results["write_file"] is None
def test_storage_failure_returns_none():
"""Graceful degradation on storage failure."""
class BrokenStorage:
def list_tool_policies(self, org_id=""):
raise RuntimeError("boom")
assert evaluate_tool_policy(BrokenStorage(), "bash") is None
def test_batch_storage_failure():
class BrokenStorage:
def list_tool_policies(self, org_id=""):
raise RuntimeError("boom")
results = evaluate_tool_policies_batch(BrokenStorage(), ["a", "b"])
assert results == {"a": None, "b": None}
def test_first_match_wins(storage):
# Two policies match, first by priority wins
storage.create_tool_policy("p1", "deny-bash", "bash*", "deny", 100)
storage.create_tool_policy("p2", "allow-bash", "bash*", "allow", 50)
assert evaluate_tool_policy(storage, "bash_exec") == "deny"
+8
View File
@@ -65,6 +65,14 @@ class TestUserCRUD:
db.delete_user("u1")
assert len(db.list_api_tokens("u1")) == 0
def test_delete_cascades_user_roles(self, db):
db.create_user("u1", "admin", "Admin", "$2b$hash")
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
db.assign_role("u1", "r1")
assert len(db.list_user_roles("u1")) == 1
db.delete_user("u1")
assert len(db.list_user_roles("u1")) == 0
class TestApiTokenCRUD:
def test_create_and_lookup_by_hash(self, db):
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.5.3"
__version__ = "0.5.4"
+192
View File
@@ -156,3 +156,195 @@ class ConsoleHealthResponse(BaseModel):
workstreams: int = 0
version_drift: bool = False
versions: list[str] = []
# ---------------------------------------------------------------------------
# Governance: Roles
# ---------------------------------------------------------------------------
class RoleInfo(BaseModel):
role_id: str
name: str
display_name: str
permissions: str
builtin: bool
org_id: str
created: str
updated: str
class CreateRoleRequest(BaseModel):
name: str
display_name: str = ""
permissions: str = "read"
class UpdateRoleRequest(BaseModel):
display_name: str | None = None
permissions: str | None = None
class ListRolesResponse(BaseModel):
roles: list[RoleInfo]
class AssignRoleRequest(BaseModel):
role_id: str
class UserRoleInfo(BaseModel):
role_id: str
name: str
display_name: str
permissions: str
builtin: bool
org_id: str
created: str
updated: str
assigned_by: str
assignment_created: str
class ListUserRolesResponse(BaseModel):
roles: list[UserRoleInfo]
# ---------------------------------------------------------------------------
# Governance: Orgs
# ---------------------------------------------------------------------------
class OrgInfo(BaseModel):
org_id: str
name: str
display_name: str
settings: str
created: str
updated: str
class UpdateOrgRequest(BaseModel):
display_name: str | None = None
settings: str | None = None
class ListOrgsResponse(BaseModel):
orgs: list[OrgInfo]
# ---------------------------------------------------------------------------
# Governance: Tool Policies
# ---------------------------------------------------------------------------
class ToolPolicyInfo(BaseModel):
policy_id: str
name: str
tool_pattern: str
action: str
priority: int
org_id: str
enabled: bool
created_by: str
created: str
updated: str
class CreateToolPolicyRequest(BaseModel):
name: str
tool_pattern: str
action: str # allow, deny, ask
priority: int = 0
org_id: str = ""
enabled: bool = True
class UpdateToolPolicyRequest(BaseModel):
name: str | None = None
tool_pattern: str | None = None
action: str | None = None
priority: int | None = None
enabled: bool | None = None
class ListToolPoliciesResponse(BaseModel):
policies: list[ToolPolicyInfo]
# ---------------------------------------------------------------------------
# Governance: Prompt Templates
# ---------------------------------------------------------------------------
class PromptTemplateInfo(BaseModel):
template_id: str
name: str
category: str
content: str
variables: str
is_default: bool
org_id: str
created_by: str
created: str
updated: str
class CreatePromptTemplateRequest(BaseModel):
name: str
content: str
category: str = "general"
variables: str = "[]"
is_default: bool = False
org_id: str = ""
class UpdatePromptTemplateRequest(BaseModel):
name: str | None = None
content: str | None = None
category: str | None = None
variables: str | None = None
is_default: bool | None = None
class ListPromptTemplatesResponse(BaseModel):
templates: list[PromptTemplateInfo]
# ---------------------------------------------------------------------------
# Governance: Usage
# ---------------------------------------------------------------------------
class UsageBreakdownItem(BaseModel):
key: str = ""
prompt_tokens: int = 0
completion_tokens: int = 0
tool_calls_count: int = 0
class UsageResponse(BaseModel):
summary: list[UsageBreakdownItem]
breakdown: list[UsageBreakdownItem]
# ---------------------------------------------------------------------------
# Governance: Audit
# ---------------------------------------------------------------------------
class AuditEventInfo(BaseModel):
event_id: str
timestamp: str
user_id: str
action: str
resource_type: str
resource_id: str
detail: str
ip_address: str
created: str
class ListAuditEventsResponse(BaseModel):
events: list[AuditEventInfo]
total: int
+229
View File
@@ -8,6 +8,8 @@ if TYPE_CHECKING:
from pydantic import BaseModel
from turnstone.api.console_schemas import (
AssignRoleRequest,
AuditEventInfo,
ClusterNodesResponse,
ClusterOverviewResponse,
ClusterSnapshotResponse,
@@ -15,7 +17,27 @@ from turnstone.api.console_schemas import (
ConsoleCreateWsRequest,
ConsoleCreateWsResponse,
ConsoleHealthResponse,
CreatePromptTemplateRequest,
CreateRoleRequest,
CreateToolPolicyRequest,
ListAuditEventsResponse,
ListOrgsResponse,
ListPromptTemplatesResponse,
ListRolesResponse,
ListToolPoliciesResponse,
ListUserRolesResponse,
NodeDetailResponse,
OrgInfo,
PromptTemplateInfo,
RoleInfo,
ToolPolicyInfo,
UpdateOrgRequest,
UpdatePromptTemplateRequest,
UpdateRoleRequest,
UpdateToolPolicyRequest,
UsageBreakdownItem,
UsageResponse,
UserRoleInfo,
)
from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi
from turnstone.api.schemas import (
@@ -252,6 +274,191 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[404],
tags=["Schedules"],
),
# --- Governance: Roles ---
EndpointSpec(
"/v1/api/admin/roles",
"GET",
"List all roles",
response_model=ListRolesResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/roles",
"POST",
"Create a custom role",
request_model=CreateRoleRequest,
response_model=RoleInfo,
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/roles/{role_id}",
"PUT",
"Update a role",
request_model=UpdateRoleRequest,
response_model=RoleInfo,
error_codes=[400, 404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/roles/{role_id}",
"DELETE",
"Delete a custom role",
response_model=StatusResponse,
error_codes=[400, 404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/users/{user_id}/roles",
"GET",
"List roles assigned to a user",
response_model=ListUserRolesResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/users/{user_id}/roles",
"POST",
"Assign a role to a user",
request_model=AssignRoleRequest,
response_model=StatusResponse,
error_codes=[400, 404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/users/{user_id}/roles/{role_id}",
"DELETE",
"Unassign a role from a user",
response_model=StatusResponse,
error_codes=[404],
tags=["Admin"],
),
# --- Governance: Orgs ---
EndpointSpec(
"/v1/api/admin/orgs",
"GET",
"List organizations",
response_model=ListOrgsResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/orgs/{org_id}",
"GET",
"Get organization details",
response_model=OrgInfo,
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/orgs/{org_id}",
"PUT",
"Update organization settings",
request_model=UpdateOrgRequest,
response_model=OrgInfo,
error_codes=[404],
tags=["Admin"],
),
# --- Governance: Tool Policies ---
EndpointSpec(
"/v1/api/admin/policies",
"GET",
"List tool policies",
response_model=ListToolPoliciesResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/policies",
"POST",
"Create a tool policy",
request_model=CreateToolPolicyRequest,
response_model=ToolPolicyInfo,
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/policies/{policy_id}",
"PUT",
"Update a tool policy",
request_model=UpdateToolPolicyRequest,
response_model=ToolPolicyInfo,
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/policies/{policy_id}",
"DELETE",
"Delete a tool policy",
response_model=StatusResponse,
error_codes=[404],
tags=["Admin"],
),
# --- Governance: Prompt Templates ---
EndpointSpec(
"/v1/api/admin/templates",
"GET",
"List prompt templates",
response_model=ListPromptTemplatesResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/templates",
"POST",
"Create a prompt template",
request_model=CreatePromptTemplateRequest,
response_model=PromptTemplateInfo,
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/templates/{template_id}",
"PUT",
"Update a prompt template",
request_model=UpdatePromptTemplateRequest,
response_model=PromptTemplateInfo,
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/templates/{template_id}",
"DELETE",
"Delete a prompt template",
response_model=StatusResponse,
error_codes=[404],
tags=["Admin"],
),
# --- Governance: Usage & Audit ---
EndpointSpec(
"/v1/api/admin/usage",
"GET",
"Aggregated usage data",
response_model=UsageResponse,
query_params=[
QueryParam("since", "Start timestamp (ISO8601, defaults to last 7 days)"),
QueryParam("until", "End timestamp (ISO8601)"),
QueryParam("user_id", "Filter by user"),
QueryParam("model", "Filter by model"),
QueryParam(
"group_by",
"Group results",
enum=["day", "hour", "model", "user"],
),
],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/audit",
"GET",
"Paginated audit events",
response_model=ListAuditEventsResponse,
query_params=[
QueryParam("action", "Filter by action type"),
QueryParam("user_id", "Filter by user"),
QueryParam("since", "Start timestamp (ISO8601)"),
QueryParam("until", "End timestamp (ISO8601)"),
QueryParam("limit", "Page size", schema_type="integer", default=50),
QueryParam("offset", "Pagination offset", schema_type="integer", default=0),
],
tags=["Admin"],
),
# --- Observability ---
EndpointSpec(
"/health",
@@ -289,6 +496,28 @@ _ALL_MODELS: list[type[BaseModel]] = [
ScheduleInfo,
ListSchedulesResponse,
ListScheduleRunsResponse,
RoleInfo,
CreateRoleRequest,
UpdateRoleRequest,
ListRolesResponse,
AssignRoleRequest,
UserRoleInfo,
ListUserRolesResponse,
OrgInfo,
UpdateOrgRequest,
ListOrgsResponse,
ToolPolicyInfo,
CreateToolPolicyRequest,
UpdateToolPolicyRequest,
ListToolPoliciesResponse,
PromptTemplateInfo,
CreatePromptTemplateRequest,
UpdatePromptTemplateRequest,
ListPromptTemplatesResponse,
UsageBreakdownItem,
UsageResponse,
AuditEventInfo,
ListAuditEventsResponse,
]
+4
View File
@@ -35,6 +35,10 @@ class CommandRequest(BaseModel):
ws_id: str = Field(description="Target workstream ID")
class CancelRequest(BaseModel):
ws_id: str = Field(description="Target workstream ID")
class CreateWorkstreamRequest(BaseModel):
name: str = Field(default="", description="Workstream display name (auto-generated if empty)")
model: str = Field(default="", description="Model alias from registry")
+11
View File
@@ -19,6 +19,7 @@ from turnstone.api.schemas import (
)
from turnstone.api.server_schemas import (
ApproveRequest,
CancelRequest,
CloseWorkstreamRequest,
CommandRequest,
CreateWorkstreamRequest,
@@ -103,6 +104,15 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
error_codes=[400, 404],
tags=["Chat"],
),
EndpointSpec(
"/v1/api/cancel",
"POST",
"Cancel the active generation in a workstream",
request_model=CancelRequest,
response_model=StatusResponse,
error_codes=[400, 404],
tags=["Chat"],
),
# --- Streaming ---
EndpointSpec(
"/v1/api/events",
@@ -186,6 +196,7 @@ _ALL_MODELS: list[type[BaseModel]] = [
ApproveRequest,
PlanFeedbackRequest,
CommandRequest,
CancelRequest,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
CloseWorkstreamRequest,
+12
View File
@@ -112,6 +112,18 @@ class TaskScheduler:
pruned = self._storage.prune_task_runs(retention_days=90)
if pruned:
log.info("scheduler.pruned_runs", count=pruned)
try:
usage_pruned = self._storage.prune_usage_events(retention_days=90)
if usage_pruned:
log.info("scheduler.pruned_usage", count=usage_pruned)
except Exception:
log.warning("scheduler.prune_usage_error", exc_info=True)
try:
audit_pruned = self._storage.prune_audit_events(retention_days=365)
if audit_pruned:
log.info("scheduler.pruned_audit", count=audit_pruned)
except Exception:
log.warning("scheduler.prune_audit_error", exc_info=True)
finally:
# Only release our own lock (safe even if TTL expired and another took it)
self._broker._redis.eval( # type: ignore[no-untyped-call]
File diff suppressed because it is too large Load Diff
+120 -12
View File
@@ -29,11 +29,62 @@ function showAdmin() {
document.getElementById("breadcrumb-label").textContent = "Admin";
document.getElementById("main").scrollTop = 0;
history.pushState({ view: "admin" }, "");
loadAdminUsers();
// Permission gating: hide tabs the user cannot access
var perms = sessionStorage.getItem("turnstone_permissions") || "";
var tabPerms = {
users: "admin.users",
tokens: "admin.users",
channels: "admin.users",
schedules: "admin.schedules",
watches: "admin.watches",
roles: "admin.roles",
policies: "admin.policies",
templates: "admin.templates",
usage: "admin.usage",
audit: "admin.audit",
};
if (perms) {
var permSet = perms.split(",");
var tabs = document.querySelectorAll(".admin-tab");
for (var i = 0; i < tabs.length; i++) {
var tabName = tabs[i].getAttribute("data-tab");
var needed = tabPerms[tabName];
if (needed && permSet.indexOf(needed) < 0) {
tabs[i].style.display = "none";
} else {
tabs[i].style.display = "";
}
}
}
// Switch to the first visible tab
var visibleTabs = document.querySelectorAll(
'.admin-tab:not([style*="display: none"])',
);
if (visibleTabs.length > 0) {
switchAdminTab(visibleTabs[0].getAttribute("data-tab"));
} else {
// No tabs visible — show empty state instead of loading an inaccessible tab
var panels = document.querySelectorAll(".admin-panel");
for (var j = 0; j < panels.length; j++) panels[j].style.display = "none";
var empty = document.getElementById("admin-no-permissions");
if (!empty) {
empty = document.createElement("div");
empty.id = "admin-no-permissions";
empty.className = "dashboard-empty";
empty.textContent = "You do not have permissions to view any admin tabs.";
document.getElementById("view-admin").appendChild(empty);
}
empty.style.display = "";
}
}
function switchAdminTab(tab) {
_adminTab = tab;
// Hide no-permissions empty state if it was showing
var noPerms = document.getElementById("admin-no-permissions");
if (noPerms) noPerms.style.display = "none";
var tabs = document.querySelectorAll(".admin-tab");
for (var i = 0; i < tabs.length; i++) {
var isActive = tabs[i].getAttribute("data-tab") === tab;
@@ -41,22 +92,36 @@ function switchAdminTab(tab) {
tabs[i].setAttribute("aria-selected", isActive ? "true" : "false");
tabs[i].setAttribute("tabindex", isActive ? "0" : "-1");
}
document.getElementById("admin-users").style.display =
tab === "users" ? "" : "none";
document.getElementById("admin-tokens").style.display =
tab === "tokens" ? "" : "none";
document.getElementById("admin-channels").style.display =
tab === "channels" ? "" : "none";
document.getElementById("admin-schedules").style.display =
tab === "schedules" ? "" : "none";
document.getElementById("admin-watches").style.display =
tab === "watches" ? "" : "none";
var panels = [
"users",
"tokens",
"channels",
"schedules",
"watches",
"roles",
"policies",
"templates",
"usage",
"audit",
];
for (var p = 0; p < panels.length; p++) {
var el = document.getElementById("admin-" + panels[p]);
if (el) el.style.display = panels[p] === tab ? "" : "none";
}
if (tab === "users") loadAdminUsers();
if (tab === "tokens") _populateTokenUserSelect();
if (tab === "channels") _populateChannelUserSelect();
if (tab === "schedules") loadAdminSchedules();
if (tab === "watches") loadAdminWatches();
if (tab === "roles") loadGovRoles();
if (tab === "policies") loadGovPolicies();
if (tab === "templates") loadGovTemplates();
if (tab === "usage") loadGovUsage();
if (tab === "audit") {
_populateAuditUserFilter();
loadGovAudit();
}
}
// ---------------------------------------------------------------------------
@@ -102,6 +167,9 @@ function _renderUsers(users) {
escapeHtml(u.created || "").slice(0, 10) +
"</span>" +
'<span class="admin-col admin-col-actions">' +
'<button class="admin-btn-action" data-user-roles="' +
escapeHtml(u.user_id) +
'" title="Manage roles">roles</button>' +
'<button class="admin-btn-danger" data-delete-user="' +
escapeHtml(u.user_id) +
'" data-username="' +
@@ -111,6 +179,13 @@ function _renderUsers(users) {
"</div>";
}
container.innerHTML = html;
// Bind roles buttons
var roleBtns = container.querySelectorAll("[data-user-roles]");
for (var rj = 0; rj < roleBtns.length; rj++) {
roleBtns[rj].addEventListener("click", function () {
showUserRolesModal(this.getAttribute("data-user-roles"));
});
}
// Bind delete buttons via delegation (avoids inline JS injection)
var btns = container.querySelectorAll("[data-delete-user]");
for (var j = 0; j < btns.length; j++) {
@@ -1362,6 +1437,14 @@ function _installTrap(overlayId, boxId, trapRef) {
else if (overlayId === "edit-schedule-overlay") hideEditScheduleModal();
else if (overlayId === "schedule-runs-overlay") hideScheduleRunsModal();
else if (overlayId === "confirm-overlay") hideConfirmModal();
else if (overlayId === "create-role-overlay") hideCreateRoleModal();
else if (overlayId === "edit-role-overlay") hideEditRoleModal();
else if (overlayId === "user-roles-overlay") hideUserRolesModal();
else if (overlayId === "create-policy-overlay") hideCreatePolicyModal();
else if (overlayId === "edit-policy-overlay") hideEditPolicyModal();
else if (overlayId === "create-template-overlay")
hideCreateTemplateModal();
else if (overlayId === "edit-template-overlay") hideEditTemplateModal();
}
};
}
@@ -1428,6 +1511,24 @@ document.addEventListener("keydown", function (e) {
hideConfirmModal();
return;
}
// Governance modals
var govOverlays = [
["create-role-overlay", hideCreateRoleModal],
["edit-role-overlay", hideEditRoleModal],
["user-roles-overlay", hideUserRolesModal],
["create-policy-overlay", hideCreatePolicyModal],
["edit-policy-overlay", hideEditPolicyModal],
["create-template-overlay", hideCreateTemplateModal],
["edit-template-overlay", hideEditTemplateModal],
];
for (var gi = 0; gi < govOverlays.length; gi++) {
var govEl = document.getElementById(govOverlays[gi][0]);
if (govEl && govEl.style.display !== "none") {
e.preventDefault();
govOverlays[gi][1]();
return;
}
}
});
// Tab arrow key navigation
@@ -1436,7 +1537,14 @@ document.addEventListener("keydown", function (e) {
if (!tablist) return;
tablist.addEventListener("keydown", function (e) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
var tabOrder = ["users", "tokens", "channels", "schedules", "watches"];
var allTabs = document.querySelectorAll(
'.admin-tab:not([style*="display: none"])',
);
var tabOrder = [];
for (var ti = 0; ti < allTabs.length; ti++) {
tabOrder.push(allTabs[ti].getAttribute("data-tab"));
}
if (tabOrder.length === 0) return;
var idx = tabOrder.indexOf(_adminTab);
if (e.key === "ArrowRight") idx = (idx + 1) % tabOrder.length;
else idx = (idx - 1 + tabOrder.length) % tabOrder.length;
File diff suppressed because it is too large Load Diff
+272
View File
@@ -82,6 +82,11 @@
<button id="tab-channels" class="admin-tab" data-tab="channels" role="tab" aria-selected="false" aria-controls="admin-channels" tabindex="-1" onclick="switchAdminTab('channels')">Channels</button>
<button id="tab-schedules" class="admin-tab" data-tab="schedules" role="tab" aria-selected="false" aria-controls="admin-schedules" tabindex="-1" onclick="switchAdminTab('schedules')">Schedules</button>
<button id="tab-watches" class="admin-tab" data-tab="watches" role="tab" aria-selected="false" aria-controls="admin-watches" tabindex="-1" onclick="switchAdminTab('watches')">Watches</button>
<button id="tab-roles" class="admin-tab" data-tab="roles" role="tab" aria-selected="false" aria-controls="admin-roles" tabindex="-1" onclick="switchAdminTab('roles')">Roles</button>
<button id="tab-policies" class="admin-tab" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
<button id="tab-templates" class="admin-tab" data-tab="templates" role="tab" aria-selected="false" aria-controls="admin-templates" tabindex="-1" onclick="switchAdminTab('templates')">Templates</button>
<button id="tab-usage" class="admin-tab" data-tab="usage" role="tab" aria-selected="false" aria-controls="admin-usage" tabindex="-1" onclick="switchAdminTab('usage')">Usage</button>
<button id="tab-audit" class="admin-tab" data-tab="audit" role="tab" aria-selected="false" aria-controls="admin-audit" tabindex="-1" onclick="switchAdminTab('audit')">Audit</button>
</div>
<!-- Users Tab -->
@@ -188,6 +193,118 @@
<div class="dashboard-empty">Loading watches...</div>
</div>
</div>
<!-- Roles Tab -->
<div id="admin-roles" class="admin-panel" role="tabpanel" aria-labelledby="tab-roles" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">ROLES</span>
<button class="admin-action-btn" onclick="showCreateRoleModal()">+ Create role</button>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-rname">NAME</span>
<span class="admin-col admin-col-rperms">PERMISSIONS</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-roles-table" role="list" aria-label="Roles" aria-live="polite">
<div class="dashboard-empty">Loading roles...</div>
</div>
</div>
<!-- Policies Tab -->
<div id="admin-policies" class="admin-panel" role="tabpanel" aria-labelledby="tab-policies" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">TOOL POLICIES</span>
<button class="admin-action-btn" onclick="showCreatePolicyModal()">+ Create policy</button>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-pname">NAME</span>
<span class="admin-col admin-col-ppattern">PATTERN</span>
<span class="admin-col admin-col-paction">ACTION</span>
<span class="admin-col admin-col-ppriority">PRI</span>
<span class="admin-col admin-col-pstatus">STATUS</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-policies-table" role="list" aria-label="Tool policies" aria-live="polite">
<div class="dashboard-empty">Loading policies...</div>
</div>
</div>
<!-- Templates Tab -->
<div id="admin-templates" class="admin-panel" role="tabpanel" aria-labelledby="tab-templates" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">PROMPT TEMPLATES</span>
<button class="admin-action-btn" onclick="showCreateTemplateModal()">+ Create template</button>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-tmname">NAME</span>
<span class="admin-col admin-col-tmcat">CATEGORY</span>
<span class="admin-col admin-col-tmvars">VARIABLES</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-templates-table" role="list" aria-label="Prompt templates" aria-live="polite">
<div class="dashboard-empty">Loading templates...</div>
</div>
</div>
<!-- Usage Tab -->
<div id="admin-usage" class="admin-panel" role="tabpanel" aria-labelledby="tab-usage" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">USAGE</span>
<div class="usage-range-group" role="group" aria-label="Time range">
<button class="usage-range-btn" data-range="24h" aria-pressed="false" onclick="setUsageRange('24h')">24h</button>
<button class="usage-range-btn active" data-range="7d" aria-pressed="true" onclick="setUsageRange('7d')">7d</button>
<button class="usage-range-btn" data-range="30d" aria-pressed="false" onclick="setUsageRange('30d')">30d</button>
</div>
<div class="usage-range-group" role="group" aria-label="Group by">
<button class="usage-group-btn active" data-group="day" aria-pressed="true" onclick="setUsageGroupBy('day')">day</button>
<button class="usage-group-btn" data-group="model" aria-pressed="false" onclick="setUsageGroupBy('model')">model</button>
<button class="usage-group-btn" data-group="user" aria-pressed="false" onclick="setUsageGroupBy('user')">user</button>
</div>
</div>
<div id="admin-usage-content">
<div class="dashboard-empty">Loading usage data...</div>
</div>
</div>
<!-- Audit Tab -->
<div id="admin-audit" class="admin-panel" role="tabpanel" aria-labelledby="tab-audit" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">AUDIT LOG</span>
<label for="audit-action-filter" class="sr-only">Filter by action</label>
<select id="audit-action-filter" onchange="loadGovAudit()">
<option value="">All actions</option>
<option value="user.create">user.create</option>
<option value="user.delete">user.delete</option>
<option value="token.create">token.create</option>
<option value="token.revoke">token.revoke</option>
<option value="role.create">role.create</option>
<option value="role.update">role.update</option>
<option value="role.delete">role.delete</option>
<option value="role.assign">role.assign</option>
<option value="role.unassign">role.unassign</option>
<option value="policy.create">policy.create</option>
<option value="policy.update">policy.update</option>
<option value="policy.delete">policy.delete</option>
<option value="template.create">template.create</option>
<option value="template.update">template.update</option>
<option value="template.delete">template.delete</option>
</select>
<label for="audit-user-filter" class="sr-only">Filter by user</label>
<select id="audit-user-filter" onchange="loadGovAudit()">
<option value="">All users</option>
</select>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-atime">TIME</span>
<span class="admin-col admin-col-auser">USER</span>
<span class="admin-col admin-col-aaction">ACTION</span>
<span class="admin-col admin-col-aresource">RESOURCE</span>
<span class="admin-col admin-col-adetail">DETAIL</span>
</div>
<div id="admin-audit-table" role="list" aria-label="Audit events" aria-live="polite">
<div class="dashboard-empty">Loading audit log...</div>
</div>
</div>
</div>
</div>
@@ -434,7 +551,162 @@ window.TURNSTONE_KB_SHORTCUTS = [
</div>
</div>
<!-- Create Role Modal -->
<div id="create-role-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-role-title">
<div id="create-role-box" class="admin-modal admin-modal-wide">
<h2 id="create-role-title">Create Role</h2>
<div id="create-role-error" role="alert" aria-live="assertive"></div>
<label for="cr-name">Name</label>
<input id="cr-name" type="text" placeholder="e.g. security-reviewer" autocomplete="off" spellcheck="false">
<label for="cr-displayname">Display name</label>
<input id="cr-displayname" type="text" placeholder="Security Reviewer" autocomplete="off">
<fieldset class="perm-fieldset"><legend>Permissions</legend>
<div id="cr-perms-container" role="group" aria-label="Permissions"></div>
</fieldset>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateRoleModal()">Cancel</button>
<button id="cr-submit" class="modal-submit" onclick="submitCreateRole()">Create</button>
</div>
</div>
</div>
<!-- Edit Role Modal -->
<div id="edit-role-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-role-title">
<div id="edit-role-box" class="admin-modal admin-modal-wide">
<h2 id="edit-role-title">Edit Role</h2>
<div id="edit-role-error" role="alert" aria-live="assertive"></div>
<input id="er-id" type="hidden">
<label for="er-name">Display name</label>
<input id="er-name" type="text" autocomplete="off">
<fieldset class="perm-fieldset"><legend>Permissions</legend>
<div id="er-perms-container" role="group" aria-label="Permissions"></div>
</fieldset>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideEditRoleModal()">Cancel</button>
<button id="er-submit" class="modal-submit" onclick="submitEditRole()">Save</button>
</div>
</div>
</div>
<!-- User Roles Modal -->
<div id="user-roles-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="user-roles-title">
<div id="user-roles-box" class="admin-modal">
<h2 id="user-roles-title">Assign Roles</h2>
<div id="user-roles-error" role="alert" aria-live="assertive"></div>
<input id="ur-user-id" type="hidden">
<div id="ur-roles-container"></div>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideUserRolesModal()">Cancel</button>
<button class="modal-submit" onclick="submitUserRoles()">Save</button>
</div>
</div>
</div>
<!-- Create Policy Modal -->
<div id="create-policy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-policy-title">
<div id="create-policy-box" class="admin-modal">
<h2 id="create-policy-title">Create Tool Policy</h2>
<div id="create-policy-error" role="alert" aria-live="assertive"></div>
<label for="cp-name">Name</label>
<input id="cp-name" type="text" placeholder="e.g. Block shell access" autocomplete="off">
<label for="cp-pattern">Tool pattern <span class="label-hint">glob syntax: bash*, file_write, *</span></label>
<input id="cp-pattern" type="text" placeholder="bash*" autocomplete="off" spellcheck="false">
<label for="cp-action">Action</label>
<select id="cp-action">
<option value="ask">Ask (require approval)</option>
<option value="allow">Allow (auto-approve)</option>
<option value="deny">Deny (block)</option>
</select>
<label for="cp-priority">Priority <span class="label-hint">higher = evaluated first</span></label>
<input id="cp-priority" type="number" value="0" min="0" max="9999">
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreatePolicyModal()">Cancel</button>
<button id="cp-submit" class="modal-submit" onclick="submitCreatePolicy()">Create</button>
</div>
</div>
</div>
<!-- Edit Policy Modal -->
<div id="edit-policy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-policy-title">
<div id="edit-policy-box" class="admin-modal">
<h2 id="edit-policy-title">Edit Tool Policy</h2>
<div id="edit-policy-error" role="alert" aria-live="assertive"></div>
<input id="ep-id" type="hidden">
<label for="ep-name">Name</label>
<input id="ep-name" type="text" autocomplete="off">
<label for="ep-pattern">Tool pattern</label>
<input id="ep-pattern" type="text" autocomplete="off" spellcheck="false">
<label for="ep-action">Action</label>
<select id="ep-action">
<option value="ask">Ask (require approval)</option>
<option value="allow">Allow (auto-approve)</option>
<option value="deny">Deny (block)</option>
</select>
<label for="ep-priority">Priority</label>
<input id="ep-priority" type="number" value="0" min="0" max="9999">
<label class="admin-checkbox"><input id="ep-enabled" type="checkbox" checked> Enabled</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideEditPolicyModal()">Cancel</button>
<button id="ep-submit" class="modal-submit" onclick="submitEditPolicy()">Save</button>
</div>
</div>
</div>
<!-- Create Template Modal -->
<div id="create-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-template-title">
<div id="create-template-box" class="admin-modal admin-modal-wide">
<h2 id="create-template-title">Create Prompt Template</h2>
<div id="create-template-error" role="alert" aria-live="assertive"></div>
<label for="ctm-name">Name</label>
<input id="ctm-name" type="text" placeholder="e.g. Code Review Agent" autocomplete="off">
<label for="ctm-category">Category</label>
<select id="ctm-category">
<option value="general">General</option>
<option value="engineering">Engineering</option>
<option value="support">Support</option>
<option value="custom">Custom</option>
</select>
<label for="ctm-content">Content <span class="label-hint">system message text, use {{variable}} for placeholders</span></label>
<textarea id="ctm-content" rows="6" placeholder="You are a helpful assistant for {{project_name}}..."></textarea>
<label for="ctm-variables">Variables <span class="label-hint">comma-separated list</span></label>
<input id="ctm-variables" type="text" placeholder="project_name, review_focus" autocomplete="off">
<label class="admin-checkbox"><input id="ctm-default" type="checkbox"> Set as default for new workstreams</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateTemplateModal()">Cancel</button>
<button id="ctm-submit" class="modal-submit" onclick="submitCreateTemplate()">Create</button>
</div>
</div>
</div>
<!-- Edit Template Modal -->
<div id="edit-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-template-title">
<div id="edit-template-box" class="admin-modal admin-modal-wide">
<h2 id="edit-template-title">Edit Prompt Template</h2>
<div id="edit-template-error" role="alert" aria-live="assertive"></div>
<input id="etm-id" type="hidden">
<label for="etm-name">Name</label>
<input id="etm-name" type="text" autocomplete="off">
<label for="etm-category">Category</label>
<select id="etm-category">
<option value="general">General</option>
<option value="engineering">Engineering</option>
<option value="support">Support</option>
<option value="custom">Custom</option>
</select>
<label for="etm-content">Content</label>
<textarea id="etm-content" rows="6"></textarea>
<label for="etm-variables">Variables</label>
<input id="etm-variables" type="text" autocomplete="off">
<label class="admin-checkbox"><input id="etm-default" type="checkbox"> Set as default</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideEditTemplateModal()">Cancel</button>
<button id="etm-submit" class="modal-submit" onclick="submitEditTemplate()">Save</button>
</div>
</div>
</div>
<script src="/static/admin.js"></script>
<script src="/static/governance.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+266 -1
View File
@@ -709,6 +709,11 @@
color: var(--accent);
border-bottom-color: var(--accent);
}
.admin-tab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
border-radius: var(--radius-sm);
}
.admin-toolbar {
display: flex;
@@ -989,7 +994,10 @@
.modal-submit:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
#create-user-overlay, #create-token-overlay, #token-created-overlay, #create-channel-overlay, #confirm-overlay,
#create-schedule-overlay, #edit-schedule-overlay, #schedule-runs-overlay {
#create-schedule-overlay, #edit-schedule-overlay, #schedule-runs-overlay,
#create-role-overlay, #edit-role-overlay, #user-roles-overlay,
#create-policy-overlay, #edit-policy-overlay,
#create-template-overlay, #edit-template-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
@@ -1043,6 +1051,263 @@
.admin-col-wcmd, .admin-col-wcond, .admin-col-winterval { display: none; }
}
/* ==========================================================================
Admin tabs horizontal scroll for 10+ tabs
========================================================================== */
.admin-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
scrollbar-width: thin;
}
/* ==========================================================================
Governance: Roles grid
========================================================================== */
#admin-roles .admin-colheaders,
#admin-roles .admin-row {
grid-template-columns: 160px 1fr 110px;
}
/* ==========================================================================
Governance: Tool Policies grid
========================================================================== */
#admin-policies .admin-colheaders,
#admin-policies .admin-row {
grid-template-columns: 1.2fr 1fr 70px 50px 80px 140px;
}
/* Policy action badges */
.policy-badge {
display: inline-block;
font-family: var(--font-display);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 2px;
}
.policy-allow {
color: var(--green);
background: var(--green-glow);
border: 1px solid var(--green-glow);
}
.policy-deny {
color: var(--red);
background: var(--red-glow);
border: 1px solid var(--red-glow);
}
.policy-ask {
color: var(--yellow);
background: var(--yellow-glow);
border: 1px solid var(--yellow-glow);
}
/* ==========================================================================
Governance: Prompt Templates grid
========================================================================== */
#admin-templates .admin-colheaders,
#admin-templates .admin-row {
grid-template-columns: 1.5fr 100px 1fr 140px;
}
/* ==========================================================================
Governance: Audit grid
========================================================================== */
#admin-audit .admin-colheaders,
#admin-audit .admin-row {
grid-template-columns: 80px 80px 1fr 120px 1.5fr;
}
/* Audit action badges */
.audit-badge {
display: inline-block;
font-family: var(--font-display);
font-size: 9px;
font-weight: 600;
text-transform: none;
letter-spacing: 0.02em;
padding: 1px 6px;
border-radius: 2px;
background: var(--bg-highlight);
color: var(--fg-dim);
border: 1px solid var(--border);
}
.audit-danger { color: var(--red); border-color: var(--red-glow); }
.audit-success { color: var(--green); border-color: var(--green-glow); }
/* ==========================================================================
Governance: Usage dashboard
========================================================================== */
.usage-summary {
display: flex;
gap: 24px;
padding: 16px 0 20px;
flex-wrap: wrap;
}
.usage-readout {
display: flex;
flex-direction: column;
gap: 2px;
}
.usage-readout-value {
font-size: 22px;
font-weight: 600;
color: var(--fg-bright);
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
letter-spacing: -0.02em;
}
.usage-readout-label {
font-size: 10px;
font-family: var(--font-display);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
}
/* Usage bar chart */
.usage-chart { padding-top: 4px; }
.usage-bar-row {
display: grid;
grid-template-columns: 90px 1fr 60px;
align-items: center;
gap: 10px;
padding: 4px 0;
}
.usage-bar-label {
font-size: 11px;
color: var(--fg-dim);
text-align: right;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-bar-track {
height: 16px;
background: var(--bg-highlight);
border-radius: 2px;
overflow: hidden;
}
.usage-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
min-width: 2px;
transition: width 0.3s ease;
box-shadow: 0 0 6px var(--accent-glow);
}
.usage-bar-value {
font-size: 11px;
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Usage range/group buttons */
.usage-range-group {
display: flex;
gap: 2px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
overflow: hidden;
}
.usage-range-btn, .usage-group-btn {
background: var(--bg);
color: var(--fg-dim);
border: none;
font-family: var(--font-display);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 5px 10px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.usage-range-btn:hover, .usage-group-btn:hover {
background: var(--bg-highlight);
color: var(--fg);
}
.usage-range-btn.active, .usage-group-btn.active {
background: var(--accent-dim);
color: var(--accent);
}
.usage-range-btn:focus-visible, .usage-group-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* ==========================================================================
Governance: Permission grid (modal checkboxes)
========================================================================== */
.perm-fieldset {
border: none;
padding: 0;
margin: 12px 0 0;
}
.perm-fieldset legend {
font-family: var(--font-display);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
padding: 0;
margin-bottom: 4px;
}
.perm-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 16px;
padding: 8px 0;
}
.perm-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--fg);
padding: 3px 0;
cursor: pointer;
text-transform: none;
letter-spacing: normal;
}
.perm-checkbox input[type="checkbox"] {
width: auto;
margin: 0;
accent-color: var(--accent);
}
/* ==========================================================================
Governance: Responsive
========================================================================== */
@media (max-width: 700px) {
#admin-roles .admin-colheaders, #admin-roles .admin-row {
grid-template-columns: 1fr 100px;
}
.admin-col-rperms { display: none; }
#admin-policies .admin-colheaders, #admin-policies .admin-row {
grid-template-columns: 1fr 70px 50px 100px;
}
.admin-col-pstatus, .admin-col-ppriority { display: none; }
#admin-templates .admin-colheaders, #admin-templates .admin-row {
grid-template-columns: 1fr 100px;
}
.admin-col-tmcat, .admin-col-tmvars { display: none; }
#admin-audit .admin-colheaders, #admin-audit .admin-row {
grid-template-columns: 60px 1fr 100px;
}
.admin-col-auser, .admin-col-adetail { display: none; }
.usage-readout-value { font-size: 18px; }
.usage-bar-row { grid-template-columns: 70px 1fr 50px; }
.perm-grid { grid-template-columns: 1fr; }
}
/* ==========================================================================
Reduced motion console-specific
========================================================================== */
+41
View File
@@ -0,0 +1,41 @@
"""Audit event recording helper.
Provides a fire-and-forget ``record_audit`` function that admin handlers
call after mutations to create a persistent audit trail.
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from turnstone.core.storage._protocol import StorageBackend
log = logging.getLogger(__name__)
def record_audit(
storage: StorageBackend,
user_id: str,
action: str,
resource_type: str = "",
resource_id: str = "",
detail: dict[str, Any] | None = None,
ip_address: str = "",
) -> None:
"""Record an audit event. Silently logs on failure (never raises)."""
try:
storage.record_audit_event(
event_id=uuid.uuid4().hex,
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
detail=json.dumps(detail) if detail else "{}",
ip_address=ip_address,
)
except Exception:
log.warning("Failed to record audit event: %s %s", action, resource_id, exc_info=True)
+110 -4
View File
@@ -18,6 +18,7 @@ always accessible without authentication.
from __future__ import annotations
import contextlib
import hashlib
import hmac
import json
@@ -33,7 +34,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp, Receive, Scope, Send
log = logging.getLogger(__name__)
@@ -80,6 +81,60 @@ _ROLE_TO_SCOPES: dict[str, frozenset[str]] = {
"full": frozenset({"read", "write", "approve"}),
}
# ---------------------------------------------------------------------------
# RBAC helpers
# ---------------------------------------------------------------------------
def _load_user_permissions(storage: Any, user_id: str) -> set[str]:
"""Load the union of all permissions from a user's assigned roles."""
try:
result: set[str] = storage.get_user_permissions(user_id)
return result
except Exception:
log.warning("Failed to load permissions for user %s", user_id)
return set()
def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
"""Derive legacy scopes from a granular permission set."""
scopes: set[str] = set()
if not permissions:
scopes.add("read")
return frozenset(scopes)
for perm in permissions:
if perm in VALID_SCOPES:
scopes.update(SCOPE_HIERARCHY.get(perm, {perm}))
# Any admin.* permission requires access to admin endpoints → approve scope
if any(p.startswith("admin.") for p in permissions):
scopes.update(SCOPE_HIERARCHY["approve"])
if not scopes:
scopes.add("read")
return frozenset(scopes)
def require_permission(request: Request, permission: str) -> JSONResponse | None:
"""Return a 403 JSONResponse if the user lacks *permission*, else None.
Call from admin handlers after the middleware scope check passes.
Config-file tokens (no user_id) are treated as full-access.
"""
from starlette.responses import JSONResponse
auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
if auth_result is None:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
# Config-file tokens (no user_id) are treated as full-access
if not auth_result.user_id:
return None
if auth_result.has_permission(permission):
return None
return JSONResponse(
{"error": f"Forbidden: missing '{permission}' permission"},
status_code=403,
)
# ---------------------------------------------------------------------------
# Path classification
# ---------------------------------------------------------------------------
@@ -104,6 +159,7 @@ WRITE_PATHS: frozenset[str] = frozenset(
"/api/send",
"/api/plan",
"/api/command",
"/api/cancel",
"/api/workstreams/new",
"/api/workstreams/close",
"/api/cluster/workstreams/new",
@@ -133,11 +189,16 @@ class AuthResult:
user_id: str # empty string for config-file tokens
scopes: frozenset[str]
token_source: str # "config", "jwt", "database"
permissions: frozenset[str] = frozenset()
def has_scope(self, scope: str) -> bool:
"""Return True if this result includes *scope*."""
return scope in self.scopes
def has_permission(self, permission: str) -> bool:
"""Return True if this result includes *permission*."""
return permission in self.permissions
# ---------------------------------------------------------------------------
# AuthConfig (unchanged from before — static config-file tokens)
@@ -249,8 +310,9 @@ def create_jwt(
secret: str,
expiry_hours: int = 24,
audience: str = "",
permissions: frozenset[str] = frozenset(),
) -> str:
"""Create a signed JWT with user identity and scopes."""
"""Create a signed JWT with user identity, scopes, and permissions."""
import jwt
now = int(time.time())
@@ -264,6 +326,8 @@ def create_jwt(
}
if audience:
payload["aud"] = audience
if permissions:
payload["permissions"] = ",".join(sorted(permissions))
return jwt.encode(payload, secret, algorithm="HS256")
@@ -293,11 +357,15 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
user_id = payload.get("sub", "")
scopes_str = payload.get("scopes", "")
source = payload.get("src", "jwt")
perms_str = payload.get("permissions", "")
perms = frozenset(p for p in perms_str.split(",") if p) if perms_str else frozenset()
return AuthResult(
user_id=user_id,
scopes=parse_scopes(scopes_str),
token_source=source,
permissions=perms,
)
@@ -531,10 +599,12 @@ def _authenticate_api_token(token: str, storage: Any) -> AuthResult | None:
if exp_dt < now:
return None
perms = _load_user_permissions(storage, row["user_id"]) if storage else set()
return AuthResult(
user_id=row["user_id"],
scopes=parse_scopes(row["scopes"]),
token_source="database",
permissions=frozenset(perms),
)
@@ -835,10 +905,14 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
if username and password and storage is not None:
user = storage.get_user_by_username(username)
if user and verify_password(password, user["password_hash"]):
# Derive scopes and permissions from assigned roles
perms = _load_user_permissions(storage, user["user_id"])
scopes = _permissions_to_scopes(perms)
result = AuthResult(
user_id=user["user_id"],
scopes=frozenset({"read", "write", "approve"}),
scopes=scopes,
token_source="password",
permissions=frozenset(perms),
)
elif body.get("token"):
result = _authenticate_token(
@@ -865,11 +939,14 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
source=result.token_source,
secret=jwt_secret,
audience=audience,
permissions=result.permissions,
)
role = "full" if result.has_scope("write") else "read"
scopes_str = ",".join(sorted(result.scopes))
resp_body: dict[str, str] = {"status": "ok", "role": role, "scopes": scopes_str}
if result.permissions:
resp_body["permissions"] = ",".join(sorted(result.permissions))
if jwt_token:
resp_body["jwt"] = jwt_token
if result.user_id:
@@ -959,7 +1036,33 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
if not created:
return JSONResponse({"error": "Setup already completed"}, status_code=409)
scopes = frozenset({"read", "write", "approve"})
# Assign admin role to the first user — fail setup if this breaks,
# otherwise the admin is created with read-only access and locked out.
try:
storage.assign_role(user_id, "builtin-admin", "")
except Exception:
log.error("Failed to assign admin role to first user %s — aborting setup", user_id)
# Roll back the user creation so setup can be retried
with contextlib.suppress(Exception):
storage.delete_user(user_id)
return JSONResponse(
{"error": "Failed to assign admin role. Ensure migrations have run."},
status_code=503,
)
# Derive permissions from roles
perms = _load_user_permissions(storage, user_id)
if not perms:
log.error(
"First user %s has no permissions after role assignment — aborting setup", user_id
)
with contextlib.suppress(Exception):
storage.delete_user(user_id)
return JSONResponse(
{"error": "Failed to load permissions. Ensure migrations have run."},
status_code=503,
)
scopes = _permissions_to_scopes(perms)
jwt_token = ""
if jwt_secret:
jwt_token = create_jwt(
@@ -968,6 +1071,7 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
source="password",
secret=jwt_secret,
audience=audience,
permissions=frozenset(perms),
)
resp_body: dict[str, str] = {
@@ -977,6 +1081,8 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
"role": "full",
"scopes": ",".join(sorted(scopes)),
}
if perms:
resp_body["permissions"] = ",".join(sorted(perms))
if jwt_token:
resp_body["jwt"] = jwt_token
+80
View File
@@ -0,0 +1,80 @@
"""Tool policy evaluation engine.
Evaluates tool calls against admin-defined policies to determine whether
a tool should be auto-allowed, denied, or require human approval.
"""
from __future__ import annotations
import fnmatch
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from turnstone.core.storage._protocol import StorageBackend
log = logging.getLogger(__name__)
def evaluate_tool_policy(
storage: StorageBackend,
tool_name: str,
org_id: str = "",
) -> str | None:
"""Check tool policies for *tool_name*.
Policies are evaluated in priority order (highest first). The first
matching policy wins.
Returns ``"allow"``, ``"deny"``, or ``"ask"`` if a policy matches,
or ``None`` if no policy matches (caller should fall through to the
default approval behaviour).
"""
try:
policies = storage.list_tool_policies(org_id=org_id)
except Exception:
log.warning("Failed to load tool policies", exc_info=True)
return None
for policy in policies:
if not policy.get("enabled", True):
continue
pattern = policy.get("tool_pattern", "")
if fnmatch.fnmatch(tool_name, pattern):
action: str = policy.get("action", "ask")
if action in ("allow", "deny", "ask"):
return action
log.warning("Unknown policy action %r for policy %s", action, policy.get("policy_id"))
return "ask"
return None
def evaluate_tool_policies_batch(
storage: StorageBackend,
tool_names: list[str],
org_id: str = "",
) -> dict[str, str | None]:
"""Evaluate policies for multiple tools at once (single DB query).
Returns a dict mapping each tool name to its policy result.
"""
try:
policies = storage.list_tool_policies(org_id=org_id)
except Exception:
log.warning("Failed to load tool policies", exc_info=True)
return {name: None for name in tool_names}
results: dict[str, str | None] = {}
for name in tool_names:
result = None
for policy in policies:
if not policy.get("enabled", True):
continue
pattern = policy.get("tool_pattern", "")
if fnmatch.fnmatch(name, pattern):
action = policy.get("action", "ask")
result = action if action in ("allow", "deny", "ask") else "ask"
break
results[name] = result
return results
+184 -85
View File
@@ -83,6 +83,19 @@ if TYPE_CHECKING:
StreamChunk,
)
# ---------------------------------------------------------------------------
# Cancellation support
# ---------------------------------------------------------------------------
class GenerationCancelled(BaseException):
"""Raised when generation is cancelled via ``ChatSession.cancel()``.
Subclasses ``BaseException`` so that broad ``except Exception`` handlers
in tool execution code do not accidentally swallow it.
"""
# Image extensions handled as vision content (SVG excluded — it's XML text)
_IMAGE_EXTENSIONS: frozenset[str] = frozenset(
{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".ico"}
@@ -227,6 +240,9 @@ class ChatSession:
self._watch_runner: Any = None # WatchRunner | None
self._watch_pending: queue.Queue[dict[str, Any]] = queue.Queue()
self._watch_dispatch_depth = 0
# Cooperative cancellation: set from outside to stop generation
self._cancel_event = threading.Event()
self._cancelled_partial_msg: dict[str, Any] | None = None
# MCP tool integration: merge external tools with built-in
self._mcp_client = mcp_client
self._mcp_refresh_cb: Any = None # Callable | None (avoid import)
@@ -715,15 +731,35 @@ class ChatSession:
assert last_err is not None # unreachable, but satisfies type checker
raise last_err
# -- Cancellation -------------------------------------------------------
def cancel(self) -> None:
"""Request cancellation of the current generation.
Thread-safe may be called from any thread (e.g. an HTTP handler)
while the worker thread is inside ``send()``.
"""
self._cancel_event.set()
def _check_cancelled(self) -> None:
"""Raise ``GenerationCancelled`` if cancellation has been requested."""
if self._cancel_event.is_set():
raise GenerationCancelled()
# -- Main generation loop ------------------------------------------------
def send(self, user_input: str) -> None:
"""Send user input and handle the response loop (including tool calls)."""
self._notify_count = 0
self._cancel_event.clear()
self._cancelled_partial_msg = None
self.messages.append({"role": "user", "content": user_input})
self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token)))
save_message(self._ws_id, "user", user_input)
try:
while True:
self._check_cancelled()
msgs = self._full_messages()
if self.debug:
@@ -851,6 +887,40 @@ class ChatSession:
if user_feedback:
self.messages.append({"role": "user", "content": user_feedback})
self._msg_tokens.append(max(1, int(len(user_feedback) / self._chars_per_token)))
except GenerationCancelled:
# Cooperative cancellation — preserve partial content if available.
if self._cancelled_partial_msg:
# _stream_response was interrupted — save partial assistant msg
msg = self._cancelled_partial_msg
self._cancelled_partial_msg = None
self.messages.append(msg)
tok_est = max(
1,
int(self._msg_char_count(msg) / self._chars_per_token),
)
self._msg_tokens.append(tok_est)
content = msg.get("content", "")
if content:
save_message(self._ws_id, "assistant", content)
else:
# Cancelled during tool execution — roll back incomplete results
while self.messages and self.messages[-1]["role"] == "tool":
self.messages.pop()
if self._msg_tokens:
self._msg_tokens.pop()
while (
self.messages
and self.messages[-1]["role"] == "assistant"
and self.messages[-1].get("tool_calls")
):
self.messages.pop()
if self._msg_tokens:
self._msg_tokens.pop()
self._cancel_event.clear()
self.ui.on_info("[Generation cancelled]")
self._emit_state("idle")
# Do NOT re-raise — return normally so server worker thread
# completes cleanly.
except KeyboardInterrupt:
# Remove any partial tool results, then the originating assistant
# message with unanswered tool_calls — keep _msg_tokens in sync
@@ -980,91 +1050,107 @@ class ChatSession:
first_token = False
finish_reason = None
for chunk in stream:
# Track finish_reason (e.g. "stop", "length", "tool_calls")
if chunk.finish_reason:
finish_reason = chunk.finish_reason
try:
for chunk in stream:
self._check_cancelled()
# Track finish_reason (e.g. "stop", "length", "tool_calls")
if chunk.finish_reason:
finish_reason = chunk.finish_reason
# Accumulate usage (Anthropic sends prompt tokens in message_start
# and completion tokens in message_delta as separate events)
if chunk.usage:
if self._last_usage is None:
self._last_usage = {
"prompt_tokens": chunk.usage.prompt_tokens,
"completion_tokens": chunk.usage.completion_tokens,
"total_tokens": chunk.usage.total_tokens,
}
else:
self._last_usage["prompt_tokens"] = max(
self._last_usage["prompt_tokens"], chunk.usage.prompt_tokens
)
self._last_usage["completion_tokens"] = max(
self._last_usage["completion_tokens"], chunk.usage.completion_tokens
)
self._last_usage["total_tokens"] = (
self._last_usage["prompt_tokens"] + self._last_usage["completion_tokens"]
)
if self.debug:
parts = []
if chunk.content_delta:
parts.append(f"content={chunk.content_delta!r}")
if chunk.reasoning_delta:
parts.append(f"reasoning={chunk.reasoning_delta!r}")
if chunk.tool_call_deltas:
parts.append("tool_calls=...")
if parts:
self.ui.on_info(f"{GRAY}[delta: {', '.join(parts)}]{RESET}")
# Path 1: reasoning field (provider-normalized reasoning_delta)
if chunk.reasoning_delta:
_stop_spinner_once()
reasoning_parts.append(chunk.reasoning_delta)
in_think = True
path1_reasoning = True
if self.show_reasoning:
self.ui.on_reasoning_token(chunk.reasoning_delta)
# Path 2: regular content (may contain <think> tags)
if chunk.content_delta:
_stop_spinner_once()
# Close reasoning if transitioning from Path 1 reasoning
if path1_reasoning:
path1_reasoning = False
in_think = False
pending += chunk.content_delta
_drain_pending()
# Handle tool call deltas
if chunk.tool_call_deltas:
_stop_spinner_once()
# Close reasoning if transitioning from reasoning
if in_think:
in_think = False
for tcd in chunk.tool_call_deltas:
idx = tcd.index
if idx not in tool_calls_acc:
tool_calls_acc[idx] = {
"id": "",
"type": "function",
"function": {"name": "", "arguments": ""},
# Accumulate usage (Anthropic sends prompt tokens in message_start
# and completion tokens in message_delta as separate events)
if chunk.usage:
if self._last_usage is None:
self._last_usage = {
"prompt_tokens": chunk.usage.prompt_tokens,
"completion_tokens": chunk.usage.completion_tokens,
"total_tokens": chunk.usage.total_tokens,
}
tc = tool_calls_acc[idx]
if tcd.id:
tc["id"] = tcd.id
if tcd.name:
tc["function"]["name"] = tcd.name
if tcd.arguments_delta:
tc["function"]["arguments"] += tcd.arguments_delta
else:
self._last_usage["prompt_tokens"] = max(
self._last_usage["prompt_tokens"], chunk.usage.prompt_tokens
)
self._last_usage["completion_tokens"] = max(
self._last_usage["completion_tokens"], chunk.usage.completion_tokens
)
self._last_usage["total_tokens"] = (
self._last_usage["prompt_tokens"]
+ self._last_usage["completion_tokens"]
)
# Informational messages (e.g. server-side web search status)
if chunk.info_delta:
_stop_spinner_once()
self.ui.on_info(f"{GRAY}{chunk.info_delta}{RESET}")
if self.debug:
parts = []
if chunk.content_delta:
parts.append(f"content={chunk.content_delta!r}")
if chunk.reasoning_delta:
parts.append(f"reasoning={chunk.reasoning_delta!r}")
if chunk.tool_call_deltas:
parts.append("tool_calls=...")
if parts:
self.ui.on_info(f"{GRAY}[delta: {', '.join(parts)}]{RESET}")
# Raw provider content blocks (for multi-turn preservation)
if chunk.provider_blocks:
provider_blocks = chunk.provider_blocks
# Path 1: reasoning field (provider-normalized reasoning_delta)
if chunk.reasoning_delta:
_stop_spinner_once()
reasoning_parts.append(chunk.reasoning_delta)
in_think = True
path1_reasoning = True
if self.show_reasoning:
self.ui.on_reasoning_token(chunk.reasoning_delta)
# Path 2: regular content (may contain <think> tags)
if chunk.content_delta:
_stop_spinner_once()
# Close reasoning if transitioning from Path 1 reasoning
if path1_reasoning:
path1_reasoning = False
in_think = False
pending += chunk.content_delta
_drain_pending()
# Handle tool call deltas
if chunk.tool_call_deltas:
_stop_spinner_once()
# Close reasoning if transitioning from reasoning
if in_think:
in_think = False
for tcd in chunk.tool_call_deltas:
idx = tcd.index
if idx not in tool_calls_acc:
tool_calls_acc[idx] = {
"id": "",
"type": "function",
"function": {"name": "", "arguments": ""},
}
tc = tool_calls_acc[idx]
if tcd.id:
tc["id"] = tcd.id
if tcd.name:
tc["function"]["name"] = tcd.name
if tcd.arguments_delta:
tc["function"]["arguments"] += tcd.arguments_delta
# Informational messages (e.g. server-side web search status)
if chunk.info_delta:
_stop_spinner_once()
self.ui.on_info(f"{GRAY}{chunk.info_delta}{RESET}")
# Raw provider content blocks (for multi-turn preservation)
if chunk.provider_blocks:
provider_blocks = chunk.provider_blocks
except GenerationCancelled:
# Flush whatever was buffered and build a partial message
if pending:
_flush_text(pending, in_think)
self.ui.on_stream_end()
partial: dict[str, Any] = {"role": "assistant"}
partial_content = "".join(content_parts)
partial["content"] = partial_content or None
# Deliberately omit tool_calls — they are incomplete
if provider_blocks:
partial["_provider_content"] = provider_blocks
self._cancelled_partial_msg = partial
raise
# Flush any remaining buffered text
if pending:
@@ -1446,7 +1532,9 @@ class ChatSession:
item["denial_msg"] = user_feedback or "Denied by user"
user_feedback = None # feedback is in the denial_msg
# Phase 3: execute
# Phase 3: execute (check cancellation before starting)
self._check_cancelled()
def run_one(
item: dict[str, Any],
) -> tuple[str, str | list[dict[str, Any]]]:
@@ -2285,6 +2373,15 @@ class ChatSession:
stdout_parts.append(line)
with contextlib.suppress(Exception):
self.ui.on_tool_output_chunk(call_id, line)
# Check cancellation during long-running commands
if self._cancel_event.is_set():
with contextlib.suppress(OSError, ProcessLookupError):
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except OSError:
with contextlib.suppress(OSError, ProcessLookupError):
proc.kill()
raise GenerationCancelled()
finally:
timer.cancel()
@@ -2548,6 +2645,7 @@ class ChatSession:
turn = 0
while max_tool_turns < 0 or turn < max_tool_turns:
self._check_cancelled()
try:
result = _api_call(agent_messages)
except Exception as e:
@@ -2559,7 +2657,7 @@ class ChatSession:
# Find the last assistant content we have
for msg in reversed(agent_messages):
if msg.get("role") == "assistant" and msg.get("content"):
return msg["content"]
return str(msg["content"])
return f"({label} stopped: context limit exceeded)"
raise
@@ -2589,6 +2687,7 @@ class ChatSession:
# concurrent _read_files mutation from worker threads.
tool_names = {t["function"]["name"] for t in tools}
for tc_dict in result.tool_calls:
self._check_cancelled()
tool_name = tc_dict["function"]["name"]
# Guard 1: block recursive agent calls.
@@ -2695,7 +2794,7 @@ class ChatSession:
tools=self._task_tools,
auto_tools=self._TASK_AUTO_TOOLS,
)
except KeyboardInterrupt:
except (KeyboardInterrupt, GenerationCancelled):
return call_id, "(task interrupted by user)"
except Exception as e:
self.ui.on_info(f"[task error] {e}")
@@ -2748,7 +2847,7 @@ class ChatSession:
label="plan",
reasoning_effort="high",
)
except KeyboardInterrupt:
except (KeyboardInterrupt, GenerationCancelled):
return call_id, "(plan interrupted by user)"
except Exception as e:
self.ui.on_info(f"[plan error] {e}")
+578
View File
@@ -10,9 +10,16 @@ import sqlalchemy as sa
from turnstone.core.storage._schema import (
api_tokens,
audit_events,
conversations,
memories,
metadata,
orgs,
prompt_templates,
roles,
tool_policies,
usage_events,
user_roles,
users,
workstream_config,
workstreams,
@@ -22,6 +29,23 @@ from turnstone.core.storage._sqlite import _reconstruct_messages
log = logging.getLogger(__name__)
def _row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
"""Convert a SQLAlchemy row to a dict, casting named fields to bool."""
d = dict(row._mapping)
for key in bool_fields:
if key in d:
d[key] = bool(d[key])
return d
# -- Field allowlists for governance update methods ---------------------------
_ROLE_MUTABLE = frozenset({"display_name", "permissions"})
_ORG_MUTABLE = frozenset({"display_name", "settings"})
_POLICY_MUTABLE = frozenset({"name", "tool_pattern", "action", "priority", "enabled"})
_TEMPLATE_MUTABLE = frozenset({"name", "content", "category", "variables", "is_default"})
class PostgreSQLBackend:
"""PostgreSQL implementation of the StorageBackend protocol."""
@@ -531,6 +555,7 @@ class PostgreSQLBackend:
from turnstone.core.storage._schema import channel_users
with self._engine.connect() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
@@ -1216,6 +1241,559 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount > 0
# -- Roles -----------------------------------------------------------------
def create_role(
self,
role_id: str,
name: str,
display_name: str,
permissions: str,
builtin: bool,
org_id: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(roles.c.role_id).where(roles.c.role_id == role_id)
).fetchone()
if not existing:
conn.execute(
sa.insert(roles),
{
"role_id": role_id,
"name": name,
"display_name": display_name,
"permissions": permissions,
"builtin": 1 if builtin else 0,
"org_id": org_id,
"created": now,
"updated": now,
},
)
conn.commit()
def get_role(self, role_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(roles).where(roles.c.role_id == role_id)).fetchone()
if row:
return _row_to_dict(row, "builtin")
return None
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(roles).where(roles.c.name == name)).fetchone()
if row:
return _row_to_dict(row, "builtin")
return None
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(roles).order_by(roles.c.name.asc())
if org_id:
q = q.where(roles.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "builtin") for r in rows]
def update_role(self, role_id: str, **fields: Any) -> bool:
dropped = set(fields) - _ROLE_MUTABLE
if dropped:
log.warning("update_role: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _ROLE_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(
sa.update(roles).where(roles.c.role_id == role_id).values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_role(self, role_id: str) -> bool:
with self._engine.connect() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id))
result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id))
conn.commit()
return result.rowcount > 0
def assign_role(self, user_id: str, role_id: str, assigned_by: str = "") -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(user_roles.c.user_id).where(
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
)
).fetchone()
if not existing:
conn.execute(
sa.insert(user_roles),
{
"user_id": user_id,
"role_id": role_id,
"assigned_by": assigned_by,
"created": now,
},
)
conn.commit()
def unassign_role(self, user_id: str, role_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(user_roles).where(
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
)
)
conn.commit()
return result.rowcount > 0
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(
roles.c.role_id,
roles.c.name,
roles.c.display_name,
roles.c.permissions,
roles.c.builtin,
roles.c.org_id,
roles.c.created,
roles.c.updated,
user_roles.c.assigned_by,
user_roles.c.created.label("assignment_created"),
)
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
.where(user_roles.c.user_id == user_id)
).fetchall()
return [
{
"role_id": r[0],
"name": r[1],
"display_name": r[2],
"permissions": r[3],
"builtin": bool(r[4]),
"org_id": r[5],
"created": r[6],
"updated": r[7],
"assigned_by": r[8],
"assignment_created": r[9],
}
for r in rows
]
def get_user_permissions(self, user_id: str) -> set[str]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(roles.c.permissions)
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
.where(user_roles.c.user_id == user_id)
).fetchall()
perms: set[str] = set()
for r in rows:
if r[0]:
for p in r[0].split(","):
p = p.strip()
if p:
perms.add(p)
return perms
# -- Organizations ---------------------------------------------------------
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(orgs.c.org_id).where(orgs.c.org_id == org_id)
).fetchone()
if not existing:
conn.execute(
sa.insert(orgs),
{
"org_id": org_id,
"name": name,
"display_name": display_name,
"settings": settings,
"created": now,
"updated": now,
},
)
conn.commit()
def get_org(self, org_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(orgs).where(orgs.c.org_id == org_id)).fetchone()
if row:
return _row_to_dict(row)
return None
def list_orgs(self) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(sa.select(orgs).order_by(orgs.c.name)).fetchall()
return [_row_to_dict(r) for r in rows]
def update_org(self, org_id: str, **fields: Any) -> bool:
dropped = set(fields) - _ORG_MUTABLE
if dropped:
log.warning("update_org: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _ORG_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(sa.update(orgs).where(orgs.c.org_id == org_id).values(**fields))
conn.commit()
return result.rowcount > 0
# -- Tool policies ---------------------------------------------------------
def create_tool_policy(
self,
policy_id: str,
name: str,
tool_pattern: str,
action: str,
priority: int,
org_id: str = "",
enabled: bool = True,
created_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(tool_policies),
{
"policy_id": policy_id,
"name": name,
"tool_pattern": tool_pattern,
"action": action,
"priority": priority,
"org_id": org_id,
"enabled": 1 if enabled else 0,
"created_by": created_by,
"created": now,
"updated": now,
},
)
conn.commit()
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(tool_policies).where(tool_policies.c.policy_id == policy_id)
).fetchone()
if row:
return _row_to_dict(row, "enabled")
return None
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(tool_policies).order_by(tool_policies.c.priority.desc())
if org_id:
q = q.where(tool_policies.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled") for r in rows]
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
dropped = set(fields) - _POLICY_MUTABLE
if dropped:
log.warning("update_tool_policy: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _POLICY_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "enabled" in fields:
fields["enabled"] = int(fields["enabled"])
with self._engine.connect() as conn:
result = conn.execute(
sa.update(tool_policies)
.where(tool_policies.c.policy_id == policy_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_tool_policy(self, policy_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(tool_policies).where(tool_policies.c.policy_id == policy_id)
)
conn.commit()
return result.rowcount > 0
# -- Prompt templates ------------------------------------------------------
def create_prompt_template(
self,
template_id: str,
name: str,
category: str,
content: str,
variables: str = "[]",
is_default: bool = False,
org_id: str = "",
created_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(prompt_templates),
{
"template_id": template_id,
"name": name,
"category": category,
"content": content,
"variables": variables,
"is_default": 1 if is_default else 0,
"org_id": org_id,
"created_by": created_by,
"created": now,
"updated": now,
},
)
conn.commit()
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(prompt_templates).where(prompt_templates.c.template_id == template_id)
).fetchone()
if row:
return _row_to_dict(row, "is_default")
return None
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(prompt_templates).order_by(prompt_templates.c.name)
if org_id:
q = q.where(prompt_templates.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "is_default") for r in rows]
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
dropped = set(fields) - _TEMPLATE_MUTABLE
if dropped:
log.warning("update_prompt_template: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _TEMPLATE_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "is_default" in fields:
fields["is_default"] = int(fields["is_default"])
with self._engine.connect() as conn:
result = conn.execute(
sa.update(prompt_templates)
.where(prompt_templates.c.template_id == template_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_prompt_template(self, template_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(prompt_templates).where(prompt_templates.c.template_id == template_id)
)
conn.commit()
return result.rowcount > 0
# -- Usage events ----------------------------------------------------------
def record_usage_event(
self,
event_id: str,
user_id: str = "",
ws_id: str = "",
node_id: str = "",
model: str = "",
prompt_tokens: int = 0,
completion_tokens: int = 0,
tool_calls_count: int = 0,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(usage_events),
{
"event_id": event_id,
"timestamp": now,
"user_id": user_id,
"ws_id": ws_id,
"node_id": node_id,
"model": model,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"tool_calls_count": tool_calls_count,
"created": now,
},
)
conn.commit()
def query_usage(
self,
since: str,
until: str = "",
user_id: str = "",
model: str = "",
group_by: str = "",
) -> list[dict[str, Any]]:
clauses = ["timestamp >= :since"]
params: dict[str, Any] = {"since": since}
if until:
clauses.append("timestamp <= :until")
params["until"] = until
if user_id:
clauses.append("user_id = :user_id")
params["user_id"] = user_id
if model:
clauses.append("model = :model")
params["model"] = model
where = " AND ".join(clauses)
if group_by == "day":
key_expr = "substring(timestamp from 1 for 10)"
elif group_by == "hour":
key_expr = "substring(timestamp from 1 for 13)"
elif group_by == "model":
key_expr = "model"
elif group_by == "user":
key_expr = "user_id"
else:
# No grouping — single summary row
sql = (
f"SELECT SUM(prompt_tokens), SUM(completion_tokens), "
f"SUM(tool_calls_count) FROM usage_events WHERE {where}"
)
with self._engine.connect() as conn:
row = conn.execute(sa.text(sql), params).fetchone()
if row:
return [
{
"prompt_tokens": row[0] or 0,
"completion_tokens": row[1] or 0,
"tool_calls_count": row[2] or 0,
}
]
return [{"prompt_tokens": 0, "completion_tokens": 0, "tool_calls_count": 0}]
sql = (
f"SELECT {key_expr} AS key, SUM(prompt_tokens), SUM(completion_tokens), "
f"SUM(tool_calls_count) FROM usage_events WHERE {where} "
f"GROUP BY {key_expr} ORDER BY key ASC"
)
with self._engine.connect() as conn:
rows = conn.execute(sa.text(sql), params).fetchall()
return [
{
"key": r[0],
"prompt_tokens": r[1] or 0,
"completion_tokens": r[2] or 0,
"tool_calls_count": r[3] or 0,
}
for r in rows
]
def prune_usage_events(self, retention_days: int = 90) -> int:
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(sa.delete(usage_events).where(usage_events.c.timestamp < cutoff))
conn.commit()
return result.rowcount
# -- Audit events ----------------------------------------------------------
def record_audit_event(
self,
event_id: str,
user_id: str = "",
action: str = "",
resource_type: str = "",
resource_id: str = "",
detail: str = "{}",
ip_address: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(audit_events),
{
"event_id": event_id,
"timestamp": now,
"user_id": user_id,
"action": action,
"resource_type": resource_type,
"resource_id": resource_id,
"detail": detail,
"ip_address": ip_address,
"created": now,
},
)
conn.commit()
def list_audit_events(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(
audit_events.c.event_id,
audit_events.c.timestamp,
audit_events.c.user_id,
audit_events.c.action,
audit_events.c.resource_type,
audit_events.c.resource_id,
audit_events.c.detail,
audit_events.c.ip_address,
audit_events.c.created,
).order_by(audit_events.c.timestamp.desc(), audit_events.c.event_id.desc())
if action:
q = q.where(audit_events.c.action == action)
if user_id:
q = q.where(audit_events.c.user_id == user_id)
if since:
q = q.where(audit_events.c.timestamp >= since)
if until:
q = q.where(audit_events.c.timestamp <= until)
q = q.limit(limit).offset(offset)
rows = conn.execute(q).fetchall()
return [
{
"event_id": r[0],
"timestamp": r[1],
"user_id": r[2],
"action": r[3],
"resource_type": r[4],
"resource_id": r[5],
"detail": r[6],
"ip_address": r[7],
"created": r[8],
}
for r in rows
]
def count_audit_events(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
) -> int:
with self._engine.connect() as conn:
q = sa.select(sa.func.count()).select_from(audit_events)
if action:
q = q.where(audit_events.c.action == action)
if user_id:
q = q.where(audit_events.c.user_id == user_id)
if since:
q = q.where(audit_events.c.timestamp >= since)
if until:
q = q.where(audit_events.c.timestamp <= until)
row = conn.execute(q).fetchone()
return row[0] if row else 0
def prune_audit_events(self, retention_days: int = 365) -> int:
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(sa.delete(audit_events).where(audit_events.c.timestamp < cutoff))
conn.commit()
return result.rowcount
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
+204
View File
@@ -359,6 +359,210 @@ class StorageBackend(Protocol):
"""Remove a service registration. Returns True if existed."""
...
# -- Roles (RBAC) ----------------------------------------------------------
def create_role(
self,
role_id: str,
name: str,
display_name: str,
permissions: str,
builtin: bool,
org_id: str,
) -> None:
"""Create a role. No-op if role_id already exists."""
...
def get_role(self, role_id: str) -> dict[str, Any] | None:
"""Return role dict or None."""
...
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
"""Lookup role by name. Returns same dict as get_role or None."""
...
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
"""Return all roles, optionally filtered by org_id. Ordered by name."""
...
def update_role(self, role_id: str, **fields: Any) -> bool:
"""Update specified fields on a role. Returns True if found."""
...
def delete_role(self, role_id: str) -> bool:
"""Delete a custom role. Returns True if found."""
...
def assign_role(self, user_id: str, role_id: str, assigned_by: str) -> None:
"""Assign a role to a user. No-op if already assigned."""
...
def unassign_role(self, user_id: str, role_id: str) -> bool:
"""Unassign a role from a user. Returns True if existed."""
...
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
"""List roles assigned to a user (joins user_roles with roles)."""
...
def get_user_permissions(self, user_id: str) -> set[str]:
"""Return the union of all permissions from the user's assigned roles."""
...
# -- Organizations ---------------------------------------------------------
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
"""Create an organization. No-op if org_id already exists."""
...
def get_org(self, org_id: str) -> dict[str, Any] | None:
"""Return org dict or None."""
...
def list_orgs(self) -> list[dict[str, Any]]:
"""Return all organizations ordered by name."""
...
def update_org(self, org_id: str, **fields: Any) -> bool:
"""Update specified fields on an org. Returns True if found."""
...
# -- Tool policies ---------------------------------------------------------
def create_tool_policy(
self,
policy_id: str,
name: str,
tool_pattern: str,
action: str,
priority: int,
org_id: str,
enabled: bool,
created_by: str,
) -> None:
"""Create a tool policy."""
...
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
"""Return tool policy dict or None."""
...
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
"""Return all tool policies ordered by priority DESC."""
...
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
"""Update specified fields on a tool policy. Returns True if found."""
...
def delete_tool_policy(self, policy_id: str) -> bool:
"""Delete a tool policy. Returns True if found."""
...
# -- Prompt templates ------------------------------------------------------
def create_prompt_template(
self,
template_id: str,
name: str,
category: str,
content: str,
variables: str,
is_default: bool,
org_id: str,
created_by: str,
) -> None:
"""Create a prompt template."""
...
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
"""Return prompt template dict or None."""
...
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
"""Return all prompt templates ordered by name."""
...
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
"""Update specified fields on a prompt template. Returns True if found."""
...
def delete_prompt_template(self, template_id: str) -> bool:
"""Delete a prompt template. Returns True if found."""
...
# -- Usage events ----------------------------------------------------------
def record_usage_event(
self,
event_id: str,
user_id: str,
ws_id: str,
node_id: str,
model: str,
prompt_tokens: int,
completion_tokens: int,
tool_calls_count: int,
) -> None:
"""Record a usage event (token counts, tool calls for one LLM request)."""
...
def query_usage(
self,
since: str,
until: str = "",
user_id: str = "",
model: str = "",
group_by: str = "",
) -> list[dict[str, Any]]:
"""Query aggregated usage data. group_by: 'day', 'hour', 'model', 'user'."""
...
def prune_usage_events(self, retention_days: int = 90) -> int:
"""Delete usage events older than retention_days. Returns count deleted."""
...
# -- Audit events ----------------------------------------------------------
def record_audit_event(
self,
event_id: str,
user_id: str,
action: str,
resource_type: str,
resource_id: str,
detail: str,
ip_address: str,
) -> None:
"""Record an audit event."""
...
def list_audit_events(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict[str, Any]]:
"""List audit events with optional filters, ordered by timestamp DESC."""
...
def count_audit_events(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
) -> int:
"""Count audit events matching the filters."""
...
def prune_audit_events(self, retention_days: int = 365) -> int:
"""Delete audit events older than retention_days. Returns count deleted."""
...
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
+112
View File
@@ -71,6 +71,7 @@ users = sa.Table(
sa.Column("username", sa.Text, nullable=False, unique=True),
sa.Column("display_name", sa.Text, nullable=False),
sa.Column("password_hash", sa.Text, nullable=False),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
)
@@ -217,3 +218,114 @@ services = sa.Table(
)
sa.Index("idx_services_type_heartbeat", services.c.service_type, services.c.last_heartbeat)
# ---------------------------------------------------------------------------
# Governance tables — RBAC, orgs, policies, templates, usage, audit
# ---------------------------------------------------------------------------
orgs = sa.Table(
"orgs",
metadata,
sa.Column("org_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("display_name", sa.Text, nullable=False),
sa.Column("settings", sa.Text, nullable=False, server_default="{}"),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
roles = sa.Table(
"roles",
metadata,
sa.Column("role_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("display_name", sa.Text, nullable=False),
sa.Column("permissions", sa.Text, nullable=False), # comma-separated
sa.Column("builtin", sa.Integer, nullable=False, server_default="0"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
user_roles = sa.Table(
"user_roles",
metadata,
sa.Column("user_id", sa.Text, nullable=False),
sa.Column("role_id", sa.Text, nullable=False),
sa.Column("assigned_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.PrimaryKeyConstraint("user_id", "role_id"),
)
sa.Index("idx_user_roles_role_id", user_roles.c.role_id)
tool_policies = sa.Table(
"tool_policies",
metadata,
sa.Column("policy_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False),
sa.Column("tool_pattern", sa.Text, nullable=False),
sa.Column("action", sa.Text, nullable=False), # allow / deny / ask
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
sa.Index("idx_tool_policies_priority", tool_policies.c.priority.desc())
sa.Index("idx_tool_policies_org", tool_policies.c.org_id)
prompt_templates = sa.Table(
"prompt_templates",
metadata,
sa.Column("template_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("category", sa.Text, nullable=False, server_default="general"),
sa.Column("content", sa.Text, nullable=False),
sa.Column("variables", sa.Text, nullable=False, server_default="[]"), # JSON array
sa.Column("is_default", sa.Integer, nullable=False, server_default="0"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
usage_events = sa.Table(
"usage_events",
metadata,
sa.Column("event_id", sa.Text, primary_key=True),
sa.Column("timestamp", sa.Text, nullable=False),
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
sa.Column("ws_id", sa.Text, nullable=False, server_default=""),
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
sa.Column("model", sa.Text, nullable=False, server_default=""),
sa.Column("prompt_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("completion_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("tool_calls_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("created", sa.Text, nullable=False),
)
sa.Index("idx_usage_events_timestamp", usage_events.c.timestamp)
sa.Index("idx_usage_events_user", usage_events.c.user_id, usage_events.c.timestamp)
sa.Index("idx_usage_events_model", usage_events.c.model, usage_events.c.timestamp)
sa.Index("idx_usage_events_ws", usage_events.c.ws_id)
audit_events = sa.Table(
"audit_events",
metadata,
sa.Column("event_id", sa.Text, primary_key=True),
sa.Column("timestamp", sa.Text, nullable=False),
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
sa.Column("action", sa.Text, nullable=False),
sa.Column("resource_type", sa.Text, nullable=False, server_default=""),
sa.Column("resource_id", sa.Text, nullable=False, server_default=""),
sa.Column("detail", sa.Text, nullable=False, server_default="{}"),
sa.Column("ip_address", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
)
sa.Index("idx_audit_timestamp", audit_events.c.timestamp)
sa.Index("idx_audit_action", audit_events.c.action)
sa.Index("idx_audit_user", audit_events.c.user_id)
+564
View File
@@ -12,9 +12,16 @@ import sqlalchemy as sa
from turnstone.core.storage._schema import (
api_tokens,
audit_events,
conversations,
memories,
metadata,
orgs,
prompt_templates,
roles,
tool_policies,
usage_events,
user_roles,
users,
workstream_config,
workstreams,
@@ -38,6 +45,23 @@ def _fts5_query(query: str) -> str:
return " ".join(safe)
def _row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
"""Convert a SQLAlchemy row to a dict, casting named fields to bool."""
d = dict(row._mapping)
for key in bool_fields:
if key in d:
d[key] = bool(d[key])
return d
# -- Field allowlists for governance update methods ---------------------------
_ROLE_MUTABLE = frozenset({"display_name", "permissions"})
_ORG_MUTABLE = frozenset({"display_name", "settings"})
_POLICY_MUTABLE = frozenset({"name", "tool_pattern", "action", "priority", "enabled"})
_TEMPLATE_MUTABLE = frozenset({"name", "content", "category", "variables", "is_default"})
class SQLiteBackend:
"""SQLite implementation of the StorageBackend protocol."""
@@ -590,6 +614,7 @@ class SQLiteBackend:
from turnstone.core.storage._schema import channel_users
with self._engine.connect() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
@@ -1264,6 +1289,545 @@ class SQLiteBackend:
conn.commit()
return result.rowcount > 0
# -- Roles -----------------------------------------------------------------
def create_role(
self,
role_id: str,
name: str,
display_name: str,
permissions: str,
builtin: bool,
org_id: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(roles).prefix_with("OR IGNORE"),
{
"role_id": role_id,
"name": name,
"display_name": display_name,
"permissions": permissions,
"builtin": 1 if builtin else 0,
"org_id": org_id,
"created": now,
"updated": now,
},
)
conn.commit()
def get_role(self, role_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(roles).where(roles.c.role_id == role_id)).fetchone()
if row:
return _row_to_dict(row, "builtin")
return None
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(roles).where(roles.c.name == name)).fetchone()
if row:
return _row_to_dict(row, "builtin")
return None
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(roles).order_by(roles.c.name.asc())
if org_id:
q = q.where(roles.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "builtin") for r in rows]
def update_role(self, role_id: str, **fields: Any) -> bool:
dropped = set(fields) - _ROLE_MUTABLE
if dropped:
log.warning("update_role: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _ROLE_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(
sa.update(roles).where(roles.c.role_id == role_id).values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_role(self, role_id: str) -> bool:
with self._engine.connect() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id))
result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id))
conn.commit()
return result.rowcount > 0
def assign_role(self, user_id: str, role_id: str, assigned_by: str = "") -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(user_roles).prefix_with("OR IGNORE"),
{
"user_id": user_id,
"role_id": role_id,
"assigned_by": assigned_by,
"created": now,
},
)
conn.commit()
def unassign_role(self, user_id: str, role_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(user_roles).where(
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
)
)
conn.commit()
return result.rowcount > 0
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(
roles.c.role_id,
roles.c.name,
roles.c.display_name,
roles.c.permissions,
roles.c.builtin,
roles.c.org_id,
roles.c.created,
roles.c.updated,
user_roles.c.assigned_by,
user_roles.c.created.label("assignment_created"),
)
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
.where(user_roles.c.user_id == user_id)
).fetchall()
return [
{
"role_id": r[0],
"name": r[1],
"display_name": r[2],
"permissions": r[3],
"builtin": bool(r[4]),
"org_id": r[5],
"created": r[6],
"updated": r[7],
"assigned_by": r[8],
"assignment_created": r[9],
}
for r in rows
]
def get_user_permissions(self, user_id: str) -> set[str]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(roles.c.permissions)
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
.where(user_roles.c.user_id == user_id)
).fetchall()
perms: set[str] = set()
for r in rows:
if r[0]:
for p in r[0].split(","):
p = p.strip()
if p:
perms.add(p)
return perms
# -- Organizations ---------------------------------------------------------
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(orgs).prefix_with("OR IGNORE"),
{
"org_id": org_id,
"name": name,
"display_name": display_name,
"settings": settings,
"created": now,
"updated": now,
},
)
conn.commit()
def get_org(self, org_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(orgs).where(orgs.c.org_id == org_id)).fetchone()
if row:
return _row_to_dict(row)
return None
def list_orgs(self) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(sa.select(orgs).order_by(orgs.c.name)).fetchall()
return [_row_to_dict(r) for r in rows]
def update_org(self, org_id: str, **fields: Any) -> bool:
dropped = set(fields) - _ORG_MUTABLE
if dropped:
log.warning("update_org: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _ORG_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(sa.update(orgs).where(orgs.c.org_id == org_id).values(**fields))
conn.commit()
return result.rowcount > 0
# -- Tool policies ---------------------------------------------------------
def create_tool_policy(
self,
policy_id: str,
name: str,
tool_pattern: str,
action: str,
priority: int,
org_id: str = "",
enabled: bool = True,
created_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(tool_policies),
{
"policy_id": policy_id,
"name": name,
"tool_pattern": tool_pattern,
"action": action,
"priority": priority,
"org_id": org_id,
"enabled": 1 if enabled else 0,
"created_by": created_by,
"created": now,
"updated": now,
},
)
conn.commit()
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(tool_policies).where(tool_policies.c.policy_id == policy_id)
).fetchone()
if row:
return _row_to_dict(row, "enabled")
return None
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(tool_policies).order_by(tool_policies.c.priority.desc())
if org_id:
q = q.where(tool_policies.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled") for r in rows]
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
dropped = set(fields) - _POLICY_MUTABLE
if dropped:
log.warning("update_tool_policy: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _POLICY_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "enabled" in fields:
fields["enabled"] = int(fields["enabled"])
with self._engine.connect() as conn:
result = conn.execute(
sa.update(tool_policies)
.where(tool_policies.c.policy_id == policy_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_tool_policy(self, policy_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(tool_policies).where(tool_policies.c.policy_id == policy_id)
)
conn.commit()
return result.rowcount > 0
# -- Prompt templates ------------------------------------------------------
def create_prompt_template(
self,
template_id: str,
name: str,
category: str,
content: str,
variables: str = "[]",
is_default: bool = False,
org_id: str = "",
created_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(prompt_templates),
{
"template_id": template_id,
"name": name,
"category": category,
"content": content,
"variables": variables,
"is_default": 1 if is_default else 0,
"org_id": org_id,
"created_by": created_by,
"created": now,
"updated": now,
},
)
conn.commit()
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(prompt_templates).where(prompt_templates.c.template_id == template_id)
).fetchone()
if row:
return _row_to_dict(row, "is_default")
return None
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(prompt_templates).order_by(prompt_templates.c.name)
if org_id:
q = q.where(prompt_templates.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "is_default") for r in rows]
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
dropped = set(fields) - _TEMPLATE_MUTABLE
if dropped:
log.warning("update_prompt_template: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _TEMPLATE_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "is_default" in fields:
fields["is_default"] = int(fields["is_default"])
with self._engine.connect() as conn:
result = conn.execute(
sa.update(prompt_templates)
.where(prompt_templates.c.template_id == template_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_prompt_template(self, template_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(prompt_templates).where(prompt_templates.c.template_id == template_id)
)
conn.commit()
return result.rowcount > 0
# -- Usage events ----------------------------------------------------------
def record_usage_event(
self,
event_id: str,
user_id: str = "",
ws_id: str = "",
node_id: str = "",
model: str = "",
prompt_tokens: int = 0,
completion_tokens: int = 0,
tool_calls_count: int = 0,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(usage_events),
{
"event_id": event_id,
"timestamp": now,
"user_id": user_id,
"ws_id": ws_id,
"node_id": node_id,
"model": model,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"tool_calls_count": tool_calls_count,
"created": now,
},
)
conn.commit()
def query_usage(
self,
since: str,
until: str = "",
user_id: str = "",
model: str = "",
group_by: str = "",
) -> list[dict[str, Any]]:
clauses = ["timestamp >= :since"]
params: dict[str, Any] = {"since": since}
if until:
clauses.append("timestamp <= :until")
params["until"] = until
if user_id:
clauses.append("user_id = :user_id")
params["user_id"] = user_id
if model:
clauses.append("model = :model")
params["model"] = model
where = " AND ".join(clauses)
if group_by == "day":
key_expr = "substr(timestamp, 1, 10)"
elif group_by == "hour":
key_expr = "substr(timestamp, 1, 13)"
elif group_by == "model":
key_expr = "model"
elif group_by == "user":
key_expr = "user_id"
else:
# No grouping — single summary row
sql = (
f"SELECT SUM(prompt_tokens), SUM(completion_tokens), "
f"SUM(tool_calls_count) FROM usage_events WHERE {where}"
)
with self._engine.connect() as conn:
row = conn.execute(sa.text(sql), params).fetchone()
if row:
return [
{
"prompt_tokens": row[0] or 0,
"completion_tokens": row[1] or 0,
"tool_calls_count": row[2] or 0,
}
]
return [{"prompt_tokens": 0, "completion_tokens": 0, "tool_calls_count": 0}]
sql = (
f"SELECT {key_expr} AS key, SUM(prompt_tokens), SUM(completion_tokens), "
f"SUM(tool_calls_count) FROM usage_events WHERE {where} "
f"GROUP BY {key_expr} ORDER BY key ASC"
)
with self._engine.connect() as conn:
rows = conn.execute(sa.text(sql), params).fetchall()
return [
{
"key": r[0],
"prompt_tokens": r[1] or 0,
"completion_tokens": r[2] or 0,
"tool_calls_count": r[3] or 0,
}
for r in rows
]
def prune_usage_events(self, retention_days: int = 90) -> int:
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(sa.delete(usage_events).where(usage_events.c.timestamp < cutoff))
conn.commit()
return result.rowcount
# -- Audit events ----------------------------------------------------------
def record_audit_event(
self,
event_id: str,
user_id: str = "",
action: str = "",
resource_type: str = "",
resource_id: str = "",
detail: str = "{}",
ip_address: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(audit_events),
{
"event_id": event_id,
"timestamp": now,
"user_id": user_id,
"action": action,
"resource_type": resource_type,
"resource_id": resource_id,
"detail": detail,
"ip_address": ip_address,
"created": now,
},
)
conn.commit()
def list_audit_events(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(
audit_events.c.event_id,
audit_events.c.timestamp,
audit_events.c.user_id,
audit_events.c.action,
audit_events.c.resource_type,
audit_events.c.resource_id,
audit_events.c.detail,
audit_events.c.ip_address,
audit_events.c.created,
).order_by(audit_events.c.timestamp.desc(), audit_events.c.event_id.desc())
if action:
q = q.where(audit_events.c.action == action)
if user_id:
q = q.where(audit_events.c.user_id == user_id)
if since:
q = q.where(audit_events.c.timestamp >= since)
if until:
q = q.where(audit_events.c.timestamp <= until)
q = q.limit(limit).offset(offset)
rows = conn.execute(q).fetchall()
return [
{
"event_id": r[0],
"timestamp": r[1],
"user_id": r[2],
"action": r[3],
"resource_type": r[4],
"resource_id": r[5],
"detail": r[6],
"ip_address": r[7],
"created": r[8],
}
for r in rows
]
def count_audit_events(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
) -> int:
with self._engine.connect() as conn:
q = sa.select(sa.func.count()).select_from(audit_events)
if action:
q = q.where(audit_events.c.action == action)
if user_id:
q = q.where(audit_events.c.user_id == user_id)
if since:
q = q.where(audit_events.c.timestamp >= since)
if until:
q = q.where(audit_events.c.timestamp <= until)
row = conn.execute(q).fetchone()
return row[0] if row else 0
def prune_audit_events(self, retention_days: int = 365) -> int:
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(sa.delete(audit_events).where(audit_events.c.timestamp < cutoff))
conn.commit()
return result.rowcount
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
@@ -0,0 +1,195 @@
"""Governance tables — RBAC roles, orgs, tool policies, prompt templates, usage, audit.
Revision ID: 008
Revises: 007
Create Date: 2026-03-10
"""
import sqlalchemy as sa
from alembic import op
revision = "008"
down_revision = "007"
branch_labels = None
depends_on = None
# Built-in roles seeded on upgrade
_ADMIN_PERMS = (
"read,write,approve,admin.users,admin.roles,admin.orgs,"
"admin.policies,admin.templates,admin.audit,admin.usage,"
"admin.schedules,admin.watches,"
"tools.approve,workstreams.create,workstreams.close"
)
_OPERATOR_PERMS = "read,write,workstreams.create,workstreams.close"
_VIEWER_PERMS = "read"
def upgrade() -> None:
# -- Organizations ---------------------------------------------------------
op.create_table(
"orgs",
sa.Column("org_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("display_name", sa.Text, nullable=False),
sa.Column("settings", sa.Text, nullable=False, server_default="{}"),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
# -- Roles -----------------------------------------------------------------
op.create_table(
"roles",
sa.Column("role_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("display_name", sa.Text, nullable=False),
sa.Column("permissions", sa.Text, nullable=False),
sa.Column("builtin", sa.Integer, nullable=False, server_default="0"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
# -- User ↔ Role assignments -----------------------------------------------
op.create_table(
"user_roles",
sa.Column("user_id", sa.Text, nullable=False),
sa.Column("role_id", sa.Text, nullable=False),
sa.Column("assigned_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.PrimaryKeyConstraint("user_id", "role_id"),
)
op.create_index("idx_user_roles_role_id", "user_roles", ["role_id"])
# -- Tool policies ---------------------------------------------------------
op.create_table(
"tool_policies",
sa.Column("policy_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False),
sa.Column("tool_pattern", sa.Text, nullable=False),
sa.Column("action", sa.Text, nullable=False),
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
op.create_index("idx_tool_policies_priority", "tool_policies", [sa.text("priority DESC")])
op.create_index("idx_tool_policies_org", "tool_policies", ["org_id"])
# -- Prompt templates ------------------------------------------------------
op.create_table(
"prompt_templates",
sa.Column("template_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("category", sa.Text, nullable=False, server_default="general"),
sa.Column("content", sa.Text, nullable=False),
sa.Column("variables", sa.Text, nullable=False, server_default="[]"),
sa.Column("is_default", sa.Integer, nullable=False, server_default="0"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
# -- Usage events ----------------------------------------------------------
op.create_table(
"usage_events",
sa.Column("event_id", sa.Text, primary_key=True),
sa.Column("timestamp", sa.Text, nullable=False),
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
sa.Column("ws_id", sa.Text, nullable=False, server_default=""),
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
sa.Column("model", sa.Text, nullable=False, server_default=""),
sa.Column("prompt_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("completion_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("tool_calls_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("created", sa.Text, nullable=False),
)
op.create_index("idx_usage_events_timestamp", "usage_events", ["timestamp"])
op.create_index("idx_usage_events_user", "usage_events", ["user_id", "timestamp"])
op.create_index("idx_usage_events_model", "usage_events", ["model", "timestamp"])
op.create_index("idx_usage_events_ws", "usage_events", ["ws_id"])
# -- Audit events ----------------------------------------------------------
op.create_table(
"audit_events",
sa.Column("event_id", sa.Text, primary_key=True),
sa.Column("timestamp", sa.Text, nullable=False),
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
sa.Column("action", sa.Text, nullable=False),
sa.Column("resource_type", sa.Text, nullable=False, server_default=""),
sa.Column("resource_id", sa.Text, nullable=False, server_default=""),
sa.Column("detail", sa.Text, nullable=False, server_default="{}"),
sa.Column("ip_address", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
)
op.create_index("idx_audit_timestamp", "audit_events", ["timestamp"])
op.create_index("idx_audit_action", "audit_events", ["action"])
op.create_index("idx_audit_user", "audit_events", ["user_id"])
# -- Add org_id to users ---------------------------------------------------
with op.batch_alter_table("users") as batch_op:
batch_op.add_column(sa.Column("org_id", sa.Text, nullable=False, server_default=""))
# -- Seed default org and built-in roles -----------------------------------
conn = op.get_bind()
import datetime
now_str = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%S")
conn.execute(
sa.text(
"INSERT INTO orgs (org_id, name, display_name, settings, created, updated) "
"VALUES (:oid, :name, :dname, '{}', :now, :now)"
),
{"oid": "default", "name": "default", "dname": "Default", "now": now_str},
)
for role_id, name, dname, perms in [
("builtin-admin", "admin", "Admin", _ADMIN_PERMS),
("builtin-operator", "operator", "Operator", _OPERATOR_PERMS),
("builtin-viewer", "viewer", "Viewer", _VIEWER_PERMS),
]:
conn.execute(
sa.text(
"INSERT INTO roles (role_id, name, display_name, permissions, builtin, org_id, created, updated) "
"VALUES (:rid, :name, :dname, :perms, 1, '', :now, :now)"
),
{"rid": role_id, "name": name, "dname": dname, "perms": perms, "now": now_str},
)
# Assign admin role to all existing users
conn.execute(
sa.text(
"INSERT INTO user_roles (user_id, role_id, assigned_by, created) "
"SELECT user_id, 'builtin-admin', '', :now FROM users"
),
{"now": now_str},
)
def downgrade() -> None:
op.drop_index("idx_audit_user", "audit_events")
op.drop_index("idx_audit_action", "audit_events")
op.drop_index("idx_audit_timestamp", "audit_events")
op.drop_table("audit_events")
op.drop_index("idx_usage_events_ws", "usage_events")
op.drop_index("idx_usage_events_model", "usage_events")
op.drop_index("idx_usage_events_user", "usage_events")
op.drop_index("idx_usage_events_timestamp", "usage_events")
op.drop_table("usage_events")
op.drop_table("prompt_templates")
op.drop_index("idx_tool_policies_org", "tool_policies")
op.drop_index("idx_tool_policies_priority", "tool_policies")
op.drop_table("tool_policies")
op.drop_index("idx_user_roles_role_id", "user_roles")
op.drop_table("user_roles")
op.drop_table("roles")
op.drop_table("orgs")
with op.batch_alter_table("users") as batch_op:
batch_op.drop_column("org_id")
+15
View File
@@ -241,6 +241,7 @@ class Bridge:
"command": self._handle_command,
"create_workstream": self._handle_create_ws,
"close_workstream": self._handle_close_ws,
"cancel": self._handle_cancel,
}
# Messages that are always local (no routing needed)
local_handlers = {
@@ -348,6 +349,20 @@ class Bridge:
if request_id:
self._broker.push_response(request_id, msg.to_json())
def _handle_cancel(self, msg: InboundMessage) -> None:
ws_id = getattr(msg, "ws_id", "")
resp = self._http.post("/v1/api/cancel", json={"ws_id": ws_id})
data = resp.json()
self._publish_ws(
ws_id,
AckEvent(
ws_id=ws_id,
correlation_id=msg.correlation_id,
status="ok" if data.get("status") == "ok" else "error",
detail=data.get("error", ""),
),
)
def _handle_command(self, msg: InboundMessage) -> None:
ws_id = getattr(msg, "ws_id", "")
command = getattr(msg, "command", "")
+9
View File
@@ -128,6 +128,14 @@ class ListNodesMessage(InboundMessage):
type: str = "list_nodes"
@dataclass
class CancelMessage(InboundMessage):
"""Cancel the active generation in a workstream."""
type: str = "cancel"
ws_id: str = ""
# ---------------------------------------------------------------------------
# Outbound events (bridge → client)
# ---------------------------------------------------------------------------
@@ -383,6 +391,7 @@ _INBOUND_REGISTRY: dict[str, type[InboundMessage]] = {
ListWorkstreamsMessage,
HealthMessage,
ListNodesMessage,
CancelMessage,
]
}
+342
View File
@@ -20,7 +20,18 @@ from turnstone.api.console_schemas import (
ClusterWorkstreamsResponse,
ConsoleCreateWsResponse,
ConsoleHealthResponse,
ListAuditEventsResponse,
ListOrgsResponse,
ListPromptTemplatesResponse,
ListRolesResponse,
ListToolPoliciesResponse,
ListUserRolesResponse,
NodeDetailResponse,
OrgInfo,
PromptTemplateInfo,
RoleInfo,
ToolPolicyInfo,
UsageResponse,
)
from turnstone.api.schemas import (
AuthLoginResponse,
@@ -293,6 +304,211 @@ class AsyncTurnstoneConsole(_BaseClient):
response_model=ListScheduleRunsResponse,
)
# -- governance: roles ---------------------------------------------------
async def list_roles(self) -> ListRolesResponse:
"""List all roles."""
return await self._request("GET", "/v1/api/admin/roles", response_model=ListRolesResponse)
async def create_role(
self, name: str, display_name: str = "", permissions: str = "read"
) -> RoleInfo:
"""Create a custom role."""
body: dict[str, Any] = {"name": name, "permissions": permissions}
if display_name:
body["display_name"] = display_name
return await self._request(
"POST", "/v1/api/admin/roles", json_body=body, response_model=RoleInfo
)
async def update_role(self, role_id: str, **fields: Any) -> RoleInfo:
"""Update a role's display_name and/or permissions."""
return await self._request(
"PUT", f"/v1/api/admin/roles/{role_id}", json_body=fields, response_model=RoleInfo
)
async def delete_role(self, role_id: str) -> StatusResponse:
"""Delete a custom role."""
return await self._request(
"DELETE", f"/v1/api/admin/roles/{role_id}", response_model=StatusResponse
)
async def list_user_roles(self, user_id: str) -> ListUserRolesResponse:
"""List roles assigned to a user."""
return await self._request(
"GET", f"/v1/api/admin/users/{user_id}/roles", response_model=ListUserRolesResponse
)
async def assign_role(self, user_id: str, role_id: str) -> StatusResponse:
"""Assign a role to a user."""
return await self._request(
"POST",
f"/v1/api/admin/users/{user_id}/roles",
json_body={"role_id": role_id},
response_model=StatusResponse,
)
async def unassign_role(self, user_id: str, role_id: str) -> StatusResponse:
"""Unassign a role from a user."""
return await self._request(
"DELETE",
f"/v1/api/admin/users/{user_id}/roles/{role_id}",
response_model=StatusResponse,
)
# -- governance: organizations -------------------------------------------
async def list_orgs(self) -> ListOrgsResponse:
"""List organizations."""
return await self._request("GET", "/v1/api/admin/orgs", response_model=ListOrgsResponse)
async def get_org(self, org_id: str) -> OrgInfo:
"""Get organization details."""
return await self._request("GET", f"/v1/api/admin/orgs/{org_id}", response_model=OrgInfo)
async def update_org(self, org_id: str, **fields: Any) -> OrgInfo:
"""Update organization settings."""
return await self._request(
"PUT", f"/v1/api/admin/orgs/{org_id}", json_body=fields, response_model=OrgInfo
)
# -- governance: tool policies -------------------------------------------
async def list_policies(self) -> ListToolPoliciesResponse:
"""List tool policies ordered by priority."""
return await self._request(
"GET", "/v1/api/admin/policies", response_model=ListToolPoliciesResponse
)
async def create_policy(
self,
name: str,
tool_pattern: str,
action: str,
priority: int = 0,
**kwargs: Any,
) -> ToolPolicyInfo:
"""Create a tool policy."""
body: dict[str, Any] = {
"name": name,
"tool_pattern": tool_pattern,
"action": action,
"priority": priority,
**kwargs,
}
return await self._request(
"POST", "/v1/api/admin/policies", json_body=body, response_model=ToolPolicyInfo
)
async def update_policy(self, policy_id: str, **fields: Any) -> ToolPolicyInfo:
"""Update a tool policy."""
return await self._request(
"PUT",
f"/v1/api/admin/policies/{policy_id}",
json_body=fields,
response_model=ToolPolicyInfo,
)
async def delete_policy(self, policy_id: str) -> StatusResponse:
"""Delete a tool policy."""
return await self._request(
"DELETE", f"/v1/api/admin/policies/{policy_id}", response_model=StatusResponse
)
# -- governance: prompt templates ----------------------------------------
async def list_templates(self) -> ListPromptTemplatesResponse:
"""List prompt templates."""
return await self._request(
"GET", "/v1/api/admin/templates", response_model=ListPromptTemplatesResponse
)
async def create_template(
self,
name: str,
content: str,
category: str = "general",
variables: str = "[]",
is_default: bool = False,
**kwargs: Any,
) -> PromptTemplateInfo:
"""Create a prompt template."""
body: dict[str, Any] = {
"name": name,
"content": content,
"category": category,
"variables": variables,
"is_default": is_default,
**kwargs,
}
return await self._request(
"POST", "/v1/api/admin/templates", json_body=body, response_model=PromptTemplateInfo
)
async def update_template(self, template_id: str, **fields: Any) -> PromptTemplateInfo:
"""Update a prompt template."""
return await self._request(
"PUT",
f"/v1/api/admin/templates/{template_id}",
json_body=fields,
response_model=PromptTemplateInfo,
)
async def delete_template(self, template_id: str) -> StatusResponse:
"""Delete a prompt template."""
return await self._request(
"DELETE",
f"/v1/api/admin/templates/{template_id}",
response_model=StatusResponse,
)
# -- governance: usage & audit -------------------------------------------
async def get_usage(
self,
since: str,
until: str = "",
user_id: str = "",
model: str = "",
group_by: str = "",
) -> UsageResponse:
"""Query aggregated usage data."""
params: dict[str, Any] = {"since": since}
if until:
params["until"] = until
if user_id:
params["user_id"] = user_id
if model:
params["model"] = model
if group_by:
params["group_by"] = group_by
return await self._request(
"GET", "/v1/api/admin/usage", params=params, response_model=UsageResponse
)
async def get_audit(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
limit: int = 50,
offset: int = 0,
) -> ListAuditEventsResponse:
"""Query paginated audit events."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if action:
params["action"] = action
if user_id:
params["user_id"] = user_id
if since:
params["since"] = since
if until:
params["until"] = until
return await self._request(
"GET", "/v1/api/admin/audit", params=params, response_model=ListAuditEventsResponse
)
class TurnstoneConsole:
"""Synchronous client for the turnstone console API.
@@ -469,6 +685,132 @@ class TurnstoneConsole:
def list_schedule_runs(self, task_id: str, *, limit: int = 50) -> ListScheduleRunsResponse:
return self._runner.run(self._async.list_schedule_runs(task_id, limit=limit))
# -- governance: roles ---------------------------------------------------
def list_roles(self) -> ListRolesResponse:
return self._runner.run(self._async.list_roles())
def create_role(self, name: str, display_name: str = "", permissions: str = "read") -> RoleInfo:
return self._runner.run(
self._async.create_role(name, display_name=display_name, permissions=permissions)
)
def update_role(self, role_id: str, **fields: Any) -> RoleInfo:
return self._runner.run(self._async.update_role(role_id, **fields))
def delete_role(self, role_id: str) -> StatusResponse:
return self._runner.run(self._async.delete_role(role_id))
def list_user_roles(self, user_id: str) -> ListUserRolesResponse:
return self._runner.run(self._async.list_user_roles(user_id))
def assign_role(self, user_id: str, role_id: str) -> StatusResponse:
return self._runner.run(self._async.assign_role(user_id, role_id))
def unassign_role(self, user_id: str, role_id: str) -> StatusResponse:
return self._runner.run(self._async.unassign_role(user_id, role_id))
# -- governance: organizations -------------------------------------------
def list_orgs(self) -> ListOrgsResponse:
return self._runner.run(self._async.list_orgs())
def get_org(self, org_id: str) -> OrgInfo:
return self._runner.run(self._async.get_org(org_id))
def update_org(self, org_id: str, **fields: Any) -> OrgInfo:
return self._runner.run(self._async.update_org(org_id, **fields))
# -- governance: tool policies -------------------------------------------
def list_policies(self) -> ListToolPoliciesResponse:
return self._runner.run(self._async.list_policies())
def create_policy(
self,
name: str,
tool_pattern: str,
action: str,
priority: int = 0,
**kwargs: Any,
) -> ToolPolicyInfo:
return self._runner.run(
self._async.create_policy(name, tool_pattern, action, priority=priority, **kwargs)
)
def update_policy(self, policy_id: str, **fields: Any) -> ToolPolicyInfo:
return self._runner.run(self._async.update_policy(policy_id, **fields))
def delete_policy(self, policy_id: str) -> StatusResponse:
return self._runner.run(self._async.delete_policy(policy_id))
# -- governance: prompt templates ----------------------------------------
def list_templates(self) -> ListPromptTemplatesResponse:
return self._runner.run(self._async.list_templates())
def create_template(
self,
name: str,
content: str,
category: str = "general",
variables: str = "[]",
is_default: bool = False,
**kwargs: Any,
) -> PromptTemplateInfo:
return self._runner.run(
self._async.create_template(
name,
content,
category=category,
variables=variables,
is_default=is_default,
**kwargs,
)
)
def update_template(self, template_id: str, **fields: Any) -> PromptTemplateInfo:
return self._runner.run(self._async.update_template(template_id, **fields))
def delete_template(self, template_id: str) -> StatusResponse:
return self._runner.run(self._async.delete_template(template_id))
# -- governance: usage & audit -------------------------------------------
def get_usage(
self,
since: str,
until: str = "",
user_id: str = "",
model: str = "",
group_by: str = "",
) -> UsageResponse:
return self._runner.run(
self._async.get_usage(
since, until=until, user_id=user_id, model=model, group_by=group_by
)
)
def get_audit(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
limit: int = 50,
offset: int = 0,
) -> ListAuditEventsResponse:
return self._runner.run(
self._async.get_audit(
action=action,
user_id=user_id,
since=since,
until=until,
limit=limit,
offset=offset,
)
)
# -- lifecycle -----------------------------------------------------------
def close(self) -> None:
+6
View File
@@ -149,6 +149,11 @@ class ClearUiEvent(ServerEvent):
type: str = "clear_ui"
@dataclass
class CancelledEvent(ServerEvent):
type: str = "cancelled"
# ---------------------------------------------------------------------------
# Server global events (/v1/api/events/global)
# ---------------------------------------------------------------------------
@@ -288,6 +293,7 @@ _SERVER_REGISTRY: dict[str, type[ServerEvent]] = {
ErrorEvent,
BusyErrorEvent,
ClearUiEvent,
CancelledEvent,
WsStateEvent,
WsActivityEvent,
WsRenameEvent,
+11
View File
@@ -145,6 +145,14 @@ class AsyncTurnstoneServer(_BaseClient):
response_model=StatusResponse,
)
async def cancel(self, ws_id: str) -> StatusResponse:
return await self._request(
"POST",
"/v1/api/cancel",
json_body={"ws_id": ws_id},
response_model=StatusResponse,
)
# -- streaming -----------------------------------------------------------
async def stream_events(self, ws_id: str) -> AsyncIterator[ServerEvent]:
@@ -343,6 +351,9 @@ class TurnstoneServer:
def command(self, *, ws_id: str, command: str) -> StatusResponse:
return self._runner.run(self._async.command(ws_id=ws_id, command=command))
def cancel(self, ws_id: str) -> StatusResponse:
return self._runner.run(self._async.cancel(ws_id))
# -- streaming -----------------------------------------------------------
def stream_events(self, ws_id: str) -> Iterator[ServerEvent]:
+108 -3
View File
@@ -43,7 +43,7 @@ from turnstone.api.server_spec import build_server_spec
from turnstone.core.auth import JWT_AUD_SERVER, AuthMiddleware
from turnstone.core.metrics import metrics as _metrics
from turnstone.core.ratelimit import resolve_client_ip
from turnstone.core.session import ChatSession, SessionUI # noqa: F401
from turnstone.core.session import ChatSession, GenerationCancelled, SessionUI # noqa: F401
from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection
from turnstone.core.workstream import Workstream, WorkstreamManager, WorkstreamState
@@ -80,8 +80,9 @@ class WebUI:
_global_queue: queue.Queue[dict[str, Any]] | None = None
_workstream_mgr: WorkstreamManager | None = None
def __init__(self, ws_id: str = "") -> None:
def __init__(self, ws_id: str = "", user_id: str = "") -> None:
self.ws_id = ws_id
self._user_id = user_id
self._listeners: list[queue.Queue[dict[str, Any]]] = []
self._listeners_lock = threading.Lock()
self._approval_event = threading.Event()
@@ -198,6 +199,55 @@ class WebUI:
}
)
# -- Tool policy evaluation -----------------------------------------------
# Check admin-defined tool policies before the auto_approve check.
if pending:
try:
from turnstone.core.policy import evaluate_tool_policies_batch
from turnstone.core.storage._registry import get_storage
storage = get_storage()
if storage is not None:
tool_names = [it.get("func_name", "") for it in pending if it.get("func_name")]
if tool_names:
verdicts = evaluate_tool_policies_batch(storage, tool_names)
still_pending = []
for it in pending:
fname = it.get("func_name", "")
verdict = verdicts.get(fname)
if verdict == "deny":
it["denied"] = True
it["denial_msg"] = (
f"Blocked by tool policy (pattern match for '{fname}')"
)
elif verdict == "allow":
it["needs_approval"] = False
else:
still_pending.append(it)
# Rebuild serialized to reflect policy verdicts
serialized = [
{
"call_id": it.get("call_id", ""),
"header": it.get("header", ""),
"preview": it.get("preview", ""),
"func_name": it.get("func_name", ""),
"approval_label": it.get("approval_label", it.get("func_name", "")),
"needs_approval": it.get("needs_approval", False),
"error": it.get("denial_msg") if it.get("denied") else None,
}
for it in items
]
# If all were resolved by policy, check if any were denied
if not still_pending:
any_denied = any(it.get("denied") for it in items)
if any_denied:
self._enqueue({"type": "tool_info", "items": serialized})
return False, "Blocked by tool policy"
pending = still_pending
except Exception:
log.debug("Tool policy evaluation failed", exc_info=True)
# -- End tool policy evaluation -------------------------------------------
if not pending or self.auto_approve:
# Track auto-approved tool activity
first = items[0] if items else {}
@@ -258,6 +308,7 @@ class WebUI:
self._ws_prompt_tokens += usage["prompt_tokens"]
self._ws_completion_tokens += usage["completion_tokens"]
self._ws_context_ratio = total_tok / context_window if context_window > 0 else 0.0
tool_count = sum(self._ws_tool_calls.values())
self._enqueue(
{
"type": "status",
@@ -269,6 +320,26 @@ class WebUI:
"effort": effort,
}
)
# Record usage event for governance dashboard
try:
from turnstone.core.storage._registry import get_storage
storage = get_storage()
if storage is not None:
import uuid
storage.record_usage_event(
event_id=uuid.uuid4().hex,
user_id=self._user_id,
ws_id=self.ws_id,
node_id="",
model=usage.get("model", ""),
prompt_tokens=usage["prompt_tokens"],
completion_tokens=usage["completion_tokens"],
tool_calls_count=tool_count,
)
except Exception:
pass # Non-critical — never break the response pipeline
def on_plan_review(self, content: str) -> str:
self._plan_event.clear()
@@ -771,6 +842,10 @@ async def send_message(request: Request) -> JSONResponse:
assert ui is not None
try:
session.send(message)
except GenerationCancelled:
# Safety net — send() normally handles this internally.
ui._enqueue({"type": "stream_end"})
ui.on_state_change("idle")
except Exception as e:
ui.on_error(f"Error: {e}")
ui._enqueue({"type": "stream_end"})
@@ -823,6 +898,33 @@ async def plan_feedback(request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"})
async def cancel_generation(request: Request) -> JSONResponse:
"""POST /v1/api/cancel — cancel the active generation in a workstream."""
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
ws_id = body.get("ws_id")
mgr = request.app.state.workstreams
ws, ui = _get_ws(mgr, ws_id)
if not ws or not ui:
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
session = ws.session
if session is None:
return JSONResponse({"error": "No session"}, status_code=400)
# Only act if generation is actually in progress
if ws.worker_thread and ws.worker_thread.is_alive():
# Set the cooperative cancel flag (worker thread checks at checkpoints)
session.cancel()
# Unblock any pending approval/plan review waits
ui.resolve_approval(False, "Cancelled by user")
ui.resolve_plan("reject")
# Emit cancelled SSE event so SDK consumers get a typed signal
ui._enqueue({"type": "cancelled"})
return JSONResponse({"status": "ok"})
async def command(request: Request) -> JSONResponse:
"""POST /v1/api/command — execute a slash command."""
from turnstone.core.web_helpers import read_json_or_400
@@ -877,10 +979,12 @@ async def create_workstream(request: Request) -> JSONResponse:
return body
mgr: WorkstreamManager = request.app.state.workstreams
skip: bool = request.app.state.skip_permissions
auth = getattr(getattr(request, "state", None), "auth_result", None)
uid: str = getattr(auth, "user_id", "") or ""
try:
ws = mgr.create(
name=body.get("name", ""),
ui_factory=lambda wid: WebUI(ws_id=wid),
ui_factory=lambda wid: WebUI(ws_id=wid, user_id=uid),
model=body.get("model") or None,
)
assert isinstance(ws.ui, WebUI)
@@ -1165,6 +1269,7 @@ def create_app(
Route("/api/approve", approve, methods=["POST"]),
Route("/api/plan", plan_feedback, methods=["POST"]),
Route("/api/command", command, methods=["POST"]),
Route("/api/cancel", cancel_generation, methods=["POST"]),
Route("/api/workstreams/new", create_workstream, methods=["POST"]),
Route("/api/workstreams/close", close_workstream, methods=["POST"]),
Route("/api/watches", list_watches),
+15 -3
View File
@@ -274,8 +274,9 @@ function _submitLogin() {
if (!r.ok) throw new Error("server");
return r.json();
})
.then(function () {
.then(function (data) {
_setBusy(false);
_storePermissions(data);
_onSuccess();
})
.catch(function (err) {
@@ -306,8 +307,9 @@ function _submitToken() {
if (!r.ok) throw new Error("server");
return r.json();
})
.then(function () {
.then(function (data) {
_setBusy(false);
_storePermissions(data);
_onSuccess();
})
.catch(function (err) {
@@ -369,8 +371,9 @@ function _submitSetup() {
});
return r.json();
})
.then(function () {
.then(function (data) {
_setBusy(false);
_storePermissions(data);
_onSuccess();
})
.catch(function (err) {
@@ -379,6 +382,14 @@ function _submitSetup() {
});
}
function _storePermissions(data) {
if (data && data.permissions) {
sessionStorage.setItem("turnstone_permissions", data.permissions);
} else {
sessionStorage.removeItem("turnstone_permissions");
}
}
function _setBusy(busy, label) {
_loginBusy = busy;
var btn = document.getElementById("login-submit");
@@ -403,6 +414,7 @@ function _onSuccess() {
function logout() {
fetch("/v1/api/auth/logout", { method: "POST" }).then(function () {
sessionStorage.removeItem("turnstone_permissions");
if (typeof window.onLogout === "function") window.onLogout();
showLogin();
});
+47 -16
View File
@@ -1,6 +1,7 @@
const messagesEl = document.getElementById("messages");
const inputEl = document.getElementById("input");
const sendBtn = document.getElementById("send-btn");
const stopBtn = document.getElementById("stop-btn");
const statusBar = document.getElementById("status-bar");
const modelName = document.getElementById("model-name");
const tabBar = document.getElementById("tab-bar");
@@ -13,6 +14,14 @@ let busy = false;
let pendingApproval = false;
let approvalBlockEl = null;
function setBusy(b) {
busy = b;
sendBtn.disabled = b;
sendBtn.style.display = b ? "none" : "";
stopBtn.style.display = b ? "" : "none";
stopBtn.disabled = !b;
}
// --- Workstream state ---
let workstreams = {}; // ws_id -> {name, state}
let currentWsId = null;
@@ -469,10 +478,9 @@ function switchTab(wsId) {
currentAssistantEl = null;
currentReasoningEl = null;
contentBuffer = "";
busy = false;
setBusy(false);
pendingApproval = false;
approvalBlockEl = null;
sendBtn.disabled = false;
inputEl.disabled = false;
currentWsId = wsId;
@@ -912,8 +920,7 @@ function dashboardSendMessage() {
hideDashboard();
input.disabled = false;
btn.disabled = false;
busy = true;
sendBtn.disabled = true;
setBusy(true);
addUserMessage(text);
authFetch("/v1/api/send", {
method: "POST",
@@ -921,8 +928,7 @@ function dashboardSendMessage() {
body: JSON.stringify({ message: text, ws_id: data.ws_id }),
}).catch(function (err) {
addErrorMessage("Connection error: " + err.message);
busy = false;
sendBtn.disabled = false;
setBusy(false);
});
})
.catch(function () {
@@ -1009,6 +1015,7 @@ function handleEvent(evt) {
switch (evt.type) {
case "thinking_start":
isThinking = true;
setBusy(true);
removeEmptyState();
addThinkingIndicator();
break;
@@ -1051,8 +1058,7 @@ function handleEvent(evt) {
currentAssistantEl = null;
currentReasoningEl = null;
contentBuffer = "";
busy = false;
sendBtn.disabled = false;
setBusy(false);
inputEl.focus();
scrollToBottom(true);
break;
@@ -1087,14 +1093,21 @@ function handleEvent(evt) {
case "error":
addErrorMessage(evt.message);
busy = false;
sendBtn.disabled = false;
setBusy(false);
break;
case "busy_error":
addErrorMessage(evt.message);
busy = false;
sendBtn.disabled = false;
setBusy(false);
break;
case "cancelled":
currentAssistantEl = null;
currentReasoningEl = null;
contentBuffer = "";
setBusy(false);
inputEl.focus();
scrollToBottom(true);
break;
case "connected":
@@ -1598,8 +1611,7 @@ function sendMessage() {
return;
}
busy = true;
sendBtn.disabled = true;
setBusy(true);
addUserMessage(text);
inputEl.value = "";
autoResize();
@@ -1610,11 +1622,24 @@ function sendMessage() {
body: JSON.stringify({ message: text, ws_id: currentWsId }),
}).catch(function (err) {
addErrorMessage("Connection error: " + err.message);
busy = false;
sendBtn.disabled = false;
setBusy(false);
});
}
function cancelGeneration() {
if (!busy || !currentWsId) return;
stopBtn.disabled = true;
authFetch("/v1/api/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ws_id: currentWsId }),
}).catch(function (err) {
addErrorMessage("Cancel error: " + err.message);
stopBtn.disabled = false; // Re-enable only on error so user can retry
});
// On success, button stays disabled until setBusy(false) hides it
}
// --- Textarea auto-resize and keyboard shortcuts ---
function autoResize() {
inputEl.style.height = "auto";
@@ -1654,6 +1679,12 @@ document.addEventListener("keydown", function (e) {
hideDashboard();
return;
}
// Escape: cancel generation when busy
if (e.key === "Escape" && busy && !pendingApproval) {
e.preventDefault();
cancelGeneration();
return;
}
// Ctrl+D: toggle dashboard
if (e.ctrlKey && e.key === "d") {
e.preventDefault();
+2
View File
@@ -87,6 +87,7 @@
<div id="input-area">
<textarea id="input" rows="1" placeholder="Type a message... (Shift+Enter for newline)" aria-label="Message input"></textarea>
<button id="send-btn" onclick="sendMessage()">Send</button>
<button id="stop-btn" onclick="cancelGeneration()" style="display:none" aria-label="Stop generation">&#9632; Stop</button>
</div>
<div id="toast" role="status" aria-live="polite"></div>
@@ -106,6 +107,7 @@ window.TURNSTONE_KB_SHORTCUTS = [
]},
{ title: "Chat", keys: [
{ desc: "Send message", badge: '<span class="kb-key">Enter</span>' },
{ desc: "Stop generation", badge: '<span class="kb-key">Esc</span>' },
{ desc: "New line", badge: '<span class="kb-key">Shift+Enter</span>' }
]},
{ title: "Navigation", keys: [
+3
View File
@@ -303,6 +303,9 @@
}
#input-area button:hover { filter: brightness(1.1); }
#input-area button:disabled { opacity: 0.35; cursor: not-allowed; filter: none; }
#stop-btn { background: var(--red, #c94040); }
#stop-btn:focus-visible { outline: 2px solid var(--fg-bright, #e8ecf4); outline-offset: 2px; }
[data-theme="light"] #stop-btn { color: #fff; }
/* ==========================================================================
Inline approval blocks