diff --git a/docs/architecture.md b/docs/architecture.md index aab2f20a..c10832ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/diagrams/19-governance-architecture.puml b/docs/diagrams/19-governance-architecture.puml new file mode 100644 index 00000000..2ab2c619 --- /dev/null +++ b/docs/diagrams/19-governance-architecture.puml @@ -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 diff --git a/docs/governance.md b/docs/governance.md new file mode 100644 index 00000000..5a84f140 --- /dev/null +++ b/docs/governance.md @@ -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` diff --git a/docs/security.md b/docs/security.md index 16cf9626..6a81afab 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/sdk/typescript/src/console.ts b/sdk/typescript/src/console.ts index 2e517492..656c49aa 100644 --- a/sdk/typescript/src/console.ts +++ b/sdk/typescript/src/console.ts @@ -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 { + return this.request("POST", "/v1/api/admin/roles", { json: opts }); + } + + async updateRole(roleId: string, opts: UpdateRoleOptions): Promise { + return this.request("PUT", `/v1/api/admin/roles/${roleId}`, { + json: opts, + }); + } + + async deleteRole(roleId: string): Promise { + 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 { + return this.request("POST", `/v1/api/admin/users/${userId}/roles`, { + json: { role_id: roleId }, + }); + } + + async unassignRole(userId: string, roleId: string): Promise { + 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 { + return this.request("GET", `/v1/api/admin/orgs/${orgId}`); + } + + async updateOrg(orgId: string, opts: UpdateOrgOptions): Promise { + 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 { + return this.request("POST", "/v1/api/admin/policies", { json: opts }); + } + + async updatePolicy( + policyId: string, + opts: UpdatePolicyOptions, + ): Promise { + return this.request("PUT", `/v1/api/admin/policies/${policyId}`, { + json: opts, + }); + } + + async deletePolicy(policyId: string): Promise { + 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 { + return this.request("POST", "/v1/api/admin/templates", { json: opts }); + } + + async updateTemplate( + templateId: string, + opts: UpdateTemplateOptions, + ): Promise { + return this.request("PUT", `/v1/api/admin/templates/${templateId}`, { + json: opts, + }); + } + + async deleteTemplate(templateId: string): Promise { + return this.request("DELETE", `/v1/api/admin/templates/${templateId}`); + } + + // -- Governance: Usage & Audit ---------------------------------------------- + + async getUsage(opts: UsageQueryOptions): Promise { + const params: Record = { 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 { + const params: Record = {}; + 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 }); + } } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index eadb4c9b..d14e96ca 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -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, diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index b637668d..e7d2093d 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 00000000..d86814fb --- /dev/null +++ b/tests/test_audit.py @@ -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"] diff --git a/tests/test_governance_endpoints.py b/tests/test_governance_endpoints.py new file mode 100644 index 00000000..5f808a7b --- /dev/null +++ b/tests/test_governance_endpoints.py @@ -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" diff --git a/tests/test_governance_storage.py b/tests/test_governance_storage.py new file mode 100644 index 00000000..fa8b2ccf --- /dev/null +++ b/tests/test_governance_storage.py @@ -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 diff --git a/tests/test_schedule_api.py b/tests/test_schedule_api.py index 27f93807..747ed675 100644 --- a/tests/test_schedule_api.py +++ b/tests/test_schedule_api.py @@ -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) diff --git a/tests/test_tool_policy.py b/tests/test_tool_policy.py new file mode 100644 index 00000000..85d398ce --- /dev/null +++ b/tests/test_tool_policy.py @@ -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" diff --git a/tests/test_user_storage.py b/tests/test_user_storage.py index 7ef3dec4..025b788e 100644 --- a/tests/test_user_storage.py +++ b/tests/test_user_storage.py @@ -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): diff --git a/turnstone/api/console_schemas.py b/turnstone/api/console_schemas.py index 50b80b65..85e0c5cb 100644 --- a/turnstone/api/console_schemas.py +++ b/turnstone/api/console_schemas.py @@ -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 diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py index 2618014f..797e0ecd 100644 --- a/turnstone/api/console_spec.py +++ b/turnstone/api/console_spec.py @@ -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, ] diff --git a/turnstone/console/scheduler.py b/turnstone/console/scheduler.py index 7ce49ce7..5cdc64e8 100644 --- a/turnstone/console/scheduler.py +++ b/turnstone/console/scheduler.py @@ -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] diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 9b91f7b1..2442821d 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -667,9 +667,13 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]: async def admin_list_users(request: Request) -> JSONResponse: """GET /v1/api/admin/users — list all users.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") if err: return err return JSONResponse({"users": storage.list_users()}) @@ -679,10 +683,13 @@ async def admin_create_user(request: Request) -> JSONResponse: """POST /v1/api/admin/users — create a new user.""" import uuid - from turnstone.core.auth import hash_password + from turnstone.core.auth import hash_password, require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") if err: return err @@ -715,6 +722,20 @@ async def admin_create_user(request: Request) -> JSONResponse: user_id = uuid.uuid4().hex pw_hash = hash_password(password) storage.create_user(user_id, username, display_name, pw_hash) + + from turnstone.core.audit import record_audit + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "user.create", + "user", + user_id, + {"username": username}, + ip, + ) + # Read back to get the storage-canonical created timestamp user = storage.get_user(user_id) return JSONResponse( @@ -729,22 +750,48 @@ async def admin_create_user(request: Request) -> JSONResponse: async def admin_delete_user(request: Request) -> JSONResponse: """DELETE /v1/api/admin/users/{user_id} — delete user + cascade tokens.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") if err: return err user_id = request.path_params["user_id"] + # Prevent self-deletion + auth_result = getattr(request.state, "auth_result", None) + if auth_result and auth_result.user_id == user_id: + return JSONResponse({"error": "Cannot delete your own account"}, status_code=400) + # Look up username for the audit trail before deleting + target_user = storage.get_user(user_id) if storage.delete_user(user_id): + from turnstone.core.audit import record_audit + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "user.delete", + "user", + user_id, + {"username": target_user.get("username", "") if target_user else ""}, + ip, + ) return JSONResponse({"status": "ok"}) return JSONResponse({"error": "User not found"}, status_code=404) async def admin_list_tokens(request: Request) -> JSONResponse: """GET /v1/api/admin/users/{user_id}/tokens — list tokens for a user.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") if err: return err user_id = request.path_params["user_id"] @@ -755,10 +802,13 @@ async def admin_create_token(request: Request) -> JSONResponse: """POST /v1/api/admin/users/{user_id}/tokens — create API token.""" import uuid - from turnstone.core.auth import generate_token, hash_token, token_prefix + from turnstone.core.auth import generate_token, hash_token, require_permission, token_prefix from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") if err: return err user_id = request.path_params["user_id"] @@ -804,6 +854,20 @@ async def admin_create_token(request: Request) -> JSONResponse: scopes=scopes, expires=expires, ) + + from turnstone.core.audit import record_audit + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "token.create", + "token", + tid, + {"name": name}, + ip, + ) + return JSONResponse( { "token": raw, @@ -816,13 +880,29 @@ async def admin_create_token(request: Request) -> JSONResponse: async def admin_revoke_token(request: Request) -> JSONResponse: """DELETE /v1/api/admin/tokens/{token_id} — revoke an API token.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") if err: return err token_id = request.path_params["token_id"] if storage.delete_api_token(token_id): + from turnstone.core.audit import record_audit + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "token.revoke", + "token", + token_id, + {}, + ip, + ) return JSONResponse({"status": "ok"}) return JSONResponse({"error": "Token not found"}, status_code=404) @@ -834,9 +914,13 @@ async def admin_revoke_token(request: Request) -> JSONResponse: async def admin_list_channels(request: Request) -> JSONResponse: """GET /v1/api/admin/users/{user_id}/channels — list channel links for a user.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") if err: return err user_id = request.path_params["user_id"] @@ -846,9 +930,13 @@ async def admin_list_channels(request: Request) -> JSONResponse: async def admin_create_channel(request: Request) -> JSONResponse: """POST /v1/api/admin/users/{user_id}/channels — link a channel account.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") if err: return err user_id = request.path_params["user_id"] @@ -891,19 +979,49 @@ async def admin_create_channel(request: Request) -> JSONResponse: {"error": f"Channel user already linked to user {result['user_id']}"}, status_code=409, ) + + from turnstone.core.audit import record_audit + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "channel.link", + "channel", + channel_user_id, + {"channel_type": channel_type, "user_id": user_id}, + ip, + ) + return JSONResponse(result) async def admin_delete_channel(request: Request) -> JSONResponse: """DELETE /v1/api/admin/channels/{channel_type}/{channel_user_id} — unlink.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") if err: return err channel_type = request.path_params["channel_type"] channel_user_id = request.path_params["channel_user_id"] if storage.delete_channel_user(channel_type, channel_user_id): + from turnstone.core.audit import record_audit + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "channel.unlink", + "channel", + channel_user_id, + {"channel_type": channel_type}, + ip, + ) return JSONResponse({"status": "ok"}) return JSONResponse({"error": "Channel link not found"}, status_code=404) @@ -968,9 +1086,13 @@ def _validate_schedule_fields(schedule_type: str, cron_expr: str, at_time: str) async def admin_list_schedules(request: Request) -> JSONResponse: """GET /v1/api/admin/schedules — list all scheduled tasks.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.schedules") if err: return err tasks = storage.list_scheduled_tasks() @@ -983,9 +1105,13 @@ async def admin_create_schedule(request: Request) -> JSONResponse: """POST /v1/api/admin/schedules — create a scheduled task.""" import uuid + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.schedules") if err: return err @@ -1058,9 +1184,13 @@ async def admin_create_schedule(request: Request) -> JSONResponse: async def admin_get_schedule(request: Request) -> JSONResponse: """GET /v1/api/admin/schedules/{task_id} — get single task.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.schedules") if err: return err task_id = request.path_params["task_id"] @@ -1073,9 +1203,13 @@ async def admin_get_schedule(request: Request) -> JSONResponse: async def admin_update_schedule(request: Request) -> JSONResponse: """PUT /v1/api/admin/schedules/{task_id} — partial update.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.schedules") if err: return err task_id = request.path_params["task_id"] @@ -1147,9 +1281,13 @@ async def admin_update_schedule(request: Request) -> JSONResponse: async def admin_delete_schedule(request: Request) -> JSONResponse: """DELETE /v1/api/admin/schedules/{task_id} — delete task + runs.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.schedules") if err: return err task_id = request.path_params["task_id"] @@ -1160,9 +1298,13 @@ async def admin_delete_schedule(request: Request) -> JSONResponse: async def admin_list_schedule_runs(request: Request) -> JSONResponse: """GET /v1/api/admin/schedules/{task_id}/runs — run history.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import require_storage_or_503 storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.schedules") if err: return err task_id = request.path_params["task_id"] @@ -1186,6 +1328,11 @@ async def admin_list_schedule_runs(request: Request) -> JSONResponse: async def admin_list_watches(request: Request) -> JSONResponse: """GET /v1/api/admin/watches — aggregate watches from all nodes.""" + from turnstone.core.auth import require_permission + + err = require_permission(request, "admin.watches") + if err: + return err collector: ClusterCollector = request.app.state.collector nodes, _ = collector.get_nodes(limit=500) client: httpx.AsyncClient = request.app.state.proxy_client @@ -1225,8 +1372,12 @@ _VALID_WATCH_ID = re.compile(r"^[a-fA-F0-9]+$") async def admin_cancel_watch(request: Request) -> Response: """POST /v1/api/admin/watches/{watch_id}/cancel — proxy cancel to the owning node.""" + from turnstone.core.auth import require_permission from turnstone.core.web_helpers import read_json_or_400 + err = require_permission(request, "admin.watches") + if err: + return err watch_id = request.path_params["watch_id"] if not watch_id or not _VALID_WATCH_ID.match(watch_id) or len(watch_id) > 128: return JSONResponse({"error": "Invalid watch_id"}, status_code=400) @@ -1261,6 +1412,827 @@ async def admin_cancel_watch(request: Request) -> Response: return JSONResponse({"error": "Node unreachable"}, status_code=502) +# --------------------------------------------------------------------------- +# Admin API endpoints — governance (roles, orgs, policies, templates, usage, audit) +# --------------------------------------------------------------------------- + + +def _audit_context(request: Request) -> tuple[str, str]: + """Extract (user_id, ip_address) from request for audit logging. + + Honors ``X-Forwarded-For`` only when the request appears to come + through a trusted proxy (``X-Forwarded-Proto`` is set), matching the + existing ``is_secure_request()`` trust model. Falls back to + ``request.client.host`` otherwise. + """ + from turnstone.core.auth import is_secure_request + + auth_result = getattr(request.state, "auth_result", None) + user_id = auth_result.user_id if auth_result else "" + ip = "" + # Only trust X-Forwarded-For when behind a proxy that sets X-Forwarded-Proto + if is_secure_request(dict(request.headers), request.url.scheme): + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + ip = forwarded.split(",")[0].strip() + if not ip: + ip = request.client.host if request.client else "" + return user_id, ip + + +_VALID_PERMISSIONS = frozenset( + { + "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", + } +) + + +async def admin_list_roles(request: Request) -> JSONResponse: + """GET /v1/api/admin/roles — list all roles.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.roles") + if err: + return err + return JSONResponse({"roles": storage.list_roles()}) + + +async def admin_create_role(request: Request) -> JSONResponse: + """POST /v1/api/admin/roles — create a new role.""" + import uuid + + from turnstone.core.audit import record_audit + from turnstone.core.auth import is_valid_username, require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.roles") + if err: + return err + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + name = str(body.get("name", "")).strip()[:128] + display_name = str(body.get("display_name", "")).strip()[:256] + permissions = str(body.get("permissions", "")).strip() + + if not is_valid_username(name): + return JSONResponse( + {"error": "Invalid name (1-64 chars: letters, digits, . _ -)"}, + status_code=400, + ) + if not display_name: + display_name = name + + # Validate permissions against the allowed set + if permissions: + perm_list = [p.strip() for p in permissions.split(",") if p.strip()] + invalid = [p for p in perm_list if p not in _VALID_PERMISSIONS] + if invalid: + return JSONResponse( + {"error": f"Invalid permissions: {', '.join(invalid)}"}, + status_code=400, + ) + + # Check for duplicate name + if storage.get_role_by_name(name) is not None: + return JSONResponse({"error": f"Role '{name}' already exists"}, status_code=409) + + role_id = uuid.uuid4().hex + storage.create_role( + role_id=role_id, + name=name, + display_name=display_name, + permissions=permissions, + builtin=False, + org_id="", + ) + + audit_uid, ip = _audit_context(request) + record_audit(storage, audit_uid, "role.create", "role", role_id, {"name": name}, ip) + + role = storage.get_role(role_id) + if role is None: + return JSONResponse({"error": "Role creation failed"}, status_code=500) + return JSONResponse(role) + + +async def admin_update_role(request: Request) -> JSONResponse: + """PUT /v1/api/admin/roles/{role_id} — update a custom role.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.roles") + if err: + return err + + role_id = request.path_params["role_id"] + existing = storage.get_role(role_id) + if existing is None: + return JSONResponse({"error": "Role not found"}, status_code=404) + if existing.get("builtin"): + return JSONResponse({"error": "Cannot modify builtin role"}, status_code=400) + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + updates: dict[str, Any] = {} + if "display_name" in body: + updates["display_name"] = str(body["display_name"]).strip()[:256] + if "permissions" in body: + raw_perms = str(body["permissions"]).strip() + if raw_perms: + perm_list = [p.strip() for p in raw_perms.split(",") if p.strip()] + invalid = [p for p in perm_list if p not in _VALID_PERMISSIONS] + if invalid: + return JSONResponse( + {"error": f"Invalid permissions: {', '.join(invalid)}"}, + status_code=400, + ) + updates["permissions"] = raw_perms + + storage.update_role(role_id, **updates) + + audit_uid, ip = _audit_context(request) + record_audit(storage, audit_uid, "role.update", "role", role_id, updates, ip) + + role = storage.get_role(role_id) + return JSONResponse(role) + + +async def admin_delete_role(request: Request) -> JSONResponse: + """DELETE /v1/api/admin/roles/{role_id} — delete a custom role.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.roles") + if err: + return err + + role_id = request.path_params["role_id"] + existing = storage.get_role(role_id) + if existing is None: + return JSONResponse({"error": "Role not found"}, status_code=404) + if existing.get("builtin"): + return JSONResponse({"error": "Cannot delete builtin role"}, status_code=400) + + storage.delete_role(role_id) + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "role.delete", + "role", + role_id, + {"name": existing.get("name", "")}, + ip, + ) + + return JSONResponse({"status": "ok"}) + + +async def admin_list_user_roles(request: Request) -> JSONResponse: + """GET /v1/api/admin/users/{user_id}/roles — list roles assigned to a user.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") + if err: + return err + + user_id = request.path_params["user_id"] + return JSONResponse({"roles": storage.list_user_roles(user_id)}) + + +async def admin_assign_role(request: Request) -> JSONResponse: + """POST /v1/api/admin/users/{user_id}/roles — assign a role to a user.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") + if err: + return err + + user_id = request.path_params["user_id"] + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + role_id = str(body.get("role_id", "")).strip() + if not role_id: + return JSONResponse({"error": "role_id is required"}, status_code=400) + + audit_uid, ip = _audit_context(request) + + # Validate that user exists + if storage.get_user(user_id) is None: + return JSONResponse({"error": "User not found"}, status_code=404) + + # Validate that role exists + target_role = storage.get_role(role_id) + if target_role is None: + return JSONResponse({"error": "Role not found"}, status_code=404) + + # Prevent self-assignment + auth_result = getattr(request.state, "auth_result", None) + if auth_result and auth_result.user_id == user_id: + return JSONResponse({"error": "Cannot modify own role assignments"}, status_code=403) + + # Ensure caller holds all permissions present in the target role + target_perms = set( + p.strip() for p in target_role.get("permissions", "").split(",") if p.strip() + ) + if ( + auth_result + and auth_result.permissions + and not target_perms.issubset(auth_result.permissions) + ): + return JSONResponse( + {"error": "Cannot assign role with permissions you do not hold"}, + status_code=403, + ) + + storage.assign_role(user_id, role_id, assigned_by=audit_uid) + record_audit( + storage, + audit_uid, + "role.assign", + "user", + user_id, + {"role_id": role_id}, + ip, + ) + + return JSONResponse({"status": "ok"}) + + +async def admin_unassign_role(request: Request) -> JSONResponse: + """DELETE /v1/api/admin/users/{user_id}/roles/{role_id} — unassign a role.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") + if err: + return err + + user_id = request.path_params["user_id"] + role_id = request.path_params["role_id"] + + audit_uid, ip = _audit_context(request) + + # Prevent self-modification + if audit_uid and audit_uid == user_id: + return JSONResponse({"error": "Cannot modify own role assignments"}, status_code=403) + + if storage.unassign_role(user_id, role_id): + record_audit( + storage, + audit_uid, + "role.unassign", + "user", + user_id, + {"role_id": role_id}, + ip, + ) + return JSONResponse({"status": "ok"}) + return JSONResponse({"error": "Role assignment not found"}, status_code=404) + + +async def admin_list_orgs(request: Request) -> JSONResponse: + """GET /v1/api/admin/orgs — list all organizations.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.orgs") + if err: + return err + return JSONResponse({"orgs": storage.list_orgs()}) + + +async def admin_get_org(request: Request) -> JSONResponse: + """GET /v1/api/admin/orgs/{org_id} — get a single organization.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.orgs") + if err: + return err + + org_id = request.path_params["org_id"] + org = storage.get_org(org_id) + if org is None: + return JSONResponse({"error": "Organization not found"}, status_code=404) + return JSONResponse(org) + + +async def admin_update_org(request: Request) -> JSONResponse: + """PUT /v1/api/admin/orgs/{org_id} — update an organization.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.orgs") + if err: + return err + + org_id = request.path_params["org_id"] + existing = storage.get_org(org_id) + if existing is None: + return JSONResponse({"error": "Organization not found"}, status_code=404) + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + updates: dict[str, Any] = {} + if "display_name" in body: + updates["display_name"] = str(body["display_name"]).strip()[:256] + if "settings" in body: + settings_str = str(body["settings"]).strip() + try: + json.loads(settings_str) + except (json.JSONDecodeError, TypeError): + return JSONResponse({"error": "settings must be valid JSON"}, status_code=400) + updates["settings"] = settings_str + + storage.update_org(org_id, **updates) + + audit_uid, ip = _audit_context(request) + record_audit(storage, audit_uid, "org.update", "org", org_id, updates, ip) + + org = storage.get_org(org_id) + return JSONResponse(org) + + +async def admin_list_policies(request: Request) -> JSONResponse: + """GET /v1/api/admin/policies — list all tool policies.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.policies") + if err: + return err + return JSONResponse({"policies": storage.list_tool_policies()}) + + +async def admin_create_policy(request: Request) -> JSONResponse: + """POST /v1/api/admin/policies — create a tool policy.""" + import uuid + + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.policies") + if err: + return err + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + name = str(body.get("name", "")).strip()[:256] + tool_pattern = str(body.get("tool_pattern", "")).strip()[:256] + action = str(body.get("action", "")).strip().lower() + priority = int(body.get("priority", 0)) if isinstance(body.get("priority"), (int, float)) else 0 + org_id = str(body.get("org_id", "")).strip()[:64] + enabled = bool(body.get("enabled", True)) + + if not name: + return JSONResponse({"error": "name is required"}, status_code=400) + if not tool_pattern: + return JSONResponse({"error": "tool_pattern is required"}, status_code=400) + if action not in ("allow", "deny", "ask"): + return JSONResponse( + {"error": "action must be one of: allow, deny, ask"}, + status_code=400, + ) + + audit_uid, ip = _audit_context(request) + + policy_id = uuid.uuid4().hex + storage.create_tool_policy( + policy_id=policy_id, + name=name, + tool_pattern=tool_pattern, + action=action, + priority=priority, + org_id=org_id, + enabled=enabled, + created_by=audit_uid, + ) + + record_audit( + storage, + audit_uid, + "policy.create", + "policy", + policy_id, + {"name": name}, + ip, + ) + + policy = storage.get_tool_policy(policy_id) + return JSONResponse(policy) + + +async def admin_update_policy(request: Request) -> JSONResponse: + """PUT /v1/api/admin/policies/{policy_id} — update a tool policy.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.policies") + if err: + return err + + policy_id = request.path_params["policy_id"] + existing = storage.get_tool_policy(policy_id) + if existing is None: + return JSONResponse({"error": "Policy not found"}, status_code=404) + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + updates: dict[str, Any] = {} + if "name" in body: + updates["name"] = str(body["name"]).strip()[:256] + if "tool_pattern" in body: + updates["tool_pattern"] = str(body["tool_pattern"]).strip()[:256] + if "action" in body: + act = str(body["action"]).strip().lower() + if act not in ("allow", "deny", "ask"): + return JSONResponse( + {"error": "action must be one of: allow, deny, ask"}, + status_code=400, + ) + updates["action"] = act + if "priority" in body: + updates["priority"] = ( + int(body["priority"]) if isinstance(body["priority"], (int, float)) else 0 + ) + if "enabled" in body: + updates["enabled"] = bool(body["enabled"]) + + storage.update_tool_policy(policy_id, **updates) + + audit_uid, ip = _audit_context(request) + record_audit(storage, audit_uid, "policy.update", "policy", policy_id, updates, ip) + + policy = storage.get_tool_policy(policy_id) + return JSONResponse(policy) + + +async def admin_delete_policy(request: Request) -> JSONResponse: + """DELETE /v1/api/admin/policies/{policy_id} — delete a tool policy.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.policies") + if err: + return err + + policy_id = request.path_params["policy_id"] + existing = storage.get_tool_policy(policy_id) + if existing is None: + return JSONResponse({"error": "Policy not found"}, status_code=404) + + storage.delete_tool_policy(policy_id) + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "policy.delete", + "policy", + policy_id, + {"name": existing.get("name", "")}, + ip, + ) + + return JSONResponse({"status": "ok"}) + + +async def admin_list_templates(request: Request) -> JSONResponse: + """GET /v1/api/admin/templates — list all prompt templates.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.templates") + if err: + return err + return JSONResponse({"templates": storage.list_prompt_templates()}) + + +async def admin_create_template(request: Request) -> JSONResponse: + """POST /v1/api/admin/templates — create a prompt template.""" + import uuid + + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.templates") + if err: + return err + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + name = str(body.get("name", "")).strip()[:256] + content = str(body.get("content", "")).strip() + category = str(body.get("category", "general")).strip()[:64] + variables = str(body.get("variables", "[]")).strip() + try: + json.loads(variables) + except (json.JSONDecodeError, TypeError): + return JSONResponse({"error": "variables must be a valid JSON array"}, status_code=400) + is_default = bool(body.get("is_default", False)) + org_id = str(body.get("org_id", "")).strip()[:64] + + if not name: + return JSONResponse({"error": "name is required"}, status_code=400) + if not content: + return JSONResponse({"error": "content is required"}, status_code=400) + + audit_uid, ip = _audit_context(request) + + template_id = uuid.uuid4().hex + storage.create_prompt_template( + template_id=template_id, + name=name, + category=category, + content=content, + variables=variables, + is_default=is_default, + org_id=org_id, + created_by=audit_uid, + ) + + record_audit( + storage, + audit_uid, + "template.create", + "template", + template_id, + {"name": name}, + ip, + ) + + template = storage.get_prompt_template(template_id) + return JSONResponse(template) + + +async def admin_update_template(request: Request) -> JSONResponse: + """PUT /v1/api/admin/templates/{template_id} — update a prompt template.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.templates") + if err: + return err + + template_id = request.path_params["template_id"] + existing = storage.get_prompt_template(template_id) + if existing is None: + return JSONResponse({"error": "Template not found"}, status_code=404) + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + updates: dict[str, Any] = {} + if "name" in body: + updates["name"] = str(body["name"]).strip()[:256] + if "content" in body: + updates["content"] = str(body["content"]).strip() + if "category" in body: + updates["category"] = str(body["category"]).strip()[:64] + if "variables" in body: + var_str = str(body["variables"]).strip() + try: + json.loads(var_str) + except (json.JSONDecodeError, TypeError): + return JSONResponse({"error": "variables must be a valid JSON array"}, status_code=400) + updates["variables"] = var_str + if "is_default" in body: + updates["is_default"] = bool(body["is_default"]) + + storage.update_prompt_template(template_id, **updates) + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "template.update", + "template", + template_id, + updates, + ip, + ) + + template = storage.get_prompt_template(template_id) + return JSONResponse(template) + + +async def admin_delete_template(request: Request) -> JSONResponse: + """DELETE /v1/api/admin/templates/{template_id} — delete a prompt template.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.templates") + if err: + return err + + template_id = request.path_params["template_id"] + existing = storage.get_prompt_template(template_id) + if existing is None: + return JSONResponse({"error": "Template not found"}, status_code=404) + + storage.delete_prompt_template(template_id) + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "template.delete", + "template", + template_id, + {"name": existing.get("name", "")}, + ip, + ) + + return JSONResponse({"status": "ok"}) + + +async def admin_usage(request: Request) -> JSONResponse: + """GET /v1/api/admin/usage — query usage data.""" + from datetime import UTC, datetime, timedelta + + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.usage") + if err: + return err + + params = dict(request.query_params) + since = params.get("since", "") + until = params.get("until", "") + user_id = params.get("user_id", "") + model = params.get("model", "") + group_by = params.get("group_by", "day") + + if group_by not in ("day", "hour", "model", "user"): + return JSONResponse( + {"error": "group_by must be one of: day, hour, model, user"}, + status_code=400, + ) + + if not since: + since = (datetime.now(UTC) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%S") + + summary = storage.query_usage(since=since, until=until, user_id=user_id, model=model) + breakdown = storage.query_usage( + since=since, + until=until, + user_id=user_id, + model=model, + group_by=group_by, + ) + + return JSONResponse({"summary": summary, "breakdown": breakdown}) + + +async def admin_audit(request: Request) -> JSONResponse: + """GET /v1/api/admin/audit — query audit events.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.audit") + if err: + return err + + params = dict(request.query_params) + action = params.get("action", "") + user_id = params.get("user_id", "") + since = params.get("since", "") + until = params.get("until", "") + try: + limit = min(int(params.get("limit", "50")), 200) + except (ValueError, TypeError): + limit = 50 + try: + offset = max(int(params.get("offset", "0")), 0) + except (ValueError, TypeError): + offset = 0 + + events = storage.list_audit_events( + action=action, + user_id=user_id, + since=since, + until=until, + limit=limit, + offset=offset, + ) + total = storage.count_audit_events( + action=action, + user_id=user_id, + since=since, + until=until, + ) + + return JSONResponse({"events": events, "total": total}) + + # --------------------------------------------------------------------------- # App factory # --------------------------------------------------------------------------- @@ -1337,6 +2309,55 @@ def create_app( admin_cancel_watch, methods=["POST"], ), + # Governance: 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"]), + 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"], + ), + # Governance: 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"]), + # Governance: Tool 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"], + ), + # Governance: Prompt 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"], + ), + # Governance: Usage & Audit + Route("/api/admin/usage", admin_usage), + Route("/api/admin/audit", admin_audit), ], ), Route("/health", health), diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index a98b4e97..262a8423 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -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) + "" + '' + + '' + '' + + ''; + html += + '
' + + '' + + escapeHtml(r.display_name) + + " " + + typeLabel + + "" + + '' + + badges + + "" + + '' + + actions + + "
"; + } + el.innerHTML = html; + // Bind edit + var editBtns = el.querySelectorAll("[data-edit-role]"); + for (var k = 0; k < editBtns.length; k++) { + editBtns[k].addEventListener("click", function () { + showEditRoleModal(this.getAttribute("data-edit-role")); + }); + } + // Bind delete + var delBtns = el.querySelectorAll("[data-delete-role]"); + for (var k = 0; k < delBtns.length; k++) { + delBtns[k].addEventListener("click", function () { + var rid = this.getAttribute("data-delete-role"); + var rname = this.getAttribute("data-role-name"); + showConfirmModal( + "Delete Role", + 'Delete role "' + + rname + + '"? Users with this role will lose its permissions.', + "Delete", + function () { + authFetch("/v1/api/admin/roles/" + rid, { method: "DELETE" }) + .then(function (r) { + if (!r.ok) throw new Error(); + return r.json(); + }) + .then(function () { + showToast("Role deleted"); + loadGovRoles(); + }) + .catch(function () { + showToast("Failed to delete role"); + }); + }, + ); + }); + } +} + +// All permission names for the checkbox UI +var _ALL_PERMISSIONS = [ + "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", +]; + +function _buildPermCheckboxes(prefix, selected) { + var html = '
'; + for (var i = 0; i < _ALL_PERMISSIONS.length; i++) { + var p = _ALL_PERMISSIONS[i]; + var checked = selected && selected.indexOf(p) >= 0 ? " checked" : ""; + html += + '"; + } + html += "
"; + return html; +} + +function _collectPermCheckboxes(prefix) { + var boxes = document.querySelectorAll( + 'input[name="' + prefix + '-perm"]:checked', + ); + var perms = []; + for (var i = 0; i < boxes.length; i++) perms.push(boxes[i].value); + return perms.join(","); +} + +function showCreateRoleModal() { + _crTriggerEl = document.activeElement; + var ov = document.getElementById("create-role-overlay"); + ov.style.display = "flex"; + document.getElementById("cr-name").value = ""; + document.getElementById("cr-displayname").value = ""; + document.getElementById("cr-perms-container").innerHTML = + _buildPermCheckboxes("cr", []); + document.getElementById("create-role-error").style.display = "none"; + document.getElementById("cr-name").focus(); + _crTrapHandler = _installTrap("create-role-overlay", "create-role-box"); +} + +function hideCreateRoleModal() { + document.getElementById("create-role-overlay").style.display = "none"; + _crTrapHandler = _removeTrap(_crTrapHandler); + if (_crTriggerEl && _crTriggerEl.focus) { + _crTriggerEl.focus(); + } + _crTriggerEl = null; +} + +function submitCreateRole() { + var name = document.getElementById("cr-name").value.trim(); + var dname = document.getElementById("cr-displayname").value.trim(); + var perms = _collectPermCheckboxes("cr"); + if (!name) { + var e = document.getElementById("create-role-error"); + e.textContent = "Name is required"; + e.style.display = ""; + return; + } + if (!dname) dname = name; + document.getElementById("cr-submit").disabled = true; + authFetch("/v1/api/admin/roles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name, + display_name: dname, + permissions: perms, + }), + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function () { + hideCreateRoleModal(); + showToast("Role created"); + loadGovRoles(); + }) + .catch(function (e) { + var el = document.getElementById("create-role-error"); + el.textContent = e.message; + el.style.display = ""; + }) + .finally(function () { + document.getElementById("cr-submit").disabled = false; + }); +} + +function showEditRoleModal(roleId) { + _erTriggerEl = document.activeElement; + var role = null; + for (var i = 0; i < _govRoles.length; i++) { + if (_govRoles[i].role_id === roleId) { + role = _govRoles[i]; + break; + } + } + if (!role) return; + var ov = document.getElementById("edit-role-overlay"); + ov.style.display = "flex"; + document.getElementById("er-id").value = roleId; + document.getElementById("er-name").value = role.display_name; + var selected = (role.permissions || "").split(","); + document.getElementById("er-perms-container").innerHTML = + _buildPermCheckboxes("er", selected); + document.getElementById("edit-role-error").style.display = "none"; + _erTrapHandler = _installTrap("edit-role-overlay", "edit-role-box"); +} + +function hideEditRoleModal() { + document.getElementById("edit-role-overlay").style.display = "none"; + _erTrapHandler = _removeTrap(_erTrapHandler); + if (_erTriggerEl && _erTriggerEl.focus) { + _erTriggerEl.focus(); + } + _erTriggerEl = null; +} + +function submitEditRole() { + var roleId = document.getElementById("er-id").value; + var dname = document.getElementById("er-name").value.trim(); + var perms = _collectPermCheckboxes("er"); + document.getElementById("er-submit").disabled = true; + authFetch("/v1/api/admin/roles/" + roleId, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ display_name: dname, permissions: perms }), + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function () { + hideEditRoleModal(); + showToast("Role updated"); + loadGovRoles(); + }) + .catch(function (e) { + var el = document.getElementById("edit-role-error"); + el.textContent = e.message; + el.style.display = ""; + }) + .finally(function () { + document.getElementById("er-submit").disabled = false; + }); +} + +// User roles modal (launched from Users tab) +function showUserRolesModal(userId) { + _urTriggerEl = document.activeElement; + var ov = document.getElementById("user-roles-overlay"); + ov.style.display = "flex"; + document.getElementById("ur-user-id").value = userId; + var container = document.getElementById("ur-roles-container"); + container.innerHTML = '
Loading...
'; + _urTrapHandler = _installTrap("user-roles-overlay", "user-roles-box"); + // Fetch all roles and user's current roles + Promise.all([ + authFetch("/v1/api/admin/roles").then(function (r) { + return r.json(); + }), + authFetch("/v1/api/admin/users/" + userId + "/roles").then(function (r) { + return r.json(); + }), + ]) + .then(function (results) { + var allRoles = results[0].roles || []; + var userRoles = results[1].roles || []; + var assigned = {}; + for (var i = 0; i < userRoles.length; i++) + assigned[userRoles[i].role_id] = true; + var html = ""; + for (var j = 0; j < allRoles.length; j++) { + var r = allRoles[j]; + var checked = assigned[r.role_id] ? " checked" : ""; + html += + '"; + } + container.innerHTML = html; + }) + .catch(function () { + container.innerHTML = + '
Failed to load roles
'; + }); +} + +function hideUserRolesModal() { + document.getElementById("user-roles-overlay").style.display = "none"; + _urTrapHandler = _removeTrap(_urTrapHandler); + if (_urTriggerEl && _urTriggerEl.focus) { + _urTriggerEl.focus(); + } + _urTriggerEl = null; +} + +function submitUserRoles() { + var userId = document.getElementById("ur-user-id").value; + var boxes = document.querySelectorAll('input[name="ur-role"]'); + var selected = []; + for (var i = 0; i < boxes.length; i++) { + if (boxes[i].checked) selected.push(boxes[i].value); + } + // Get current user roles to diff + authFetch("/v1/api/admin/users/" + userId + "/roles") + .then(function (r) { + return r.json(); + }) + .then(function (data) { + var current = {}; + var roles = data.roles || []; + for (var i = 0; i < roles.length; i++) current[roles[i].role_id] = true; + var promises = []; + // Assign new + for (var j = 0; j < selected.length; j++) { + if (!current[selected[j]]) { + promises.push( + authFetch("/v1/api/admin/users/" + userId + "/roles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role_id: selected[j] }), + }), + ); + } + } + // Unassign removed + var selMap = {}; + for (var k = 0; k < selected.length; k++) selMap[selected[k]] = true; + for (var rid in current) { + if (!selMap[rid]) { + promises.push( + authFetch("/v1/api/admin/users/" + userId + "/roles/" + rid, { + method: "DELETE", + }), + ); + } + } + return Promise.all(promises); + }) + .then(function () { + hideUserRolesModal(); + showToast("Roles updated"); + }) + .catch(function () { + showToast("Failed to update roles"); + }); +} + +// --------------------------------------------------------------------------- +// Tool Policies +// --------------------------------------------------------------------------- + +function loadGovPolicies() { + authFetch("/v1/api/admin/policies") + .then(function (r) { + if (!r.ok) throw new Error("Failed"); + return r.json(); + }) + .then(function (data) { + _govPolicies = data.policies || []; + _renderGovPolicies(_govPolicies); + }) + .catch(function () { + document.getElementById("admin-policies-table").innerHTML = + '
Failed to load policies
'; + }); +} + +function _renderGovPolicies(items) { + var el = document.getElementById("admin-policies-table"); + if (!items.length) { + el.innerHTML = + '
No tool policies defined
'; + return; + } + var html = ""; + for (var i = 0; i < items.length; i++) { + var p = items[i]; + var actionCls = "policy-badge policy-" + p.action; + var statusDot = p.enabled + ? '\u25CF active' + : '\u25CB disabled'; + html += + '
' + + '' + + escapeHtml(p.name) + + "" + + '' + + escapeHtml(p.tool_pattern) + + "" + + '' + + escapeHtml(p.action) + + "" + + '' + + p.priority + + "" + + '' + + statusDot + + "" + + '' + + '' + + '' + + "
"; + } + el.innerHTML = html; + el.querySelectorAll("[data-edit-policy]").forEach(function (btn) { + btn.addEventListener("click", function () { + showEditPolicyModal(this.getAttribute("data-edit-policy")); + }); + }); + el.querySelectorAll("[data-delete-policy]").forEach(function (btn) { + btn.addEventListener("click", function () { + var pid = this.getAttribute("data-delete-policy"); + var pname = this.getAttribute("data-policy-name"); + showConfirmModal( + "Delete Policy", + 'Delete policy "' + pname + '"?', + "Delete", + function () { + authFetch("/v1/api/admin/policies/" + pid, { method: "DELETE" }) + .then(function (r) { + if (!r.ok) throw new Error(); + return r.json(); + }) + .then(function () { + showToast("Policy deleted"); + loadGovPolicies(); + }) + .catch(function () { + showToast("Failed to delete policy"); + }); + }, + ); + }); + }); +} + +function showCreatePolicyModal() { + _cpTriggerEl = document.activeElement; + var ov = document.getElementById("create-policy-overlay"); + ov.style.display = "flex"; + document.getElementById("cp-name").value = ""; + document.getElementById("cp-pattern").value = ""; + document.getElementById("cp-action").value = "ask"; + document.getElementById("cp-priority").value = "0"; + document.getElementById("create-policy-error").style.display = "none"; + document.getElementById("cp-name").focus(); + _cpTrapHandler = _installTrap("create-policy-overlay", "create-policy-box"); +} + +function hideCreatePolicyModal() { + document.getElementById("create-policy-overlay").style.display = "none"; + _cpTrapHandler = _removeTrap(_cpTrapHandler); + if (_cpTriggerEl && _cpTriggerEl.focus) { + _cpTriggerEl.focus(); + } + _cpTriggerEl = null; +} + +function submitCreatePolicy() { + var name = document.getElementById("cp-name").value.trim(); + var pattern = document.getElementById("cp-pattern").value.trim(); + var action = document.getElementById("cp-action").value; + var priority = + parseInt(document.getElementById("cp-priority").value, 10) || 0; + if (!name || !pattern) { + var e = document.getElementById("create-policy-error"); + e.textContent = "Name and pattern are required"; + e.style.display = ""; + return; + } + document.getElementById("cp-submit").disabled = true; + authFetch("/v1/api/admin/policies", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name, + tool_pattern: pattern, + action: action, + priority: priority, + }), + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function () { + hideCreatePolicyModal(); + showToast("Policy created"); + loadGovPolicies(); + }) + .catch(function (e) { + var el = document.getElementById("create-policy-error"); + el.textContent = e.message; + el.style.display = ""; + }) + .finally(function () { + document.getElementById("cp-submit").disabled = false; + }); +} + +function showEditPolicyModal(policyId) { + _epTriggerEl = document.activeElement; + var policy = null; + for (var i = 0; i < _govPolicies.length; i++) { + if (_govPolicies[i].policy_id === policyId) { + policy = _govPolicies[i]; + break; + } + } + if (!policy) return; + var ov = document.getElementById("edit-policy-overlay"); + ov.style.display = "flex"; + document.getElementById("ep-id").value = policyId; + document.getElementById("ep-name").value = policy.name; + document.getElementById("ep-pattern").value = policy.tool_pattern; + document.getElementById("ep-action").value = policy.action; + document.getElementById("ep-priority").value = policy.priority; + document.getElementById("ep-enabled").checked = policy.enabled; + document.getElementById("edit-policy-error").style.display = "none"; + _epTrapHandler = _installTrap("edit-policy-overlay", "edit-policy-box"); +} + +function hideEditPolicyModal() { + document.getElementById("edit-policy-overlay").style.display = "none"; + _epTrapHandler = _removeTrap(_epTrapHandler); + if (_epTriggerEl && _epTriggerEl.focus) { + _epTriggerEl.focus(); + } + _epTriggerEl = null; +} + +function submitEditPolicy() { + var id = document.getElementById("ep-id").value; + document.getElementById("ep-submit").disabled = true; + authFetch("/v1/api/admin/policies/" + id, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: document.getElementById("ep-name").value.trim(), + tool_pattern: document.getElementById("ep-pattern").value.trim(), + action: document.getElementById("ep-action").value, + priority: parseInt(document.getElementById("ep-priority").value, 10) || 0, + enabled: document.getElementById("ep-enabled").checked, + }), + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function () { + hideEditPolicyModal(); + showToast("Policy updated"); + loadGovPolicies(); + }) + .catch(function (e) { + var el = document.getElementById("edit-policy-error"); + el.textContent = e.message; + el.style.display = ""; + }) + .finally(function () { + document.getElementById("ep-submit").disabled = false; + }); +} + +// --------------------------------------------------------------------------- +// Prompt Templates +// --------------------------------------------------------------------------- + +function loadGovTemplates() { + authFetch("/v1/api/admin/templates") + .then(function (r) { + if (!r.ok) throw new Error("Failed"); + return r.json(); + }) + .then(function (data) { + _govTemplates = data.templates || []; + _renderGovTemplates(_govTemplates); + }) + .catch(function () { + document.getElementById("admin-templates-table").innerHTML = + '
Failed to load templates
'; + }); +} + +function _renderGovTemplates(items) { + var el = document.getElementById("admin-templates-table"); + if (!items.length) { + el.innerHTML = + '
No prompt templates defined
'; + return; + } + var html = ""; + for (var i = 0; i < items.length; i++) { + var t = items[i]; + var vars = ""; + try { + var vlist = JSON.parse(t.variables || "[]"); + vars = vlist.join(", "); + } catch (e) { + vars = t.variables; + } + var defBadge = t.is_default + ? 'default' + : ""; + var catBadge = + '' + escapeHtml(t.category) + ""; + html += + '
' + + '' + + escapeHtml(t.name) + + " " + + defBadge + + "" + + '' + + catBadge + + "" + + '' + + escapeHtml(vars || "\u2014") + + "" + + '' + + '' + + '' + + "
"; + } + el.innerHTML = html; + el.querySelectorAll("[data-edit-tmpl]").forEach(function (btn) { + btn.addEventListener("click", function () { + showEditTemplateModal(this.getAttribute("data-edit-tmpl")); + }); + }); + el.querySelectorAll("[data-delete-tmpl]").forEach(function (btn) { + btn.addEventListener("click", function () { + var tid = this.getAttribute("data-delete-tmpl"); + var tname = this.getAttribute("data-tmpl-name"); + showConfirmModal( + "Delete Template", + 'Delete template "' + tname + '"?', + "Delete", + function () { + authFetch("/v1/api/admin/templates/" + tid, { method: "DELETE" }) + .then(function (r) { + if (!r.ok) throw new Error(); + return r.json(); + }) + .then(function () { + showToast("Template deleted"); + loadGovTemplates(); + }) + .catch(function () { + showToast("Failed to delete template"); + }); + }, + ); + }); + }); +} + +function showCreateTemplateModal() { + _ctmTriggerEl = document.activeElement; + var ov = document.getElementById("create-template-overlay"); + ov.style.display = "flex"; + document.getElementById("ctm-name").value = ""; + document.getElementById("ctm-category").value = "general"; + document.getElementById("ctm-content").value = ""; + document.getElementById("ctm-variables").value = ""; + document.getElementById("ctm-default").checked = false; + document.getElementById("create-template-error").style.display = "none"; + document.getElementById("ctm-name").focus(); + _ctmTrapHandler = _installTrap( + "create-template-overlay", + "create-template-box", + ); +} + +function hideCreateTemplateModal() { + document.getElementById("create-template-overlay").style.display = "none"; + _ctmTrapHandler = _removeTrap(_ctmTrapHandler); + if (_ctmTriggerEl && _ctmTriggerEl.focus) { + _ctmTriggerEl.focus(); + } + _ctmTriggerEl = null; +} + +function submitCreateTemplate() { + var name = document.getElementById("ctm-name").value.trim(); + var content = document.getElementById("ctm-content").value; + if (!name || !content) { + var e = document.getElementById("create-template-error"); + e.textContent = "Name and content are required"; + e.style.display = ""; + return; + } + var vars = document.getElementById("ctm-variables").value.trim(); + var varList = vars + ? vars.split(",").map(function (s) { + return s.trim(); + }) + : []; + document.getElementById("ctm-submit").disabled = true; + authFetch("/v1/api/admin/templates", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name, + category: document.getElementById("ctm-category").value, + content: content, + variables: JSON.stringify(varList), + is_default: document.getElementById("ctm-default").checked, + }), + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function () { + hideCreateTemplateModal(); + showToast("Template created"); + loadGovTemplates(); + }) + .catch(function (e) { + var el = document.getElementById("create-template-error"); + el.textContent = e.message; + el.style.display = ""; + }) + .finally(function () { + document.getElementById("ctm-submit").disabled = false; + }); +} + +function showEditTemplateModal(tmplId) { + _etmTriggerEl = document.activeElement; + var tmpl = null; + for (var i = 0; i < _govTemplates.length; i++) { + if (_govTemplates[i].template_id === tmplId) { + tmpl = _govTemplates[i]; + break; + } + } + if (!tmpl) return; + var ov = document.getElementById("edit-template-overlay"); + ov.style.display = "flex"; + document.getElementById("etm-id").value = tmplId; + document.getElementById("etm-name").value = tmpl.name; + document.getElementById("etm-category").value = tmpl.category; + document.getElementById("etm-content").value = tmpl.content; + var vars = ""; + try { + vars = JSON.parse(tmpl.variables || "[]").join(", "); + } catch (e) { + vars = tmpl.variables; + } + document.getElementById("etm-variables").value = vars; + document.getElementById("etm-default").checked = tmpl.is_default; + document.getElementById("edit-template-error").style.display = "none"; + _etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box"); +} + +function hideEditTemplateModal() { + document.getElementById("edit-template-overlay").style.display = "none"; + _etmTrapHandler = _removeTrap(_etmTrapHandler); + if (_etmTriggerEl && _etmTriggerEl.focus) { + _etmTriggerEl.focus(); + } + _etmTriggerEl = null; +} + +function submitEditTemplate() { + var id = document.getElementById("etm-id").value; + var content = document.getElementById("etm-content").value; + var vars = document.getElementById("etm-variables").value.trim(); + var varList = vars + ? vars.split(",").map(function (s) { + return s.trim(); + }) + : []; + document.getElementById("etm-submit").disabled = true; + authFetch("/v1/api/admin/templates/" + id, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: document.getElementById("etm-name").value.trim(), + category: document.getElementById("etm-category").value, + content: content, + variables: JSON.stringify(varList), + is_default: document.getElementById("etm-default").checked, + }), + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function () { + hideEditTemplateModal(); + showToast("Template updated"); + loadGovTemplates(); + }) + .catch(function (e) { + var el = document.getElementById("edit-template-error"); + el.textContent = e.message; + el.style.display = ""; + }) + .finally(function () { + document.getElementById("etm-submit").disabled = false; + }); +} + +// --------------------------------------------------------------------------- +// Usage +// --------------------------------------------------------------------------- + +function loadGovUsage() { + var now = new Date(); + var since; + if (_govUsageRange === "24h") since = new Date(now - 24 * 60 * 60 * 1000); + else if (_govUsageRange === "30d") + since = new Date(now - 30 * 24 * 60 * 60 * 1000); + else since = new Date(now - 7 * 24 * 60 * 60 * 1000); + var sinceStr = since.toISOString().slice(0, 19); + + // Fetch summary + breakdown in parallel + var summaryUrl = "/v1/api/admin/usage?since=" + encodeURIComponent(sinceStr); + var breakdownUrl = summaryUrl + "&group_by=" + _govUsageGroupBy; + + Promise.all([ + authFetch(summaryUrl).then(function (r) { + return r.json(); + }), + authFetch(breakdownUrl).then(function (r) { + return r.json(); + }), + ]) + .then(function (results) { + _renderGovUsage(results[0], results[1]); + }) + .catch(function () { + document.getElementById("admin-usage-content").innerHTML = + '
Failed to load usage data
'; + }); +} + +function _renderGovUsage(summary, breakdown) { + var container = document.getElementById("admin-usage-content"); + var s = (summary.breakdown && summary.breakdown[0]) || {}; + var prompt = s.prompt_tokens || 0; + var completion = s.completion_tokens || 0; + var total = prompt + completion; + var tools = s.tool_calls_count || 0; + + var html = + '
' + + '
' + + formatTokens(total) + + 'total tokens
' + + '
' + + formatTokens(prompt) + + 'prompt
' + + '
' + + formatTokens(completion) + + 'completion
' + + '
' + + formatCount(tools) + + 'tool calls
' + + "
"; + + // Bar chart breakdown + var items = breakdown.breakdown || []; + if (items.length) { + var maxVal = 0; + for (var i = 0; i < items.length; i++) { + var v = (items[i].prompt_tokens || 0) + (items[i].completion_tokens || 0); + if (v > maxVal) maxVal = v; + } + html += '
'; + for (var j = 0; j < items.length; j++) { + var item = items[j]; + var val = (item.prompt_tokens || 0) + (item.completion_tokens || 0); + var pct = maxVal > 0 ? Math.round((val / maxVal) * 100) : 0; + var label = item.key || "\u2014"; + html += + '
' + + '' + + escapeHtml(label) + + "" + + '
' + + '' + + formatTokens(val) + + "" + + "
"; + } + html += "
"; + } else { + html += '
No usage data for this period
'; + } + + container.innerHTML = html; +} + +function setUsageRange(range) { + _govUsageRange = range; + // Update button states + var btns = document.querySelectorAll(".usage-range-btn"); + for (var i = 0; i < btns.length; i++) { + btns[i].classList.toggle( + "active", + btns[i].getAttribute("data-range") === range, + ); + btns[i].setAttribute( + "aria-pressed", + btns[i].classList.contains("active") ? "true" : "false", + ); + } + loadGovUsage(); +} + +function setUsageGroupBy(groupBy) { + _govUsageGroupBy = groupBy; + var btns = document.querySelectorAll(".usage-group-btn"); + for (var i = 0; i < btns.length; i++) { + btns[i].classList.toggle( + "active", + btns[i].getAttribute("data-group") === groupBy, + ); + btns[i].setAttribute( + "aria-pressed", + btns[i].classList.contains("active") ? "true" : "false", + ); + } + loadGovUsage(); +} + +// --------------------------------------------------------------------------- +// Audit +// --------------------------------------------------------------------------- + +function loadGovAudit(append) { + if (!append) { + _govAuditOffset = 0; + _govAuditEvents = []; + } + var url = "/v1/api/admin/audit?limit=50&offset=" + _govAuditOffset; + var actionFilter = document.getElementById("audit-action-filter"); + var userFilter = document.getElementById("audit-user-filter"); + if (actionFilter && actionFilter.value) + url += "&action=" + encodeURIComponent(actionFilter.value); + if (userFilter && userFilter.value) + url += "&user_id=" + encodeURIComponent(userFilter.value); + + authFetch(url) + .then(function (r) { + if (!r.ok) throw new Error("Failed"); + return r.json(); + }) + .then(function (data) { + _govAuditTotal = data.total || 0; + var events = data.events || []; + _govAuditEvents = _govAuditEvents.concat(events); + _renderGovAudit(_govAuditEvents, _govAuditTotal); + }) + .catch(function () { + document.getElementById("admin-audit-table").innerHTML = + '
Failed to load audit events
'; + }); +} + +function _relativeTime(isoStr) { + var now = Date.now(); + var then = new Date(isoStr + "Z").getTime(); + var diff = Math.max(0, Math.floor((now - then) / 1000)); + if (diff < 60) return diff + "s ago"; + if (diff < 3600) return Math.floor(diff / 60) + "m ago"; + if (diff < 86400) return Math.floor(diff / 3600) + "h ago"; + return Math.floor(diff / 86400) + "d ago"; +} + +function _renderGovAudit(events, total) { + var el = document.getElementById("admin-audit-table"); + if (!events.length) { + el.innerHTML = '
No audit events
'; + return; + } + var html = ""; + for (var i = 0; i < events.length; i++) { + var ev = events[i]; + var detail = ""; + try { + var d = JSON.parse(ev.detail || "{}"); + var keys = Object.keys(d); + if (keys.length) { + var parts = []; + for (var k = 0; k < Math.min(keys.length, 3); k++) { + parts.push(keys[k] + "=" + String(d[keys[k]]).slice(0, 30)); + } + detail = parts.join(", "); + } + } catch (e) { + detail = ev.detail; + } + + var actionCls = "audit-badge"; + if (ev.action.indexOf("delete") >= 0 || ev.action.indexOf("revoke") >= 0) + actionCls += " audit-danger"; + else if ( + ev.action.indexOf("create") >= 0 || + ev.action.indexOf("assign") >= 0 + ) + actionCls += " audit-success"; + + html += + '
' + + '' + + _relativeTime(ev.timestamp) + + "" + + '' + + escapeHtml(ev.user_id ? ev.user_id.slice(0, 8) : "\u2014") + + "" + + '' + + escapeHtml(ev.action) + + "" + + '' + + escapeHtml( + ev.resource_type + ? ev.resource_type + "/" + (ev.resource_id || "").slice(0, 8) + : "\u2014", + ) + + "" + + '' + + escapeHtml(detail || "\u2014") + + "" + + "
"; + } + // Pagination + if (events.length < total) { + html += + '"; + } + el.innerHTML = html; +} + +function loadMoreAudit() { + _govAuditOffset = _govAuditEvents.length; + loadGovAudit(true); +} + +// Populate audit user filter from admin users list +function _populateAuditUserFilter() { + var sel = document.getElementById("audit-user-filter"); + if (!sel) return; + var html = ''; + for (var i = 0; i < _adminUsers.length; i++) { + html += + '"; + } + sel.innerHTML = html; +} diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index d5beff84..17cad5d6 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -82,6 +82,11 @@ + + + + + @@ -188,6 +193,118 @@
Loading watches...
+ + + + + + + + + + + + + + + @@ -434,7 +551,162 @@ window.TURNSTONE_KB_SHORTCUTS = [ + + + + + + + + + + + + + + + + + + + + + + diff --git a/turnstone/console/static/style.css b/turnstone/console/static/style.css index 2eec3442..8efdff23 100644 --- a/turnstone/console/static/style.css +++ b/turnstone/console/static/style.css @@ -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 ========================================================================== */ diff --git a/turnstone/core/audit.py b/turnstone/core/audit.py new file mode 100644 index 00000000..80abc074 --- /dev/null +++ b/turnstone/core/audit.py @@ -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) diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index d456f577..982d8017 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -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 diff --git a/turnstone/core/policy.py b/turnstone/core/policy.py new file mode 100644 index 00000000..9e18cfda --- /dev/null +++ b/turnstone/core/policy.py @@ -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 diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py index 923b5dbd..61cd0ddf 100644 --- a/turnstone/core/storage/_postgresql.py +++ b/turnstone/core/storage/_postgresql.py @@ -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: diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py index 8fa9fd26..14975bb7 100644 --- a/turnstone/core/storage/_protocol.py +++ b/turnstone/core/storage/_protocol.py @@ -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: diff --git a/turnstone/core/storage/_schema.py b/turnstone/core/storage/_schema.py index 84392225..17796906 100644 --- a/turnstone/core/storage/_schema.py +++ b/turnstone/core/storage/_schema.py @@ -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) diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py index 6b8f2461..071ee42e 100644 --- a/turnstone/core/storage/_sqlite.py +++ b/turnstone/core/storage/_sqlite.py @@ -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: diff --git a/turnstone/core/storage/migrations/versions/008_governance.py b/turnstone/core/storage/migrations/versions/008_governance.py new file mode 100644 index 00000000..ca7b60b4 --- /dev/null +++ b/turnstone/core/storage/migrations/versions/008_governance.py @@ -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") diff --git a/turnstone/sdk/console.py b/turnstone/sdk/console.py index db7ea1db..b6b5c06c 100644 --- a/turnstone/sdk/console.py +++ b/turnstone/sdk/console.py @@ -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: diff --git a/turnstone/server.py b/turnstone/server.py index 7badcb56..2c5155f3 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -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) diff --git a/turnstone/shared_static/auth.js b/turnstone/shared_static/auth.js index 9f04224c..41809c41 100644 --- a/turnstone/shared_static/auth.js +++ b/turnstone/shared_static/auth.js @@ -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(); });