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
This commit is contained in:
Patrick Buckley
2026-03-10 19:32:37 -07:00
committed by GitHub
parent d6ba1d5e25
commit 7492816ab2
32 changed files with 7880 additions and 24 deletions
+25
View File
@@ -1340,3 +1340,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.
@@ -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
+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`
+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
+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 });
}
}
+18
View File
@@ -112,6 +112,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,
+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"]
+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
+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):
+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,
]
+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)
+109 -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
# ---------------------------------------------------------------------------
@@ -133,11 +188,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 +309,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 +325,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 +356,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 +598,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 +904,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 +938,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 +1035,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 +1070,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 +1080,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
+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")
+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:
+75 -2
View File
@@ -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()
@@ -877,10 +948,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)
+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();
});