rbac: builtin-role override editor + tighten under-enforced perm gates (#585)

* feat(rbac): editable builtin role permissions via overlay layer

Adds a ``role_permission_overrides`` table that stores per-(role_id,
permission) grant/revoke deltas, applied on top of the immutable
``roles.permissions`` baseline at permission-load time. Builtin roles
(``builtin-admin/operator/viewer``) become customizable through the
admin Roles UI without losing the "reset to default" guarantee — every
override is auditable and reversible.

Motivating case: ``model.skills.write`` is deliberately default-ungranted
on every role so operators must consciously opt in before a coordinator
session can mutate the skill catalog. Until now there was no UX path to
do that opt-in — the only options were dropping into SQL or running a
fresh migration. The overrides editor closes that gap.

Backend
- Migration 057 + storage methods on both sqlite + postgresql backends
- ``get_user_permissions`` merges baseline ∪ grants − revokes for builtin
  rows; custom rows pass through unchanged
- ``GET /v1/api/admin/roles/{id}/effective`` for inspect
- ``PUT /v1/api/admin/roles/{id}/overrides`` for write — admin.roles gated,
  audited, validates against ``_VALID_PERMISSIONS``, refuses non-builtin
  targets, strips no-op grants/revokes before persisting
- Lockout guard: cannot revoke ``admin.roles`` if doing so would leave
  zero users with the permission (returns 409)
- ``coordinator.trust.send`` added to ``_VALID_PERMISSIONS`` — was
  seeded into builtin-admin by migration 042 but never registered with
  the validator, so the very first round-trip through the editor 400'd
  on it. Drift-detection test guards future migrations from recreating
  the same gap

Frontend
- Roles tab redesign: chevron + permission-count chip replace the
  "..." truncation; expand-on-click drawer groups perms by namespace
  with baseline / grant (green +) / revoke (red −) chip variants
- Edit modal opens for builtin rows ("Customize Built-in Role" title);
  toggles show baseline-default vs override state; submit diffs against
  the rendered toggle universe (not raw baseline) so future taxonomy
  drift can't silently strip unknown perms
- "Modified +N/-N" pill on rows with active overrides; "Reset to default"
  drawer action clears the override set
- ``_PERMISSION_SECTIONS`` brought up to date with all currently-seeded
  perms (admin.coordinator, admin.cluster.inspect, admin.models,
  admin.nodes, admin.prompt_policies, conversation.modify,
  coordinator.trust.send were missing)

Tests
- 7 storage tests covering set/list/clear/effective + overlay merge into
  ``get_user_permissions`` for both builtin and custom roles
- 11 endpoint tests covering effective/overrides happy paths, validation,
  lockout guard, builtin-only restriction, no-op normalization, list
  enrichment

* feat(rbac): enforce workstreams.{create,close} + tools.approve gates

These three permissions were declared in ``_VALID_PERMISSIONS``, seeded
into ``builtin-operator``'s baseline by migration 008/017, surfaced in
the admin Roles UI as toggles, and documented in ``bootstrap.py`` as
the operator role's capabilities — and never enforced anywhere. The
audit that ran out of the overlay PR found zero ``require_permission``
sites for any of them; any authenticated user could create workstreams,
close any workstream, or approve any pending tool regardless of role.

Behaviour change for callers without the perms:

- ``POST /v1/api/workstreams/new`` (node + console proxy variants)
  now 403 without ``workstreams.create``
- ``POST /v1/api/workstreams/{ws_id}/close`` (and ``/route/`` proxy)
  now 403 without ``workstreams.close``
- ``POST /v1/api/workstreams/{ws_id}/approve`` (and ``/route/`` proxy)
  now 403 without ``tools.approve``

The OR-fallback to ``admin.coordinator`` keeps coord sessions spawning
interactive children unblocked without needing operator-style perms.
Service-scoped inter-cluster calls bypass via the existing
``allow_service_bypass`` path on the new ``require_any_permission``
helper. Builtin admin and operator both already carry these perms;
viewer correctly loses workstream create/close/approve (it already
couldn't do those in spirit).

Implementation
- ``require_any_permission`` (core/auth.py) — OR-semantics variant of
  ``require_permission`` with per-conditional comments documenting the
  security policy at the choke point. 403 body names every accepted
  perm so operators get an actionable remediation
- ``make_{create,close,approve}_handler`` (core/session_routes.py)
  accept ``fallback_permissions: tuple[str, ...]`` — checked only when
  ``cfg.permission_gate is None`` (interactive case). Coord's
  ``permission_gate=_require_admin_coordinator`` continues to take
  precedence on the coord-config side
- Console-side ``create_workstream`` and ``route_create`` inline the
  same OR check before proxying — fail fast on a forbidden request
  without burning a cluster round-trip
- ``route_proxy`` adds a verb-scoped gate on ``approve`` and ``close``
  only; ``send``/``cancel``/``dequeue``/``command``/``plan`` remain
  authenticated-only (pre-existing, out of scope for this audit)

Tests
- New ``TestPermissionGatesOnLifecycle`` (4 tests) in test_server_authz
  pinning 403-without-perm + non-403-with-perm at the node lift sites
- New ``TestRouteProxyPermissionGates`` (5 tests) in
  test_console_routing_proxy covering 403 paths, OR fallback via
  ``admin.coordinator``, and that ``send`` remains ungated
- ``_make_jwt`` helpers in test_server_authz, test_close_reason_
  persistence, test_server_attachments_on_create updated to embed
  operator-shaped perms by default so existing tests continue to
  exercise the post-gate logic rather than 403'ing on the new check

Docs
- ``bootstrap.py`` operator role line corrected to list every perm
  it actually carries (was missing ``tools.approve`` and
  ``conversation.modify``)

* fix(rbac): close lockout + escalation gaps in role-overrides editor

Three issues surfaced by /review of the overlay layer and gate uplift —
all in the RBAC/auth surface, treated as zero-days.

**F-1: lockout guard misses the grant-removal path.** PUT-replace
semantics on ``set_role_overrides`` mean an existing grant of
``admin.roles`` (added via override to e.g. builtin-operator) is
silently dropped when the new payload omits it.  The previous guard
short-circuited on ``"admin.roles" not in revokes`` and never noticed.
Concrete cluster-bricking scenario: grant admin.roles to operator via
override, unassign builtin-admin, click "Reset to default" on operator
→ all users lose admin.roles, recoverable only via SQL.

The rewritten guard simulates the post-PUT effective set on the target
role directly: if ``(baseline | new_grants) - new_revokes`` lacks
admin.roles AND nobody holds it via another role, refuse the change.
The "via another role" question is answered by one bulk query rather
than the prior O(users × roles) round-trip loop.

**F-3: lockout check blocked the event loop on moderate deployments.**
The prior check called ``storage.list_user_roles`` per user and
``storage.effective_role_permissions`` per (user, role) pair —
synchronous SQL inside an async handler.  200 users × 5 roles = 1000
connection cycles long enough to trip reverse-proxy timeouts on a
permission revoke.

Replaced with ``storage.users_with_permission(perm, *,
exclude_role_id)`` — one join over ``user_roles ⋈ roles`` plus one IN
fetch on overrides for the builtin role ids in the result, folded
in-process.  Two queries total, independent of cluster size.  The whole
check now runs under ``asyncio.to_thread`` so even the bulk read
doesn't stall the loop.

**F-2 reframed: admin_assign_role's subset check ignored the overlay.**
The check at lines 6321-6328 reads ``target_role.get("permissions",
"")`` (baseline column) when computing the perms it requires the
caller to hold.  After this branch, an admin.roles holder can grant
e.g. ``model.skills.write`` to builtin-operator via override; an
admin.users holder (who happens to NOT hold that perm) could then
assign operator to a new user, silently escalating the assignee.  The
existing two-person-rule by perm split (admin.roles for catalog edits,
admin.users for assignments) only holds if the assignment-time check
considers the overlay.  Switched ``target_perms`` to
``storage.effective_role_permissions(role_id)["effective"]``.

Note: this PR retains the existing model where admin.roles is the
catalog-edit superuser (admin_create_role, admin_update_role, and now
admin_role_overrides all skip the caller-holds-grants check).  The
two-person rule against escalation lives at the assignment gate, which
this fix reinforces.

**F-7: delete_role left orphaned override rows.** No FK on
``role_permission_overrides.role_id`` (migration 057 omitted FKs to
match the rest of the governance schema).  Added explicit cleanup in
both sqlite + postgresql ``delete_role`` implementations so a
re-seeded role_id (deterministic for builtins on schema reseed) can't
silently inherit stale overrides from the prior occupant.

Tests
- storage: ``test_users_with_permission_bulk`` exercises the new bulk
  helper including ``exclude_role_id`` and overlay folding
- storage: ``test_delete_role_cleans_up_overrides`` pins the F-7 fix
- endpoint: ``test_overrides_lockout_guard_blocks_grant_removal`` is
  the F-1 reproduction — operator-overlay grants admin.roles, builtin-
  admin has it removed, attempting to reset operator's overrides 409s
- endpoint: ``test_assign_role_blocks_escalation_via_overlay_grant``
  pins the F-2 reframed fix — overlay-poisoned operator can't be
  assigned by a caller missing the overlay perms

* refactor(rbac): cleanup batch from /review (#584)

Five non-security findings folded into one commit so the security
batch stays focused.  All consistent with the existing intent of
``feat/builtin-role-overrides``.

**F-4: presence check on ``_effectivePerms``.** ``governance.js`` was
guarding on ``Array.isArray(role.effective) && role.effective.length > 0``,
falling through to splitting ``role.permissions`` (the baseline) when
the array was empty.  For a builtin role whose overrides legitimately
revoke every baseline perm, that path silently rendered the baseline
chips with no override indicators — the inspector lied about what the
role can do.  ``_enrich_role`` always sets ``effective: []``, so
presence is the right sentinel.

**F-5: JS-side drift detector.**  Commit 1 added a Python-side test
asserting ``_VALID_PERMISSIONS`` covers every baseline perm; the
mirror invariant on the frontend went uncaught.  A new perm added to
``_VALID_PERMISSIONS`` without a matching entry in
``_PERMISSION_SECTIONS`` becomes silently un-customizable through the
admin UI (the only documented grant/revoke path).  Test parses the
JS const out via regex and asserts set-equality both directions —
detects "missing in UI" and "extra in UI" so the toggle catalog and
validator can't fork.

**F-6: bulk enrich for ``admin_list_roles``.**  Was ``1 +
2*builtin_count + 1*custom_count`` SELECTs per admin-tab open;
collapsed to one ``IN``-filtered query via new
``storage.effective_role_permissions_bulk(role_ids)``.  Implemented
on both sqlite + postgresql backends following the existing
``effective_role_permissions`` shape.

**F-8: rename ``fallback_permissions`` → ``accepted_permissions``.**
The lift body uses ``if cfg.permission_gate / elif accepted_permissions``
— mutually exclusive — so when ``permission_gate`` is None this IS
the primary gate, not a fallback to anything.  The "fallback" name
suggested a tier-2-after-tier-1 semantic that didn't exist.  Renamed
across ``make_{approve,close,create}_handler`` factories, the three
call sites in ``turnstone/server.py``, and the docstrings.

**F-9: positive lift-level tests for ``admin.coordinator``-only.**
``TestPermissionGatesOnLifecycle`` previously had a single positive
test for ``workstreams.create`` alone, plus negative-403 tests for
each verb without perms.  The OR-fallback to ``admin.coordinator``
(which keeps coord sessions spawning interactive children unblocked)
had no positive coverage at the lift code path — only at the proxy,
which exercises a different verb-dict gate.  Added three tests
(create / close / approve) that pass ``admin.coordinator`` alone and
assert non-403, so a future tightening of the accepted_permissions
tuple can't silently regress coord-driven child workstreams.

Out of scope: nit perf-4 (event-delegation refactor on
``_renderGovRoles``).  ``setSafeHtml`` rebuild is the existing
pattern across every admin tab; rewriting one tab's render path on
this branch would be drive-by inconsistent with the surrounding
codebase.  Filed as a separate concern if the Roles tab grows past
the scale where it bites.

* fix(rbac-ui): aria-expanded + row-click on Roles drawer (#585)

Two Copilot review findings on governance.js:

- Expand button was missing aria-expanded — screen readers couldn't
  announce drawer state.  Now reflects the row's expanded flag.
- Comment said "row + chevron both work" but only the chevron was
  wired.  Added data-expand-role to the row element too so the
  existing handler loop (querySelectorAll on the attribute) picks up
  both — clicking anywhere in the role row toggles the drawer.
  Edit/Delete handlers already stopPropagation so they aren't
  triggered by the row-level click.

* fix(migrations): rebase role_permission_overrides to 058

PR #560 mitigation #1 landed 057_output_assessments_llm_judge.py on
main in parallel; my migration claimed the same number, forking
alembic's head and breaking postgres.  Renumbered to 058 and
re-pointed down_revision at 057 so the chain stays linear.

No behaviour change — same DDL.  Full sweep clean (6730 passed).

* fix(migrations): update 058 revision strings to match filename

Previous commit (ea86aefc) renamed 057_role_permission_overrides.py to
058_* but the in-file revision = "057" / down_revision = "056"
strings stayed — leftover from when the file shipped as 057.  Tests
pass because alembic walks the chain by revision string, and the
strings now correctly read revision = "058" / down_revision = "057"
to make the chain linear with main's 057_output_assessments_llm_judge.

Caught locally before re-running CI; my prior `git mv` + content edit
landed as a staged rename + unstaged modification on the previous
push.
This commit is contained in:
Patrick Buckley
2026-05-24 18:31:23 -07:00
committed by GitHub
parent f94db6e5a9
commit d068366a61
21 changed files with 2129 additions and 68 deletions
+4 -1
View File
@@ -23,9 +23,12 @@ _JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _full_hdr() -> dict[str, str]: def _full_hdr() -> dict[str, str]:
# ``workstreams.close`` is now a real gate on the close handler
# (was a vestigial perm, see PR adding 057_role_permission_overrides);
# tests that drive close need it embedded in the JWT.
return { return {
"Authorization": ( "Authorization": (
f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', _JWT_SECRET, audience=JWT_AUD_SERVER)}" f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', _JWT_SECRET, audience=JWT_AUD_SERVER, permissions=frozenset({'workstreams.close'}))}"
) )
} }
+83
View File
@@ -354,6 +354,89 @@ class TestRouteProxy:
assert resp.status_code == 200 assert resp.status_code == 200
class TestRouteProxyPermissionGates:
"""``route_proxy`` was pre-existing infra that forwarded blindly —
any authenticated caller could send/approve/cancel/close. PR
adding 057_role_permission_overrides added verb-scoped gates on
approve + close (the verbs that had vestigial perms in
``_VALID_PERMISSIONS`` with no enforcement site). These tests
pin the new shape and the OR fallback to ``admin.coordinator``."""
@pytest.fixture()
def client(self):
router = _make_mock_router()
app = _make_app(router=router)
_wire_proxy(app, _make_proxy_post(json_data={"status": "ok"}))
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
@staticmethod
def _hdr(*, perms: frozenset[str] = frozenset()) -> dict[str, str]:
# Plain user — no service scope, so the bypass doesn't kick in;
# just the perms passed by the test.
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return {
"Authorization": (
"Bearer "
+ create_jwt(
user_id="test-user",
scopes=frozenset({"read", "write", "approve"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
permissions=perms,
)
)
}
def test_approve_without_perm_returns_403(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/approve",
json={"approved": True},
headers=self._hdr(),
)
assert resp.status_code == 403
assert "tools.approve" in resp.json()["error"]
def test_close_without_perm_returns_403(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/close",
json={},
headers=self._hdr(),
)
assert resp.status_code == 403
assert "workstreams.close" in resp.json()["error"]
def test_approve_with_tools_approve_passes(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/approve",
json={"approved": True},
headers=self._hdr(perms=frozenset({"tools.approve"})),
)
assert resp.status_code == 200
def test_close_with_admin_coordinator_passes(self, client):
# The OR fallback: coord sessions can drive close on
# interactive children without holding workstreams.close.
resp = client.post(
"/v1/api/route/workstreams/abc123/close",
json={},
headers=self._hdr(perms=frozenset({"admin.coordinator"})),
)
assert resp.status_code == 200
def test_send_remains_authenticated_only(self, client):
# send/cancel/dequeue/command/plan are unchanged — no new gate.
resp = client.post(
"/v1/api/route/workstreams/abc123/send",
json={"message": "hi"},
headers=self._hdr(),
)
assert resp.status_code == 200
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests — route_lookup # Tests — route_lookup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+332
View File
@@ -28,6 +28,8 @@ from turnstone.console.server import (
admin_list_policies, admin_list_policies,
admin_list_roles, admin_list_roles,
admin_list_user_roles, admin_list_user_roles,
admin_role_effective,
admin_role_overrides,
admin_unassign_role, admin_unassign_role,
admin_update_org, admin_update_org,
admin_update_policy, admin_update_policy,
@@ -100,6 +102,12 @@ def client(storage):
Route("/api/admin/roles", admin_create_role, methods=["POST"]), 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_update_role, methods=["PUT"]),
Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]), Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]),
Route("/api/admin/roles/{role_id}/effective", admin_role_effective),
Route(
"/api/admin/roles/{role_id}/overrides",
admin_role_overrides,
methods=["PUT"],
),
# Users # Users
Route( Route(
"/api/admin/users/{user_id}", "/api/admin/users/{user_id}",
@@ -217,6 +225,94 @@ class TestRoles:
assert resp.status_code == 200, resp.json() assert resp.status_code == 200, resp.json()
assert "model.skills.write" in resp.json()["permissions"] assert "model.skills.write" in resp.json()["permissions"]
def test_permission_sections_js_covers_valid_permissions(self):
"""F-5: ``_PERMISSION_SECTIONS`` in governance.js mirrors
``_VALID_PERMISSIONS`` in console/server.py. A new perm added
to the Python validator without a matching JS toggle becomes
silently un-customizable through the admin Roles UI — the only
documented path for granting/revoking perms on a builtin.
Catches the same shape that surfaced ``coordinator.trust.send``
missing from the validator during manual verification of the
overlay editor (a similar drift, in the opposite direction)."""
import re
from pathlib import Path
from turnstone.console.server import _VALID_PERMISSIONS
src = Path("turnstone/console/static/governance.js").read_text()
# _PERMISSION_SECTIONS is a `const X = [...]` containing nested
# `permissions: ["a", "b", ...]` arrays. Pull every quoted
# string out of every permissions: [...] block; we don't need
# a full JS parser to enumerate the perm names.
m = re.search(
r"const _PERMISSION_SECTIONS\s*=\s*\[(.*?)\];",
src,
re.DOTALL,
)
assert m, "could not locate _PERMISSION_SECTIONS in governance.js"
body = m.group(1)
in_ui = set(re.findall(r'"([a-z][a-z._]*)"', body))
# Exclude the section labels themselves (they're sentence-case
# like "Scopes", "Admin"; the regex above already excludes them
# by anchoring on lowercase, but be explicit about intent).
missing_in_ui = sorted(_VALID_PERMISSIONS - in_ui)
extra_in_ui = sorted(in_ui - _VALID_PERMISSIONS)
assert not missing_in_ui, (
f"perms in _VALID_PERMISSIONS but not _PERMISSION_SECTIONS "
f"(silently un-customizable in admin UI): {missing_in_ui}"
)
assert not extra_in_ui, (
f"perms in _PERMISSION_SECTIONS but not _VALID_PERMISSIONS "
f"(toggle would 400 on save): {extra_in_ui}"
)
def test_valid_permissions_covers_all_seeded_builtin_perms(self):
"""Every permission migration 008/011/014/015/029/032/033/035/040/042
adds to a builtin role must be in ``_VALID_PERMISSIONS`` — otherwise
the overrides editor cannot round-trip the baseline (a perm dropped
from the toggle universe gets stripped to satisfy the validator,
producing a silent capability loss). Caught by the manual
verification run of feat/builtin-role-overrides:
``coordinator.trust.send`` was in the baseline but not the
validator, so the very first Save through the overrides editor
400'd."""
from turnstone.console.server import _VALID_PERMISSIONS
# Mirror the union the bootstrap migrations write into the baseline
# ``permissions`` column for builtin-admin. Keep this in sync with
# 017_catchup_admin_permissions.py and every subsequent migration
# that touches builtin-admin.
seeded = {
"read",
"write",
"approve",
"admin.users",
"admin.roles",
"admin.orgs",
"admin.policies",
"admin.prompt_policies",
"admin.skills",
"admin.audit",
"admin.usage",
"admin.schedules",
"admin.watches",
"admin.judge",
"admin.memories",
"admin.settings",
"admin.mcp",
"admin.models",
"admin.nodes",
"admin.coordinator",
"admin.cluster.inspect",
"tools.approve",
"workstreams.create",
"workstreams.close",
"conversation.modify",
"coordinator.trust.send",
}
missing = sorted(seeded - _VALID_PERMISSIONS)
assert not missing, f"perms in baseline but not _VALID_PERMISSIONS: {missing}"
def test_create_role_rejects_unknown_permission(self, client): def test_create_role_rejects_unknown_permission(self, client):
"""Unknown permission strings are rejected — guards the validator """Unknown permission strings are rejected — guards the validator
against typos in the constant list and would-be capability inflation against typos in the constant list and would-be capability inflation
@@ -311,6 +407,242 @@ class TestRoles:
assert "builtin" in resp.json()["error"].lower() assert "builtin" in resp.json()["error"].lower()
# ---------------------------------------------------------------------------
# Tests — Role permission overrides (builtin customization)
# ---------------------------------------------------------------------------
def _seed_builtin_admin(storage: Any, perms: str = "read,write,admin.roles") -> None:
storage.create_role(
role_id="builtin-admin",
name="admin",
display_name="Admin",
permissions=perms,
builtin=True,
)
storage.assign_role("test-admin", "builtin-admin")
class TestRoleOverrides:
def test_effective_returns_baseline_when_no_overrides(self, client, storage):
_seed_builtin_admin(storage, "read,admin.roles")
resp = client.get("/v1/api/admin/roles/builtin-admin/effective")
assert resp.status_code == 200
body = resp.json()
assert body["baseline"] == ["admin.roles", "read"]
assert body["grants"] == []
assert body["revokes"] == []
assert body["effective"] == ["admin.roles", "read"]
def test_effective_404_unknown_role(self, client):
resp = client.get("/v1/api/admin/roles/nope/effective")
assert resp.status_code == 404
def test_overrides_grant_skills_write(self, client, storage):
# The motivating case: model.skills.write is default-ungranted,
# operator opts in via the overrides endpoint.
_seed_builtin_admin(storage, "read,write,admin.roles")
resp = client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={"grant": ["model.skills.write"], "revoke": []},
)
assert resp.status_code == 200, resp.json()
body = resp.json()
assert "model.skills.write" in body["effective"]
assert body["grants"] == ["model.skills.write"]
def test_overrides_replace_semantics(self, client, storage):
_seed_builtin_admin(storage, "read,write,admin.roles")
client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={"grant": ["model.skills.write"], "revoke": []},
)
# PUT replaces — the prior grant should be gone after sending an
# empty body, leaving only the new revoke (which IS in baseline).
resp = client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={"grant": [], "revoke": ["write"]},
)
assert resp.status_code == 200
body = resp.json()
assert body["grants"] == []
assert body["revokes"] == ["write"]
assert "model.skills.write" not in body["effective"]
def test_overrides_invalid_permission_rejected(self, client, storage):
_seed_builtin_admin(storage)
resp = client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={"grant": ["totally.fake.perm"], "revoke": []},
)
assert resp.status_code == 400
assert "invalid" in resp.json()["error"].lower()
def test_overrides_disjoint_grant_revoke_rejected(self, client, storage):
_seed_builtin_admin(storage)
resp = client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={"grant": ["approve"], "revoke": ["approve"]},
)
assert resp.status_code == 400
def test_overrides_non_builtin_rejected(self, client, storage):
storage.create_role(
role_id="custom-1",
name="custom",
display_name="Custom",
permissions="read",
builtin=False,
)
resp = client.put(
"/v1/api/admin/roles/custom-1/overrides",
json={"grant": ["write"], "revoke": []},
)
assert resp.status_code == 400
assert "builtin" in resp.json()["error"].lower()
def test_overrides_no_op_grant_and_revoke_normalize(self, client, storage):
# A grant of a perm already in baseline AND a revoke of a perm not
# in baseline both have zero behavioural effect; the endpoint
# strips them rather than persisting redundant rows.
_seed_builtin_admin(storage, "read,write,admin.roles")
resp = client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={
"grant": ["read", "model.skills.write"],
"revoke": ["tools.approve"],
},
)
assert resp.status_code == 200
body = resp.json()
# Only the meaningful delta survived.
assert body["grants"] == ["model.skills.write"]
assert body["revokes"] == []
def test_overrides_lockout_guard_blocks_last_admin_revoke(self, client, storage):
_seed_builtin_admin(storage, "read,admin.roles")
resp = client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={"grant": [], "revoke": ["admin.roles"]},
)
assert resp.status_code == 409
assert "admin.roles" in resp.json()["error"]
# Verify the override was NOT applied — the user must still be admin.
assert "admin.roles" in storage.get_user_permissions("test-admin")
def test_overrides_lockout_guard_permits_revoke_when_other_admin_exists(self, client, storage):
_seed_builtin_admin(storage, "read,admin.roles")
# Second role on a different user that also carries admin.roles —
# revoking from builtin-admin no longer locks the deployment out.
storage.create_role(
role_id="custom-admin",
name="custom-admin",
display_name="Custom Admin",
permissions="read,admin.roles",
builtin=False,
)
storage.assign_role("user-1", "custom-admin")
resp = client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={"grant": [], "revoke": ["admin.roles"]},
)
assert resp.status_code == 200
def test_list_roles_includes_overlay_fields(self, client, storage):
_seed_builtin_admin(storage, "read,admin.roles")
client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={"grant": ["model.skills.write"], "revoke": []},
)
resp = client.get("/v1/api/admin/roles")
roles = resp.json()["roles"]
# Find builtin-admin in the listing
row = next(r for r in roles if r["role_id"] == "builtin-admin")
assert row["grants"] == ["model.skills.write"]
assert row["revokes"] == []
assert "model.skills.write" in row["effective"]
def test_overrides_lockout_guard_blocks_grant_removal(self, client, storage):
# F-1: PUT-replace semantics mean an existing grant of admin.roles
# on a role whose baseline lacks it is silently dropped when the
# new payload omits it. Old guard only fired on explicit revokes
# and missed this path entirely — concrete cluster-bricking scenario.
# Setup: only builtin-operator users hold admin.roles, via overlay grant.
storage.create_role(
role_id="builtin-operator",
name="operator",
display_name="Operator",
permissions="read,write", # baseline lacks admin.roles
builtin=True,
)
# Grant admin.roles to operator via overlay, then unassign builtin-admin
# from the test user so operator is the only path to admin.roles.
storage.set_role_overrides("builtin-operator", {"admin.roles"}, set())
storage.assign_role("test-admin", "builtin-operator")
# The test-admin user keeps builtin-admin assigned by _seed_builtin_admin
# which would normally hold admin.roles — but we seed without it so the
# only source is the overlay on builtin-operator.
if storage.get_role("builtin-admin") is None:
storage.create_role(
role_id="builtin-admin",
name="admin",
display_name="Admin",
permissions="read,write", # baseline lacks admin.roles
builtin=True,
)
storage.assign_role("test-admin", "builtin-admin")
# Sanity: admin.roles only reachable via operator's overlay
assert "admin.roles" in storage.get_user_permissions("test-admin")
# The lockout-triggering call: Reset operator's overrides (drops
# the admin.roles grant). Old guard short-circuited because
# revoke=[] doesn't contain "admin.roles"; new guard simulates
# the post-PUT effective set on the target role.
resp = client.put(
"/v1/api/admin/roles/builtin-operator/overrides",
json={"grant": [], "revoke": []},
)
assert resp.status_code == 409, resp.json()
assert "admin.roles" in resp.json()["error"]
# Override was NOT applied — admin.roles still reachable.
assert "admin.roles" in storage.get_user_permissions("test-admin")
def test_assign_role_blocks_escalation_via_overlay_grant(self, storage, client):
# F-2 reframed. Simulates the attack path where a previous
# admin.roles holder injected an overlay grant on a builtin
# role, then a separate admin.users holder (who does NOT hold
# the granted perm) tries to assign that role to a new user.
# Without this fix the assign-time subset check would read the
# baseline column and miss the overlay, silently escalating
# the assignee.
#
# Operator's baseline is unchanged production default
# ("read,write" — no model.skills.write). The overlay grant
# below is the simulated attack step, not the system default.
_seed_builtin_admin(storage, "read,write,admin.roles,admin.users")
storage.create_role(
role_id="builtin-operator",
name="operator",
display_name="Operator",
permissions="read,write", # production default
builtin=True,
)
storage.set_role_overrides(
"builtin-operator", {"model.skills.write"}, set()
) # simulated prior poisoning by an admin.roles holder
# The harness AuthResult holds admin.roles + admin.users + many
# admin.* perms but NOT model.skills.write. Assigning operator
# — whose POST-OVERLAY effective set in this test scenario
# contains model.skills.write — must 403, because the assignee
# would otherwise gain a perm the assigner doesn't hold.
resp = client.post(
"/v1/api/admin/users/user-1/roles",
json={"role_id": "builtin-operator"},
)
assert resp.status_code == 403
assert "permissions you do not hold" in resp.json()["error"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests — Role assignments # Tests — Role assignments
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+106
View File
@@ -152,6 +152,112 @@ class TestRoleCRUD:
assert db.get_user_permissions("u1") == set() assert db.get_user_permissions("u1") == set()
# ---------------------------------------------------------------------------
# Role permission overrides (builtin-role customization layer)
# ---------------------------------------------------------------------------
class TestRolePermissionOverrides:
def test_overrides_empty_by_default(self, db):
db.create_role("r1", "admin", "Admin", "read,write", builtin=True, org_id="")
assert db.list_role_overrides("r1") == []
eff = db.effective_role_permissions("r1")
assert eff["baseline"] == ["read", "write"]
assert eff["grants"] == []
assert eff["revokes"] == []
assert eff["effective"] == ["read", "write"]
def test_set_role_overrides_grant_and_revoke(self, db):
db.create_role("r1", "admin", "Admin", "read,write", builtin=True, org_id="")
db.set_role_overrides("r1", {"approve"}, {"write"}, created_by="u-admin")
eff = db.effective_role_permissions("r1")
assert eff["baseline"] == ["read", "write"]
assert eff["grants"] == ["approve"]
assert eff["revokes"] == ["write"]
assert eff["effective"] == ["approve", "read"]
def test_set_role_overrides_replaces_prior_state(self, db):
db.create_role("r1", "admin", "Admin", "read,write", builtin=True, org_id="")
db.set_role_overrides("r1", {"approve"}, set())
db.set_role_overrides("r1", set(), {"write"})
rows = db.list_role_overrides("r1")
# Prior grant is gone; only the new revoke remains.
assert len(rows) == 1
assert rows[0]["permission"] == "write"
assert rows[0]["action"] == "revoke"
def test_set_role_overrides_disjoint_required(self, db):
db.create_role("r1", "admin", "Admin", "read", builtin=True, org_id="")
with pytest.raises(ValueError):
db.set_role_overrides("r1", {"write"}, {"write"})
def test_clear_role_overrides(self, db):
db.create_role("r1", "admin", "Admin", "read", builtin=True, org_id="")
db.set_role_overrides("r1", {"approve"}, set())
assert len(db.list_role_overrides("r1")) == 1
db.clear_role_overrides("r1")
assert db.list_role_overrides("r1") == []
def test_get_user_permissions_applies_overlay_to_builtin(self, db):
db.create_role("r1", "admin", "Admin", "read,write", builtin=True, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.assign_role("u1", "r1")
# Before overrides: baseline only
assert db.get_user_permissions("u1") == {"read", "write"}
# After overrides: grants in, revokes out
db.set_role_overrides("r1", {"approve", "model.skills.write"}, {"write"})
assert db.get_user_permissions("u1") == {"read", "approve", "model.skills.write"}
def test_get_user_permissions_ignores_overlay_on_custom_role(self, db):
# Overrides only apply to builtin rows. A custom role with stray
# override rows (defensive case — should never happen via the API)
# must NOT have them applied.
db.create_role("r1", "custom", "Custom", "read", builtin=False, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.assign_role("u1", "r1")
db.set_role_overrides("r1", {"approve"}, {"read"})
# Effective perms come from the role row only — overlay is dropped.
assert db.get_user_permissions("u1") == {"read"}
def test_users_with_permission_bulk(self, db):
# Two roles, three users; only users whose EFFECTIVE perm set
# includes the queried perm appear. Drives the lockout-guard
# rewrite in admin_role_overrides — one bulk SELECT replaces
# the prior per-user/per-role loop.
db.create_role("r-adm", "adm", "Adm", "admin.roles,read", builtin=True, org_id="")
db.create_role("r-op", "op", "Op", "read,write", builtin=True, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.create_user("u2", "bob", "Bob", "$2b$hash")
db.create_user("u3", "cara", "Cara", "$2b$hash")
db.assign_role("u1", "r-adm")
db.assign_role("u2", "r-op")
db.assign_role("u3", "r-op")
# Baseline state
assert db.users_with_permission("admin.roles") == {"u1"}
# Overlay-grant admin.roles to r-op → u2 + u3 now hold it too
db.set_role_overrides("r-op", {"admin.roles"}, set())
assert db.users_with_permission("admin.roles") == {"u1", "u2", "u3"}
# exclude_role_id = r-adm → u1 drops; u2/u3 still hold via r-op
assert db.users_with_permission("admin.roles", exclude_role_id="r-adm") == {
"u2",
"u3",
}
# Overlay-revoke admin.roles from r-adm → u1 no longer holds via that role
db.set_role_overrides("r-adm", set(), {"admin.roles"})
assert db.users_with_permission("admin.roles") == {"u2", "u3"}
def test_delete_role_cleans_up_overrides(self, db):
# F-7: no FK on role_permission_overrides. Storage layer must
# clean up by hand so a re-seeded role_id (deterministic for
# builtins on schema reseed) doesn't silently inherit stale
# overrides from the prior occupant.
db.create_role("r1", "custom", "Custom", "read", builtin=False, org_id="")
db.set_role_overrides("r1", {"approve"}, set())
assert len(db.list_role_overrides("r1")) == 1
assert db.delete_role("r1") is True
assert db.list_role_overrides("r1") == []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Organizations # Organizations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -36,6 +36,11 @@ def _make_jwt(user_id: str) -> str:
source="test", source="test",
secret=_TEST_JWT_SECRET, secret=_TEST_JWT_SECRET,
audience=JWT_AUD_SERVER, audience=JWT_AUD_SERVER,
# ``workstreams.create`` is now a real gate on POST /workstreams/new
# — see PR adding 057_role_permission_overrides. Embed the perm so
# the multipart-create flow under test stays exercising the create
# path and not the new 403.
permissions=frozenset({"workstreams.create"}),
) )
+127 -3
View File
@@ -21,7 +21,21 @@ from starlette.testclient import TestClient
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!" _TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _make_jwt(user_id: str, *, scopes: frozenset[str] | None = None) -> str: # Default permission set for test JWTs. Mirrors what builtin-operator
# carries: enough perms to exercise create/close/approve gates without
# turning every existing test into a re-authorization round. Tests
# negating these gates pass ``permissions=frozenset()`` explicitly.
_DEFAULT_TEST_PERMS = frozenset(
{"workstreams.create", "workstreams.close", "tools.approve", "conversation.modify"}
)
def _make_jwt(
user_id: str,
*,
scopes: frozenset[str] | None = None,
permissions: frozenset[str] | None = None,
) -> str:
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
return create_jwt( return create_jwt(
@@ -30,11 +44,17 @@ def _make_jwt(user_id: str, *, scopes: frozenset[str] | None = None) -> str:
source="test", source="test",
secret=_TEST_JWT_SECRET, secret=_TEST_JWT_SECRET,
audience=JWT_AUD_SERVER, audience=JWT_AUD_SERVER,
permissions=_DEFAULT_TEST_PERMS if permissions is None else permissions,
) )
def _auth(user: str, *, scopes: frozenset[str] | None = None) -> dict[str, str]: def _auth(
return {"Authorization": f"Bearer {_make_jwt(user, scopes=scopes)}"} user: str,
*,
scopes: frozenset[str] | None = None,
permissions: frozenset[str] | None = None,
) -> dict[str, str]:
return {"Authorization": f"Bearer {_make_jwt(user, scopes=scopes, permissions=permissions)}"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -411,6 +431,110 @@ class TestCrossTenantClose:
assert resp.status_code == 404 assert resp.status_code == 404
class TestPermissionGatesOnLifecycle:
"""Gates that previously didn't exist — ``workstreams.create``,
``workstreams.close``, ``tools.approve`` were declared, seeded into
builtin-operator, surfaced in the admin Roles UI, and never wired
to a single ``require_permission`` site. PR added the gates; these
tests confirm a JWT without each perm gets 403."""
def test_create_without_perm_returns_403(self, app_client):
client, _mgr = app_client
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "no-perm"},
headers=_auth("user-1", permissions=frozenset()),
)
assert resp.status_code == 403
assert "workstreams.create" in resp.json()["error"]
def test_close_without_perm_returns_403(self, app_client):
from turnstone.core.storage import get_storage
client, _mgr = app_client
storage = get_storage()
assert storage is not None
_register_ws(storage, "ws-1", "user-1")
resp = client.post(
"/v1/api/workstreams/ws-1/close",
json={},
headers=_auth("user-1", permissions=frozenset()),
)
assert resp.status_code == 403
assert "workstreams.close" in resp.json()["error"]
def test_approve_without_perm_returns_403(self, app_client):
from turnstone.core.storage import get_storage
client, _mgr = app_client
storage = get_storage()
assert storage is not None
_register_ws(storage, "ws-1", "user-1")
resp = client.post(
"/v1/api/workstreams/ws-1/approve",
json={"approved": True},
headers=_auth("user-1", permissions=frozenset()),
)
assert resp.status_code == 403
assert "tools.approve" in resp.json()["error"]
def test_create_with_perm_passes_gate(self, app_client):
# Sanity: same call WITH the perm reaches the post-gate logic
# (whatever its outcome — a successful create or a non-403
# validation/state error is fine; only the gate behaviour is
# under test here).
client, _mgr = app_client
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "with-perm"},
headers=_auth("user-1", permissions=frozenset({"workstreams.create"})),
)
assert resp.status_code != 403, resp.json()
# Positive coverage for the admin.coordinator OR-fallback on each
# of the three lifted verbs. Without these, a future refactor
# that dropped admin.coordinator from the accepted_permissions
# tuple would regress coord-session children silently — the proxy
# tests only exercise the route_proxy verb dict, not the lift.
def test_create_with_admin_coordinator_passes_gate(self, app_client):
client, _mgr = app_client
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "coord-child"},
headers=_auth("user-1", permissions=frozenset({"admin.coordinator"})),
)
assert resp.status_code != 403, resp.json()
def test_close_with_admin_coordinator_passes_gate(self, app_client):
from turnstone.core.storage import get_storage
client, _mgr = app_client
storage = get_storage()
assert storage is not None
_register_ws(storage, "ws-1", "user-1")
resp = client.post(
"/v1/api/workstreams/ws-1/close",
json={},
headers=_auth("user-1", permissions=frozenset({"admin.coordinator"})),
)
assert resp.status_code != 403, resp.json()
def test_approve_with_admin_coordinator_passes_gate(self, app_client):
from turnstone.core.storage import get_storage
client, _mgr = app_client
storage = get_storage()
assert storage is not None
_register_ws(storage, "ws-1", "user-1")
resp = client.post(
"/v1/api/workstreams/ws-1/approve",
json={"approved": True},
headers=_auth("user-1", permissions=frozenset({"admin.coordinator"})),
)
assert resp.status_code != 403, resp.json()
class TestCrossTenantTitle: class TestCrossTenantTitle:
def test_refresh_title_requires_live_session(self, app_client): def test_refresh_title_requires_live_session(self, app_client):
# Trusted-team model: scope-level auth is the gate; any caller # Trusted-team model: scope-level auth is the gate; any caller
+19
View File
@@ -189,6 +189,13 @@ class RoleInfo(BaseModel):
org_id: str org_id: str
created: str created: str
updated: str updated: str
# Overlay fields (populated by list/get endpoints for builtin roles;
# ``effective`` always reflects the post-overlay set, ``grants``/
# ``revokes`` are the user-applied deltas — both empty for custom roles
# since overrides apply only to builtins).
effective: list[str] = []
grants: list[str] = []
revokes: list[str] = []
class CreateRoleRequest(BaseModel): class CreateRoleRequest(BaseModel):
@@ -206,6 +213,18 @@ class ListRolesResponse(BaseModel):
roles: list[RoleInfo] roles: list[RoleInfo]
class RoleOverridesRequest(BaseModel):
grant: list[str] = []
revoke: list[str] = []
class RoleEffectiveResponse(BaseModel):
baseline: list[str]
grants: list[str]
revokes: list[str]
effective: list[str]
class AssignRoleRequest(BaseModel): class AssignRoleRequest(BaseModel):
role_id: str role_id: str
+19
View File
@@ -81,7 +81,9 @@ from turnstone.api.console_schemas import (
ParseSkillResponse, ParseSkillResponse,
RegistryInstallRequest, RegistryInstallRequest,
RegistrySearchResponse, RegistrySearchResponse,
RoleEffectiveResponse,
RoleInfo, RoleInfo,
RoleOverridesRequest,
RouteCreateResponse, RouteCreateResponse,
RouteResponse, RouteResponse,
SetNodeMetadataValueRequest, SetNodeMetadataValueRequest,
@@ -449,6 +451,23 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[400, 404], error_codes=[400, 404],
tags=["Admin"], tags=["Admin"],
), ),
EndpointSpec(
"/v1/api/admin/roles/{role_id}/effective",
"GET",
"Get effective permissions for a role (baseline + overrides)",
response_model=RoleEffectiveResponse,
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/roles/{role_id}/overrides",
"PUT",
"Replace the grant/revoke override set for a builtin role",
request_model=RoleOverridesRequest,
response_model=RoleEffectiveResponse,
error_codes=[400, 404, 409],
tags=["Admin"],
),
EndpointSpec( EndpointSpec(
"/v1/api/admin/users/{user_id}/roles", "/v1/api/admin/users/{user_id}/roles",
"GET", "GET",
+1 -1
View File
@@ -138,7 +138,7 @@ model and behavioral settings after deployment through the admin panel.
## Built-in Roles ## Built-in Roles
- **Admin** (`builtin-admin`): Full access read, write, approve, all admin.* permissions - **Admin** (`builtin-admin`): Full access read, write, approve, all admin.* permissions
- **Operator** (`builtin-operator`): read, write, workstreams.create, workstreams.close - **Operator** (`builtin-operator`): create / close workstreams, approve tools, modify conversations (read, write, workstreams.create, workstreams.close, tools.approve, conversation.modify)
- **Viewer** (`builtin-viewer`): read only - **Viewer** (`builtin-viewer`): read only
## Tool Policies ## Tool Policies
+235 -6
View File
@@ -1761,8 +1761,19 @@ async def create_workstream(request: Request) -> JSONResponse:
- ``node_id`` omitted or ``"auto"`` console picks the node with most headroom - ``node_id`` omitted or ``"auto"`` console picks the node with most headroom
- ``node_id`` set to ``"pool"`` console picks any available node - ``node_id`` set to ``"pool"`` console picks any available node
""" """
from turnstone.core.auth import require_any_permission
from turnstone.core.web_helpers import read_json_or_400 from turnstone.core.web_helpers import read_json_or_400
# Gate on workstreams.create OR admin.coordinator before proxying —
# keeps the 403 attributed at the console (audit clarity) and avoids
# a cluster round-trip on a forbidden request. The node-side lift
# gates again as defense in depth. See ``interactive_endpoint_config``
# in ``turnstone/server.py`` for the OR rationale (coord sessions
# spawning interactive children).
err = require_any_permission(request, ("workstreams.create", "admin.coordinator"))
if err is not None:
return err
body = await read_json_or_400(request) body = await read_json_or_400(request)
if isinstance(body, JSONResponse): if isinstance(body, JSONResponse):
return body return body
@@ -1892,7 +1903,17 @@ async def route_create(request: Request) -> Response:
console can hash to the owning node before the multipart body lands console can hash to the owning node before the multipart body lands
we do not parse the body just to peek at the metadata. we do not parse the body just to peek at the metadata.
""" """
from turnstone.core.auth import require_any_permission
t0 = time.monotonic() t0 = time.monotonic()
# Fail fast on forbidden requests — the upstream node's lift gates
# too (see ``make_create_handler`` in session_routes.py).
# ``admin.coordinator`` is accepted so coord sessions can spawn
# interactive children via the route proxy without holding
# ``workstreams.create``.
err = require_any_permission(request, ("workstreams.create", "admin.coordinator"))
if err is not None:
return _record_route(request, "create", 403, t0, err)
router: ConsoleRouter | None = request.app.state.router router: ConsoleRouter | None = request.app.state.router
ring_ready = router is not None and router.is_ready() ring_ready = router is not None and router.is_ready()
if not ring_ready: if not ring_ready:
@@ -2267,12 +2288,32 @@ async def route_proxy(request: Request) -> Response:
legacies still in scope). ``verb`` drives the audit action lookup; legacies still in scope). ``verb`` drives the audit action lookup;
DELETE on ``/send`` is treated as dequeue for audit attribution. DELETE on ``/send`` is treated as dequeue for audit attribution.
""" """
from turnstone.core.auth import require_any_permission
t0 = time.monotonic() t0 = time.monotonic()
# Extract verb name from URL tail: /v1/api/route/.../send -> "send". # Extract verb name from URL tail: /v1/api/route/.../send -> "send".
# DELETE on /send is the dequeue path — audit attribution diverges. # DELETE on /send is the dequeue path — audit attribution diverges.
verb = request.url.path.rsplit("/", 1)[-1] verb = request.url.path.rsplit("/", 1)[-1]
if verb == "send" and request.method == "DELETE": if verb == "send" and request.method == "DELETE":
verb = "dequeue" verb = "dequeue"
# Verb-scoped permission gate. Redundant with the node-side lift's
# check (the upstream server gates again), but failing fast at the
# proxy avoids a cluster round-trip on a forbidden request and keeps
# the 403 attributed to the proxy in audit logs. Only the two verbs
# whose perms exist; other verbs (send/cancel/dequeue/command/plan)
# remain authenticated-only and pre-existing — leaving them alone
# rather than expanding scope. ``admin.coordinator`` accepted as
# an alternative on each so coord sessions driving interactive
# children pass through without the operator-style perms.
_verb_perms: dict[str, tuple[str, ...]] = {
"approve": ("tools.approve", "admin.coordinator"),
"close": ("workstreams.close", "admin.coordinator"),
}
if verb in _verb_perms:
err = require_any_permission(request, _verb_perms[verb])
if err is not None:
return _record_route(request, verb, 403, t0, err)
router: ConsoleRouter | None = request.app.state.router router: ConsoleRouter | None = request.app.state.router
ring_ready = router is not None and router.is_ready() ring_ready = router is not None and router.is_ready()
if not ring_ready: if not ring_ready:
@@ -5904,6 +5945,11 @@ _VALID_PERMISSIONS = frozenset(
# explicitly before their coordinator sessions can mutate the # explicitly before their coordinator sessions can mutate the
# catalog. # catalog.
"model.skills.write", "model.skills.write",
# Coordinator out-of-band send capability — granted to
# ``builtin-admin`` by migration 042 but previously absent
# from this validator, which made it impossible to add to a
# custom role or restore via the overrides editor.
"coordinator.trust.send",
"tools.approve", "tools.approve",
"workstreams.create", "workstreams.create",
"workstreams.close", "workstreams.close",
@@ -5912,8 +5958,28 @@ _VALID_PERMISSIONS = frozenset(
) )
def _enrich_role(row: dict[str, Any], eff: dict[str, list[str]]) -> dict[str, Any]:
"""Add overlay fields (effective / grants / revokes) to a role dict.
For builtin rows, ``effective`` is the post-overlay set; for custom
rows it's just the parsed ``permissions`` column with empty deltas.
Keeps a single round-trip shape so the admin UI can render chips +
"modified" indicators without per-row fetches.
Caller supplies the prefetched ``eff`` dict from
:meth:`effective_role_permissions_bulk` so admin_list_roles needs
one storage round-trip total instead of 1 + 2*builtin_count.
"""
return {
**row,
"effective": eff["effective"],
"grants": eff["grants"] if row.get("builtin") else [],
"revokes": eff["revokes"] if row.get("builtin") else [],
}
async def admin_list_roles(request: Request) -> JSONResponse: async def admin_list_roles(request: Request) -> JSONResponse:
"""GET /v1/api/admin/roles — list all roles.""" """GET /v1/api/admin/roles — list all roles with overlay info."""
from turnstone.core.auth import require_permission from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503 from turnstone.core.web_helpers import require_storage_or_503
@@ -5923,7 +5989,18 @@ async def admin_list_roles(request: Request) -> JSONResponse:
err = require_permission(request, "admin.roles") err = require_permission(request, "admin.roles")
if err: if err:
return err return err
return JSONResponse({"roles": storage.list_roles()}) rows = storage.list_roles()
eff_map = storage.effective_role_permissions_bulk([r["role_id"] for r in rows])
return JSONResponse(
{
"roles": [
_enrich_role(
r, eff_map.get(r["role_id"], {"effective": [], "grants": [], "revokes": []})
)
for r in rows
]
}
)
async def admin_create_role(request: Request) -> JSONResponse: async def admin_create_role(request: Request) -> JSONResponse:
@@ -6074,6 +6151,148 @@ async def admin_delete_role(request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
async def admin_role_effective(request: Request) -> JSONResponse:
"""GET /v1/api/admin/roles/{role_id}/effective — baseline + overrides."""
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"]
if storage.get_role(role_id) is None:
return JSONResponse({"error": "Role not found"}, status_code=404)
return JSONResponse(storage.effective_role_permissions(role_id))
def _check_admin_lockout(
storage: Any,
role_id: str,
grants: set[str],
revokes: set[str],
) -> JSONResponse | None:
"""Refuse override changes that would leave nobody with admin.roles.
``admin.roles`` is the only permission whose loss is self-locking
without it, no user can reach the Roles tab to undo the change. Other
revoked permissions (``model.skills.write``, ``admin.skills``, etc.)
can always be restored by an admin, so they don't get this guard.
PUT-replace semantics on ``set_role_overrides`` mean the lockout
surface isn't just "did the new payload revoke admin.roles" — it's
also "did the new payload omit a previously-granted admin.roles
override." Either path lands at the same effective state, so the
check computes the post-PUT effective set on the target role and
falls through to a bulk users_with_permission query for any user
who retains the perm via another role.
Two queries total (one ``get_role`` for the target's baseline, one
join over ``user_roles roles`` plus IN-fetch on overrides for the
builtin role ids), regardless of cluster user/role count. Caller
is expected to wrap this in ``asyncio.to_thread`` since both
SQLite and asyncpg-via-sync-wrapper open new connections.
"""
role = storage.get_role(role_id)
if role is None:
return None # caller already validated existence; defensive no-op
baseline = {p.strip() for p in (role.get("permissions") or "").split(",") if p.strip()}
# Simulate the proposed PUT on the target role. If admin.roles
# survives there, every user assigned to the target keeps it; we're
# done.
target_effective = (baseline | grants) - revokes
if "admin.roles" in target_effective:
return None
# admin.roles is leaving the target role. Only need a single user
# who still holds it through some OTHER role to keep the cluster
# recoverable. ``exclude_role_id`` makes that one SQL question
# instead of N+M round-trips.
if storage.users_with_permission("admin.roles", exclude_role_id=role_id):
return None
return JSONResponse(
{"error": "Refusing change: would leave no user with admin.roles"},
status_code=409,
)
async def admin_role_overrides(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/roles/{role_id}/overrides — replace grant/revoke set."""
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 not existing.get("builtin"):
return JSONResponse(
{"error": "Overrides apply only to builtin roles; edit custom roles directly"},
status_code=400,
)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
grant_list = body.get("grant", []) or []
revoke_list = body.get("revoke", []) or []
if not isinstance(grant_list, list) or not isinstance(revoke_list, list):
return JSONResponse({"error": "grant and revoke must be arrays"}, status_code=400)
grants = {str(p) for p in grant_list}
revokes = {str(p) for p in revoke_list}
invalid = sorted((grants | revokes) - _VALID_PERMISSIONS)
if invalid:
return JSONResponse(
{"error": f"Invalid permissions: {', '.join(invalid)}"},
status_code=400,
)
if grants & revokes:
return JSONResponse(
{"error": "A permission cannot appear in both grant and revoke"},
status_code=400,
)
# Strip no-ops: grants already in baseline and revokes not in baseline both
# have zero effect on the merged set. Storing them wastes rows and clutters
# the audit detail without changing behavior.
baseline = {p.strip() for p in (existing.get("permissions") or "").split(",") if p.strip()}
grants = grants - baseline
revokes = revokes & baseline
# Block in a worker thread — even bulk-querying the lockout state
# touches sync DB connections (SQLite open, asyncpg sync wrapper)
# and should never run inline on the asyncio event loop.
lockout = await asyncio.to_thread(_check_admin_lockout, storage, role_id, grants, revokes)
if lockout is not None:
return lockout
audit_uid, ip = _audit_context(request)
storage.set_role_overrides(role_id, grants, revokes, created_by=audit_uid)
record_audit(
storage,
audit_uid,
"role.overrides.set",
"role",
role_id,
{"grants": sorted(grants), "revokes": sorted(revokes)},
ip,
)
return JSONResponse(storage.effective_role_permissions(role_id))
async def admin_list_user_roles(request: Request) -> JSONResponse: async def admin_list_user_roles(request: Request) -> JSONResponse:
"""GET /v1/api/admin/users/{user_id}/roles — list roles assigned to a user.""" """GET /v1/api/admin/users/{user_id}/roles — list roles assigned to a user."""
from turnstone.core.auth import require_permission from turnstone.core.auth import require_permission
@@ -6129,10 +6348,14 @@ async def admin_assign_role(request: Request) -> JSONResponse:
if auth_result and auth_result.user_id == user_id: if auth_result and auth_result.user_id == user_id:
return JSONResponse({"error": "Cannot modify own role assignments"}, status_code=403) return JSONResponse({"error": "Cannot modify own role assignments"}, status_code=403)
# Ensure caller holds all permissions present in the target role # Ensure caller holds all permissions present in the target role.
target_perms = set( # Read the EFFECTIVE perm set (post-overlay) rather than the raw
p.strip() for p in target_role.get("permissions", "").split(",") if p.strip() # ``permissions`` baseline column — builtin roles can carry an
) # override layer added via PUT ``/v1/api/admin/roles/{id}/overrides``,
# and skipping the overlay here would let an admin.roles holder
# silently bypass the subset gate by granting a perm to e.g.
# builtin-operator before assigning that role to a new user.
target_perms = set(storage.effective_role_permissions(role_id)["effective"])
if ( if (
auth_result auth_result
and auth_result.permissions and auth_result.permissions
@@ -12507,6 +12730,12 @@ def create_app(
Route("/api/admin/roles", admin_create_role, methods=["POST"]), 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_update_role, methods=["PUT"]),
Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]), Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]),
Route("/api/admin/roles/{role_id}/effective", admin_role_effective),
Route(
"/api/admin/roles/{role_id}/overrides",
admin_role_overrides,
methods=["PUT"],
),
Route("/api/admin/users/{user_id}/roles", admin_list_user_roles), Route("/api/admin/users/{user_id}/roles", admin_list_user_roles),
Route( Route(
"/api/admin/users/{user_id}/roles", "/api/admin/users/{user_id}/roles",
+334 -37
View File
@@ -58,6 +58,100 @@ function loadGovRoles() {
}); });
} }
// Set of role_ids whose drawer is open. Persists across re-renders so a
// reload (e.g. after edit) doesn't collapse the inspector the user was
// looking at.
const _govRoleExpanded = new Set();
function _effectivePerms(role) {
// Server's _enrich_role always sets ``effective`` (empty array when
// the role legitimately has no perms — e.g. a builtin whose overrides
// revoke every baseline entry). Presence is the right sentinel, NOT
// length: a length check would silently fall through to the baseline
// column for a fully-revoked role, lying to the inspector about what
// the role can do. Only the raw-column fallback is for callers that
// hit older /v1/api/admin/roles payloads (e.g. mocked tests).
if (Array.isArray(role.effective)) return role.effective;
return (role.permissions || "")
.split(",")
.map(function (p) {
return p.trim();
})
.filter(Boolean);
}
function _renderRoleDrawer(role) {
const effective = _effectivePerms(role);
const effectiveSet = {};
for (let i = 0; i < effective.length; i++) effectiveSet[effective[i]] = true;
const baseline = role.builtin
? (role.permissions || "")
.split(",")
.map(function (p) {
return p.trim();
})
.filter(Boolean)
: effective;
const baselineSet = {};
for (let i = 0; i < baseline.length; i++) baselineSet[baseline[i]] = true;
const grants = role.grants || [];
const revokes = role.revokes || [];
const grantSet = {};
for (let i = 0; i < grants.length; i++) grantSet[grants[i]] = true;
const revokeSet = {};
for (let i = 0; i < revokes.length; i++) revokeSet[revokes[i]] = true;
let body = "";
for (let s = 0; s < _PERMISSION_SECTIONS.length; s++) {
const section = _PERMISSION_SECTIONS[s];
let chips = "";
let sectionHasAny = false;
for (let i = 0; i < section.permissions.length; i++) {
const p = section.permissions[i];
const inBase = !!baselineSet[p];
const inEff = !!effectiveSet[p];
const isGrant = !!grantSet[p];
const isRevoke = !!revokeSet[p];
if (!inEff && !isRevoke && !isGrant) continue; // hide perms not relevant to this role
sectionHasAny = true;
let cls = "perm-inspect-chip";
if (isGrant) cls += " is-grant";
else if (isRevoke) cls += " is-revoke";
else if (inBase) cls += " is-baseline";
let suffix = "";
if (isGrant) suffix = ' <span class="perm-delta-mark">+</span>';
else if (isRevoke) suffix = ' <span class="perm-delta-mark"></span>';
chips +=
'<span class="' + cls + '">' + escapeHtml(p) + suffix + "</span>";
}
if (!sectionHasAny) continue;
body +=
'<div class="role-drawer-section">' +
'<div class="role-drawer-section-label">' +
escapeHtml(section.label) +
"</div>" +
'<div class="role-drawer-chips">' +
chips +
"</div></div>";
}
const drawerActions =
role.builtin && (grants.length || revokes.length)
? '<button class="admin-btn-action" data-reset-role="' +
escapeHtml(role.role_id) +
'">Reset to default</button>'
: "";
return (
'<div class="admin-role-drawer" data-drawer-role="' +
escapeHtml(role.role_id) +
'">' +
body +
(drawerActions
? '<div class="role-drawer-actions">' + drawerActions + "</div>"
: "") +
"</div>"
);
}
function _renderGovRoles(items) { function _renderGovRoles(items) {
const el = document.getElementById("admin-roles-table"); const el = document.getElementById("admin-roles-table");
if (!items.length) { if (!items.length) {
@@ -67,57 +161,105 @@ function _renderGovRoles(items) {
let html = ""; let html = "";
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const r = items[i]; const r = items[i];
// Render permissions as badges const effective = _effectivePerms(r);
const perms = (r.permissions || "").split(","); const grants = r.grants || [];
let badges = ""; const revokes = r.revokes || [];
for (let j = 0; j < perms.length; j++) { const modified = grants.length > 0 || revokes.length > 0;
const p = perms[j].trim(); const expanded = _govRoleExpanded.has(r.role_id);
if (!p) continue;
let cls = "scope-badge"; let pills = "";
if (p === "approve" || p.indexOf("admin.") === 0) cls += " scope-approve"; if (r.builtin)
else if (p === "write" || p.indexOf("workstreams.") === 0) pills += '<span class="scope-badge scope-channel">builtin</span>';
cls += " scope-write"; if (modified) {
badges += '<span class="' + cls + '">' + escapeHtml(p) + "</span>"; const deltaLabel =
"modified " +
(grants.length ? "+" + grants.length : "") +
(grants.length && revokes.length ? " / " : "") +
(revokes.length ? "" + revokes.length : "");
pills +=
'<span class="scope-badge scope-write" title="' +
escapeHtml(deltaLabel) +
'">' +
escapeHtml(deltaLabel) +
"</span>";
} }
const typeLabel = r.builtin
? '<span class="scope-badge scope-channel">builtin</span>' const countChip =
: ""; '<span class="perm-count-chip">' +
const actions = r.builtin effective.length +
? "" (effective.length === 1 ? " permission" : " permissions") +
: '<button class="admin-btn-action" data-edit-role="' + "</span>";
escapeHtml(r.role_id) +
'">edit</button>' + // Builtin rows always get Edit (lands in the overrides editor).
// Custom rows get Edit + Delete (existing behavior).
let actions =
'<button class="admin-btn-action" data-edit-role="' +
escapeHtml(r.role_id) +
'">edit</button>';
if (!r.builtin) {
actions +=
'<button class="admin-btn-danger" data-delete-role="' + '<button class="admin-btn-danger" data-delete-role="' +
escapeHtml(r.role_id) + escapeHtml(r.role_id) +
'" data-role-name="' + '" data-role-name="' +
escapeHtml(r.name) + escapeHtml(r.name) +
'">delete</button>'; '">delete</button>';
}
const chevron = expanded ? "▾" : "▸";
html += html +=
'<div class="admin-row" role="listitem">' + '<div class="admin-row admin-role-row" role="listitem" data-role-id="' +
escapeHtml(r.role_id) +
'" data-expanded="' +
(expanded ? "true" : "false") +
'" data-expand-role="' +
escapeHtml(r.role_id) +
'">' +
'<span class="admin-col admin-col-rname">' + '<span class="admin-col admin-col-rname">' +
'<button type="button" class="role-expand-btn" aria-label="Toggle details" aria-expanded="' +
(expanded ? "true" : "false") +
'" data-expand-role="' +
escapeHtml(r.role_id) +
'">' +
chevron +
"</button>" +
escapeHtml(r.display_name) + escapeHtml(r.display_name) +
" " + " " +
typeLabel + pills +
"</span>" + "</span>" +
'<span class="admin-col admin-col-rperms">' + '<span class="admin-col admin-col-rperms">' +
badges + countChip +
"</span>" + "</span>" +
'<span class="admin-col admin-col-actions">' + '<span class="admin-col admin-col-actions">' +
actions + actions +
"</span></div>"; "</span></div>";
if (expanded) html += _renderRoleDrawer(r);
} }
setSafeHtml(el, html); setSafeHtml(el, html);
// Bind expand toggle (row + chevron both work)
const expandBtns = el.querySelectorAll("[data-expand-role]");
for (let k = 0; k < expandBtns.length; k++) {
expandBtns[k].addEventListener("click", function (ev) {
ev.stopPropagation();
const rid = this.getAttribute("data-expand-role");
if (_govRoleExpanded.has(rid)) _govRoleExpanded.delete(rid);
else _govRoleExpanded.add(rid);
_renderGovRoles(_govRoles);
});
}
// Bind edit // Bind edit
const editBtns = el.querySelectorAll("[data-edit-role]"); const editBtns = el.querySelectorAll("[data-edit-role]");
for (let k = 0; k < editBtns.length; k++) { for (let k = 0; k < editBtns.length; k++) {
editBtns[k].addEventListener("click", function () { editBtns[k].addEventListener("click", function (ev) {
ev.stopPropagation();
showEditRoleModal(this.getAttribute("data-edit-role")); showEditRoleModal(this.getAttribute("data-edit-role"));
}); });
} }
// Bind delete // Bind delete
const delBtns = el.querySelectorAll("[data-delete-role]"); const delBtns = el.querySelectorAll("[data-delete-role]");
for (let k = 0; k < delBtns.length; k++) { for (let k = 0; k < delBtns.length; k++) {
delBtns[k].addEventListener("click", function () { delBtns[k].addEventListener("click", function (ev) {
ev.stopPropagation();
const rid = this.getAttribute("data-delete-role"); const rid = this.getAttribute("data-delete-role");
const rname = this.getAttribute("data-role-name"); const rname = this.getAttribute("data-role-name");
showConfirmModal( showConfirmModal(
@@ -143,6 +285,37 @@ function _renderGovRoles(items) {
); );
}); });
} }
// Bind reset-to-default (drawer action; builtin + overridden only)
const resetBtns = el.querySelectorAll("[data-reset-role]");
for (let k = 0; k < resetBtns.length; k++) {
resetBtns[k].addEventListener("click", function (ev) {
ev.stopPropagation();
const rid = this.getAttribute("data-reset-role");
showConfirmModal(
"Reset overrides",
"Drop all overrides on this builtin role? Effective permissions return to the shipped baseline.",
"Reset",
function () {
authFetch("/v1/api/admin/roles/" + rid + "/overrides", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant: [], revoke: [] }),
})
.then(function (r) {
if (!r.ok) throw new Error();
return r.json();
})
.then(function () {
showToast("Overrides cleared");
loadGovRoles();
})
.catch(function () {
showToast("Failed to reset overrides");
});
},
);
});
}
} }
// Permission inventory grouped by namespace so the role modal can // Permission inventory grouped by namespace so the role modal can
@@ -165,6 +338,7 @@ const _PERMISSION_SECTIONS = [
"admin.roles", "admin.roles",
"admin.orgs", "admin.orgs",
"admin.policies", "admin.policies",
"admin.prompt_policies",
"admin.skills", "admin.skills",
"admin.audit", "admin.audit",
"admin.usage", "admin.usage",
@@ -174,11 +348,24 @@ const _PERMISSION_SECTIONS = [
"admin.memories", "admin.memories",
"admin.settings", "admin.settings",
"admin.mcp", "admin.mcp",
"admin.models",
"admin.nodes",
"admin.coordinator",
"admin.cluster.inspect",
], ],
}, },
{ {
label: "Workstreams & Tools", label: "Workstreams & Tools",
permissions: ["workstreams.create", "workstreams.close", "tools.approve"], permissions: [
"workstreams.create",
"workstreams.close",
"conversation.modify",
"tools.approve",
],
},
{
label: "Coordinator",
permissions: ["coordinator.trust.send"],
}, },
{ {
label: "Model", label: "Model",
@@ -196,7 +383,7 @@ const _ALL_PERMISSIONS = (function () {
return flat; return flat;
})(); })();
function _buildPermCheckboxes(prefix, selected) { function _buildPermCheckboxes(prefix, selected, baseline) {
// Emits the toggle-switch component used elsewhere in the admin // Emits the toggle-switch component used elsewhere in the admin
// modals so each permission reads as a deliberate on/off rather // modals so each permission reads as a deliberate on/off rather
// than a generic checkbox. Sections are wrapped in a // than a generic checkbox. Sections are wrapped in a
@@ -206,6 +393,18 @@ function _buildPermCheckboxes(prefix, selected) {
// The underlying ``<input type="checkbox" name="{prefix}-perm">`` // The underlying ``<input type="checkbox" name="{prefix}-perm">``
// shape is preserved so ``_collectPermCheckboxes`` still picks // shape is preserved so ``_collectPermCheckboxes`` still picks
// them up regardless of section. // them up regardless of section.
//
// ``baseline`` is optional — when provided (builtin-role edits) each
// toggle gets a small visual mark showing whether the perm is on by
// default and whether the current state is an override (added or
// removed). ``submitEditRole`` diffs the toggle state against the
// baseline to produce the {grant, revoke} payload for /overrides.
const baselineSet = {};
if (baseline)
for (let i = 0; i < baseline.length; i++) baselineSet[baseline[i]] = true;
const selectedSet = {};
if (selected)
for (let i = 0; i < selected.length; i++) selectedSet[selected[i]] = true;
let html = ""; let html = "";
for (let s = 0; s < _PERMISSION_SECTIONS.length; s++) { for (let s = 0; s < _PERMISSION_SECTIONS.length; s++) {
const section = _PERMISSION_SECTIONS[s]; const section = _PERMISSION_SECTIONS[s];
@@ -217,9 +416,28 @@ function _buildPermCheckboxes(prefix, selected) {
'<div class="perm-grid">'; '<div class="perm-grid">';
for (let i = 0; i < section.permissions.length; i++) { for (let i = 0; i < section.permissions.length; i++) {
const p = section.permissions[i]; const p = section.permissions[i];
const checked = selected && selected.indexOf(p) >= 0 ? " checked" : ""; const isChecked = !!selectedSet[p];
const inBase = !!baselineSet[p];
const checked = isChecked ? " checked" : "";
let extraCls = "";
let badge = "";
if (baseline) {
if (inBase && !isChecked) {
extraCls = " is-revoke";
badge = '<span class="perm-baseline-mark" title="Revoked"></span>';
} else if (!inBase && isChecked) {
extraCls = " is-grant";
badge = '<span class="perm-baseline-mark" title="Granted">+</span>';
} else if (inBase) {
extraCls = " is-baseline";
badge =
'<span class="perm-baseline-mark is-default" title="Default">•</span>';
}
}
html += html +=
'<label class="toggle-switch perm-toggle">' + '<label class="toggle-switch perm-toggle' +
extraCls +
'">' +
'<input type="checkbox" value="' + '<input type="checkbox" value="' +
p + p +
'" name="' + '" name="' +
@@ -230,6 +448,7 @@ function _buildPermCheckboxes(prefix, selected) {
'<span class="toggle-track" aria-hidden="true"></span>' + '<span class="toggle-track" aria-hidden="true"></span>' +
'<span class="toggle-label">' + '<span class="toggle-label">' +
escapeHtml(p) + escapeHtml(p) +
badge +
"</span></label>"; "</span></label>";
} }
html += "</div></div>"; html += "</div></div>";
@@ -326,11 +545,41 @@ function showEditRoleModal(roleId) {
const ov = document.getElementById("edit-role-overlay"); const ov = document.getElementById("edit-role-overlay");
ov.style.display = "flex"; ov.style.display = "flex";
document.getElementById("er-id").value = roleId; document.getElementById("er-id").value = roleId;
document.getElementById("er-name").value = role.display_name; // Builtin rows expose the name field read-only — only display_name and
const selected = (role.permissions || "").split(","); // permissions are mutable on customs; for builtins, only permissions
// (via the override layer). The display_name input is kept visible on
// builtin rows but disabled to reduce surprise.
const nameInput = document.getElementById("er-name");
nameInput.value = role.display_name;
nameInput.disabled = !!role.builtin;
const titleEl = document.getElementById("edit-role-title");
if (titleEl)
titleEl.textContent = role.builtin
? "Customize Built-in Role"
: "Edit Role";
const baseline = role.builtin
? (role.permissions || "")
.split(",")
.map(function (p) {
return p.trim();
})
.filter(Boolean)
: null;
const selected = _effectivePerms(role);
// Persist the baseline + builtin flag on the form so submit can diff
// without re-walking _govRoles (which could have been refreshed mid-edit).
const form = document.getElementById("edit-role-box");
if (form) {
form.dataset.builtin = role.builtin ? "1" : "0";
form.dataset.baseline = baseline ? baseline.join(",") : "";
}
setSafeHtml( setSafeHtml(
document.getElementById("er-perms-container"), document.getElementById("er-perms-container"),
_buildPermCheckboxes("er", selected), _buildPermCheckboxes("er", selected, baseline),
); );
document.getElementById("edit-role-error").classList.remove("is-visible"); document.getElementById("edit-role-error").classList.remove("is-visible");
_erTrapHandler = _installTrap("edit-role-overlay", "edit-role-box"); _erTrapHandler = _installTrap("edit-role-overlay", "edit-role-box");
@@ -348,13 +597,61 @@ function hideEditRoleModal() {
function submitEditRole() { function submitEditRole() {
const roleId = document.getElementById("er-id").value; const roleId = document.getElementById("er-id").value;
const dname = document.getElementById("er-name").value.trim(); const dname = document.getElementById("er-name").value.trim();
const perms = _collectPermCheckboxes("er"); const form = document.getElementById("edit-role-box");
const isBuiltin = form && form.dataset.builtin === "1";
document.getElementById("er-submit").disabled = true; document.getElementById("er-submit").disabled = true;
authFetch("/v1/api/admin/roles/" + roleId, {
method: "PUT", let fetchOpts;
headers: { "Content-Type": "application/json" }, let url;
body: JSON.stringify({ display_name: dname, permissions: perms }), if (isBuiltin) {
}) // Diff against baseline → {grant, revoke}. Display name is immutable.
//
// Crucial safety property: the diff universe is the set of permissions
// we actually RENDERED as toggles, not the full baseline. If the
// permission taxonomy in `_PERMISSION_SECTIONS` ever falls behind a
// new server-side perm (e.g. migration adds it to builtin-admin
// before this file ships the toggle), naïve baseline-vs-selected
// diffing would treat every unrendered perm as "user wants this
// revoked" and silently strip it. Limiting the universe to rendered
// toggles makes the editor a NO-OP for unknown perms — they pass
// through untouched.
const renderedNodes = document.querySelectorAll('input[name="er-perm"]');
const renderedSet = {};
for (let i = 0; i < renderedNodes.length; i++)
renderedSet[renderedNodes[i].value] = true;
const baseline = (form.dataset.baseline || "").split(",").filter(Boolean);
const baselineSet = {};
for (let i = 0; i < baseline.length; i++) baselineSet[baseline[i]] = true;
const selectedStr = _collectPermCheckboxes("er");
const selected = selectedStr.split(",").filter(Boolean);
const selectedSet = {};
for (let i = 0; i < selected.length; i++) selectedSet[selected[i]] = true;
const grant = [];
const revoke = [];
for (const p in selectedSet) {
if (!baselineSet[p]) grant.push(p);
}
for (const p in baselineSet) {
// Only revoke perms the user could actually see — unrendered ones
// are out of scope for this edit and must round-trip unchanged.
if (renderedSet[p] && !selectedSet[p]) revoke.push(p);
}
url = "/v1/api/admin/roles/" + roleId + "/overrides";
fetchOpts = {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant: grant, revoke: revoke }),
};
} else {
const perms = _collectPermCheckboxes("er");
url = "/v1/api/admin/roles/" + roleId;
fetchOpts = {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_name: dname, permissions: perms }),
};
}
authFetch(url, fetchOpts)
.then(function (r) { .then(function (r) {
if (!r.ok) if (!r.ok)
return r.json().then(function (d) { return r.json().then(function (d) {
+156 -1
View File
@@ -2622,7 +2622,162 @@ textarea.skill-content-area {
========================================================================== */ ========================================================================== */
#admin-roles .admin-colheaders, #admin-roles .admin-colheaders,
#admin-roles .admin-row { #admin-roles .admin-row {
grid-template-columns: 160px 1fr 110px; grid-template-columns: 240px 1fr 140px;
}
/* Row-level chevron opens the inspect drawer. Square-bracketed
ascii triangle keeps with the "instrument panel" typographic
palette already used by the rest of the admin UX (no SVG icons). */
.role-expand-btn {
display: inline-block;
background: transparent;
border: 1px solid transparent;
color: var(--fg-dim);
font-family: var(--font-ui);
font-size: 10px;
line-height: 1;
padding: 1px 4px;
margin-right: 6px;
cursor: pointer;
border-radius: 2px;
}
.role-expand-btn:hover {
color: var(--accent);
border-color: var(--border);
}
.role-expand-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.admin-role-row[data-expanded="true"] .role-expand-btn {
color: var(--accent);
}
/* Collapsed-row permission summary replaces the chip stack that
used to overflow with a "…" the moment a role accrued more than a
handful of permissions. Drawer carries the real inventory. */
.perm-count-chip {
display: inline-block;
font-family: var(--font-ui);
font-size: 11px;
color: var(--fg-dim);
letter-spacing: 0.02em;
}
/* Inspect drawer slot directly under its row, full-width. No
sub-grid (the parent table is a column of rows, not a grid), so a
plain block container with bordered padding is sufficient. */
.admin-role-drawer {
padding: 12px 16px 14px 24px;
/* Pull contrast from the page background, not from --bg-highlight,
so the drawer reads as a recessed surface in both themes. Light
theme: a slight darken via rgba black layered over the page bg
instead of --bg-highlight (which is barely distinguishable from
--bg in the light palette). */
background: rgba(0, 0, 0, 0.04);
border-left: 2px solid var(--accent);
border-bottom: 1px solid var(--border);
margin-bottom: 2px;
}
.role-drawer-section {
margin-top: 6px;
}
.role-drawer-section:first-child {
margin-top: 0;
}
.role-drawer-section-label {
font-family: var(--font-ui);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
margin-bottom: 4px;
}
.role-drawer-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.role-drawer-actions {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
}
/* Drawer chips: baseline = subdued, grant = green plus, revoke =
red strike-through. The trailing "+" / "" sigil ('.perm-delta-mark')
gives the same signal at a glance for screen-reader pass-through
and high-contrast users where the colour alone isn't enough. */
.perm-inspect-chip {
display: inline-flex;
align-items: center;
gap: 3px;
font-family: var(--font-ui);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 2px;
/* Solid page bg so chips sit on top of the recessed drawer with a
visible step; --fg (not --fg-dim) so the label is comfortably
readable in both themes. */
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
}
.perm-inspect-chip.is-baseline {
/* Baseline = "shipped default." Slightly dimmed so the override
variants pop, but still high-contrast enough to read. */
color: var(--fg-dim);
}
.perm-inspect-chip.is-grant {
/* Bumped from 0.06 0.16 alpha so the green wash actually reads
as a state. Border step too. */
color: var(--green);
border-color: rgba(52, 211, 153, 0.55);
background: rgba(52, 211, 153, 0.16);
}
.perm-inspect-chip.is-revoke {
color: var(--red);
border-color: rgba(248, 113, 113, 0.55);
background: rgba(248, 113, 113, 0.14);
text-decoration: line-through;
}
.perm-delta-mark {
font-weight: 700;
font-size: 10px;
}
/* Toggle annotations inside the edit modal same baseline/grant/revoke
palette as the drawer, but lighter so the toggle remains the primary
affordance. The dot/plus/minus mark trails the label. */
.perm-baseline-mark {
display: inline-block;
margin-left: 6px;
font-size: 10px;
font-weight: 700;
color: var(--fg-dim);
}
.perm-baseline-mark.is-default {
color: var(--fg-dim);
opacity: 0.6;
}
.perm-toggle.is-grant .perm-baseline-mark {
color: var(--green);
}
.perm-toggle.is-revoke .perm-baseline-mark {
color: var(--red);
}
.perm-toggle.is-revoke .toggle-label {
color: var(--red);
}
.perm-toggle.is-grant .toggle-label {
color: var(--green);
} }
/* ========================================================================== /* ==========================================================================
+75
View File
@@ -206,6 +206,81 @@ def require_permission(
) )
def require_any_permission(
request: Request,
permissions: tuple[str, ...],
*,
allow_service_bypass: bool = True,
) -> JSONResponse | None:
"""OR-semantics variant of :func:`require_permission`.
Returns ``None`` if the caller holds at least one of ``permissions``;
otherwise a 403 naming the full set so operators know which roles
would satisfy the gate. Used where multiple roles legitimately
reach the same endpoint e.g. workstream-create accepts both
``workstreams.create`` (operator) and ``admin.coordinator`` (coord
sessions spawning interactive children). ``permissions`` is
required and must be non-empty; the empty tuple is almost certainly
a programmer error and would produce an always-403 gate.
This function is the OR-equivalent of the security choke point in
:func:`require_permission` every branch is intentional and the
per-branch comments below should stay accurate as the policy
evolves. If a future change adds or reorders a branch, the comment
must move with it; a stale comment on a security gate is worse than
no comment.
"""
from starlette.responses import JSONResponse
# Defensive: an empty tuple here means a caller mis-wired the gate
# and would silently 403 every request — fail loud at import-adjacent
# time so the breakage shows up in tests, not under load.
if not permissions:
raise ValueError("require_any_permission needs at least one permission")
# Pull the AuthResult attached by the auth middleware. Using
# ``getattr`` twice survives both "no state attribute" (Starlette
# request not yet wrapped) and "state present but auth_result
# unset" (middleware skipped the request) — both manifest as the
# same "no identity" outcome and route to 401, never silently
# admitting the call.
auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
if auth_result is None:
# Distinguishing 401 from 403 here matters: 401 tells the client
# "we don't know who you are, retry with credentials" while a
# 403 would suggest the identity is known but lacks the perm,
# which would mislead operators chasing an auth bug.
return JSONResponse({"error": "Unauthorized"}, status_code=401)
# Service-scope bypass: inter-cluster calls (collector → node,
# console → upstream) carry a service token and must not be blocked
# by per-user permission grants — the service scope itself is the
# cluster-side trust boundary. Callers that protect a capability-
# escalation gate (e.g. ``coordinator.trust.send``) pass
# ``allow_service_bypass=False`` to opt out.
if allow_service_bypass and auth_result.has_scope("service"):
return None
# OR-semantics happy path: any single permission in the set is
# enough. ``any()`` short-circuits so the linear scan is cheap
# even on a long permissions tuple. Note: this is a pure set
# membership check against the AuthResult — DB role lookups
# already happened at middleware time, so the gate stays in-process.
if any(auth_result.has_permission(p) for p in permissions):
return None
# Final fallthrough: identity present, not a service, no matching
# perm. Listing every accepted perm in the error body gives the
# operator an actionable remediation — "grant one of {workstreams.create,
# admin.coordinator}" — instead of guessing which role would
# satisfy a generic 403.
perm_list = ", ".join(f"'{p}'" for p in permissions)
return JSONResponse(
{"error": f"Forbidden: missing one of {perm_list} permissions"},
status_code=403,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Path classification # Path classification
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+32 -1
View File
@@ -695,7 +695,11 @@ def register_coord_verbs(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def make_approve_handler(cfg: SessionEndpointConfig) -> Handler: def make_approve_handler(
cfg: SessionEndpointConfig,
*,
accepted_permissions: tuple[str, ...] = (),
) -> Handler:
"""Lifted body for ``POST {prefix}/{ws_id}/approve``. """Lifted body for ``POST {prefix}/{ws_id}/approve``.
Resolves a pending tool approval on the workstream's UI. Both Resolves a pending tool approval on the workstream's UI. Both
@@ -704,7 +708,17 @@ def make_approve_handler(cfg: SessionEndpointConfig) -> Handler:
differences are auth scope, manager lookup, and the differences are auth scope, manager lookup, and the
``__budget_override__`` filter (interactive-only coord workstreams ``__budget_override__`` filter (interactive-only coord workstreams
don't have the budget-override pseudo-tool). don't have the budget-override pseudo-tool).
``accepted_permissions`` is OR-checked via :func:`require_any_permission`
only when ``cfg.permission_gate`` is ``None`` i.e. for the
interactive kind, where it IS the primary gate (not a fallback to
something else). Coord's ``permission_gate`` already takes
precedence so admin-coordinator users don't also need
``tools.approve`` to act on their own coord workstreams. Pass
``admin.coordinator`` alongside ``tools.approve`` for endpoints
reachable by coord sessions spawning interactive children.
""" """
from turnstone.core.auth import require_any_permission
from turnstone.core.web_helpers import read_json_or_400 from turnstone.core.web_helpers import read_json_or_400
async def approve(request: Request) -> Response: async def approve(request: Request) -> Response:
@@ -714,6 +728,10 @@ def make_approve_handler(cfg: SessionEndpointConfig) -> Handler:
err = cfg.permission_gate(request) err = cfg.permission_gate(request)
if err is not None: if err is not None:
return err return err
elif accepted_permissions:
err = require_any_permission(request, accepted_permissions)
if err is not None:
return err
mgr_opt, err503 = cfg.manager_lookup(request) mgr_opt, err503 = cfg.manager_lookup(request)
if err503 is not None: if err503 is not None:
return err503 return err503
@@ -826,6 +844,7 @@ def make_close_handler(
*, *,
audit_emit: CloseAuditEmitter | None = None, audit_emit: CloseAuditEmitter | None = None,
supports_close_reason: bool = False, supports_close_reason: bool = False,
accepted_permissions: tuple[str, ...] = (),
) -> Handler: ) -> Handler:
"""Lifted body for ``POST {prefix}/{ws_id}/close``. """Lifted body for ``POST {prefix}/{ws_id}/close``.
@@ -870,10 +889,16 @@ def make_close_handler(
async def close(request: Request) -> Response: async def close(request: Request) -> Response:
import asyncio import asyncio
from turnstone.core.auth import require_any_permission
if cfg.permission_gate is not None: if cfg.permission_gate is not None:
err = cfg.permission_gate(request) err = cfg.permission_gate(request)
if err is not None: if err is not None:
return err return err
elif accepted_permissions:
err = require_any_permission(request, accepted_permissions)
if err is not None:
return err
mgr_opt, err503 = cfg.manager_lookup(request) mgr_opt, err503 = cfg.manager_lookup(request)
if err503 is not None: if err503 is not None:
return err503 return err503
@@ -1726,6 +1751,7 @@ def make_create_handler(
cfg: SessionEndpointConfig, cfg: SessionEndpointConfig,
*, *,
audit_emit: CreateAuditEmitter | None = None, audit_emit: CreateAuditEmitter | None = None,
accepted_permissions: tuple[str, ...] = (),
) -> Handler: ) -> Handler:
"""Lifted body for ``POST {prefix}/new`` — workstream creation. """Lifted body for ``POST {prefix}/new`` — workstream creation.
@@ -1861,6 +1887,7 @@ def make_create_handler(
IMAGE_SIZE_CAP, IMAGE_SIZE_CAP,
validate_and_save_uploaded_files, validate_and_save_uploaded_files,
) )
from turnstone.core.auth import require_any_permission
from turnstone.core.web_helpers import ( from turnstone.core.web_helpers import (
read_json_or_400, read_json_or_400,
read_multipart_create_or_400, read_multipart_create_or_400,
@@ -1870,6 +1897,10 @@ def make_create_handler(
err = cfg.permission_gate(request) err = cfg.permission_gate(request)
if err is not None: if err is not None:
return err return err
elif accepted_permissions:
err = require_any_permission(request, accepted_permissions)
if err is not None:
return err
mgr_opt, err503 = cfg.manager_lookup(request) mgr_opt, err503 = cfg.manager_lookup(request)
if err503 is not None: if err503 is not None:
return err503 return err503
+215 -8
View File
@@ -45,6 +45,7 @@ from turnstone.core.storage._schema import (
output_assessments, output_assessments,
output_guard_patterns, output_guard_patterns,
prompt_templates, prompt_templates,
role_permission_overrides,
roles, roles,
scheduled_task_runs, scheduled_task_runs,
scheduled_tasks, scheduled_tasks,
@@ -121,6 +122,9 @@ from turnstone.core.storage._utils import sanitize_text
from turnstone.core.storage._utils import ( from turnstone.core.storage._utils import (
scan_skill_content as _scan_skill_content, scan_skill_content as _scan_skill_content,
) )
from turnstone.core.storage._utils import (
split_perms as _split_perms,
)
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
log = get_logger(__name__) log = get_logger(__name__)
@@ -2267,6 +2271,16 @@ class PostgreSQLBackend:
def delete_role(self, role_id: str) -> bool: def delete_role(self, role_id: str) -> bool:
with self._conn() as conn: with self._conn() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id)) conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id))
# No FK on role_permission_overrides (migration 057 omitted
# to match the rest of the governance schema), so clean up
# by hand. Orphan rows would otherwise apply silently if
# a role_id were ever reused — deterministic for builtins
# on schema reseed.
conn.execute(
sa.delete(role_permission_overrides).where(
role_permission_overrides.c.role_id == role_id
)
)
result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id)) result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id))
conn.commit() conn.commit()
return result.rowcount > 0 return result.rowcount > 0
@@ -2385,20 +2399,213 @@ class PostgreSQLBackend:
def get_user_permissions(self, user_id: str) -> set[str]: def get_user_permissions(self, user_id: str) -> set[str]:
with self._conn() as conn: with self._conn() as conn:
rows = conn.execute( role_rows = conn.execute(
sa.select(roles.c.permissions) sa.select(roles.c.role_id, roles.c.permissions, roles.c.builtin)
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id)) .select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
.where(user_roles.c.user_id == user_id) .where(user_roles.c.user_id == user_id)
).fetchall() ).fetchall()
if not role_rows:
return set()
builtin_role_ids = [r[0] for r in role_rows if r[2]]
grants: dict[str, set[str]] = {}
revokes: dict[str, set[str]] = {}
if builtin_role_ids:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.role_id,
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id.in_(builtin_role_ids))
).fetchall()
for rid, perm, action in ov_rows:
if action == "grant":
grants.setdefault(rid, set()).add(perm)
elif action == "revoke":
revokes.setdefault(rid, set()).add(perm)
perms: set[str] = set() perms: set[str] = set()
for r in rows: for rid, perms_str, builtin in role_rows:
if r[0]: role_perms = _split_perms(perms_str)
for p in r[0].split(","): if builtin:
p = p.strip() role_perms = (role_perms | grants.get(rid, set())) - revokes.get(rid, set())
if p: perms |= role_perms
perms.add(p)
return perms return perms
def users_with_permission(
self,
permission: str,
*,
exclude_role_id: str | None = None,
) -> set[str]:
with self._conn() as conn:
q = sa.select(
user_roles.c.user_id,
user_roles.c.role_id,
roles.c.permissions,
roles.c.builtin,
).select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
if exclude_role_id:
q = q.where(user_roles.c.role_id != exclude_role_id)
rows = conn.execute(q).fetchall()
if not rows:
return set()
builtin_role_ids = {r[1] for r in rows if r[3]}
grants: dict[str, set[str]] = {}
revokes: dict[str, set[str]] = {}
if builtin_role_ids:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.role_id,
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id.in_(builtin_role_ids))
).fetchall()
for rid, perm, action in ov_rows:
if action == "grant":
grants.setdefault(rid, set()).add(perm)
elif action == "revoke":
revokes.setdefault(rid, set()).add(perm)
holders: set[str] = set()
for user_id, role_id, perms_str, builtin in rows:
eff = _split_perms(perms_str)
if builtin:
eff = (eff | grants.get(role_id, set())) - revokes.get(role_id, set())
if permission in eff:
holders.add(user_id)
return holders
def list_role_overrides(self, role_id: str) -> list[dict[str, str]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(role_permission_overrides)
.where(role_permission_overrides.c.role_id == role_id)
.order_by(
role_permission_overrides.c.action,
role_permission_overrides.c.permission,
)
).fetchall()
return [dict(r._mapping) for r in rows]
def set_role_overrides(
self,
role_id: str,
grants: set[str],
revokes: set[str],
created_by: str = "",
) -> None:
if grants & revokes:
raise ValueError("grants and revokes must be disjoint")
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.delete(role_permission_overrides).where(
role_permission_overrides.c.role_id == role_id
)
)
rows = [
{
"role_id": role_id,
"permission": p,
"action": "grant",
"created": now,
"created_by": created_by,
}
for p in sorted(grants)
] + [
{
"role_id": role_id,
"permission": p,
"action": "revoke",
"created": now,
"created_by": created_by,
}
for p in sorted(revokes)
]
if rows:
conn.execute(sa.insert(role_permission_overrides), rows)
conn.commit()
def clear_role_overrides(self, role_id: str) -> None:
with self._conn() as conn:
conn.execute(
sa.delete(role_permission_overrides).where(
role_permission_overrides.c.role_id == role_id
)
)
conn.commit()
def effective_role_permissions(self, role_id: str) -> dict[str, list[str]]:
with self._conn() as conn:
role_row = conn.execute(
sa.select(roles.c.permissions, roles.c.builtin).where(roles.c.role_id == role_id)
).fetchone()
if role_row is None:
return {"baseline": [], "grants": [], "revokes": [], "effective": []}
baseline = _split_perms(role_row[0])
grants: set[str] = set()
revokes: set[str] = set()
if role_row[1]:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id == role_id)
).fetchall()
for perm, action in ov_rows:
if action == "grant":
grants.add(perm)
elif action == "revoke":
revokes.add(perm)
effective = (baseline | grants) - revokes
return {
"baseline": sorted(baseline),
"grants": sorted(grants),
"revokes": sorted(revokes),
"effective": sorted(effective),
}
def effective_role_permissions_bulk(
self, role_ids: list[str]
) -> dict[str, dict[str, list[str]]]:
if not role_ids:
return {}
with self._conn() as conn:
role_rows = conn.execute(
sa.select(roles.c.role_id, roles.c.permissions, roles.c.builtin).where(
roles.c.role_id.in_(role_ids)
)
).fetchall()
if not role_rows:
return {}
builtin_role_ids = [r[0] for r in role_rows if r[2]]
grants: dict[str, set[str]] = {}
revokes: dict[str, set[str]] = {}
if builtin_role_ids:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.role_id,
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id.in_(builtin_role_ids))
).fetchall()
for rid, perm, action in ov_rows:
if action == "grant":
grants.setdefault(rid, set()).add(perm)
elif action == "revoke":
revokes.setdefault(rid, set()).add(perm)
out: dict[str, dict[str, list[str]]] = {}
for rid, perms_str, builtin in role_rows:
baseline = _split_perms(perms_str)
role_grants = grants.get(rid, set()) if builtin else set()
role_revokes = revokes.get(rid, set()) if builtin else set()
effective = (baseline | role_grants) - role_revokes
out[rid] = {
"baseline": sorted(baseline),
"grants": sorted(role_grants),
"revokes": sorted(role_revokes),
"effective": sorted(effective),
}
return out
# -- Organizations --------------------------------------------------------- # -- Organizations ---------------------------------------------------------
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None: def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
+71 -1
View File
@@ -1202,7 +1202,77 @@ class StorageBackend(Protocol):
... ...
def get_user_permissions(self, user_id: str) -> set[str]: def get_user_permissions(self, user_id: str) -> set[str]:
"""Return the union of all permissions from the user's assigned roles.""" """Return the union of all permissions from the user's assigned roles.
For builtin roles, applies any rows in ``role_permission_overrides``
on top of ``roles.permissions`` as ``baseline grants revokes``.
"""
...
def users_with_permission(
self,
permission: str,
*,
exclude_role_id: str | None = None,
) -> set[str]:
"""Return ``user_id``s whose effective perms include ``permission``.
Walks every ``(user, assigned_role)`` pair in two bulk queries
(one over ``user_roles roles``, one over
``role_permission_overrides`` for the builtin role ids in the
first query's result) instead of N round-trips, then folds the
overlay in-process. ``exclude_role_id``, when set, ignores any
contribution from that role used by the lockout guard to
answer "would anyone still hold ``admin.roles`` via SOME OTHER
role if we modified this one?" without first having to apply
the proposed override.
"""
...
def list_role_overrides(self, role_id: str) -> list[dict[str, str]]:
"""Return override rows for ``role_id`` (action in {'grant','revoke'})."""
...
def set_role_overrides(
self,
role_id: str,
grants: set[str],
revokes: set[str],
created_by: str = "",
) -> None:
"""Transactionally replace the override set for ``role_id``.
Deletes any existing rows for the role and inserts one row per
(permission, action) in ``grants`` / ``revokes``. Empty inputs
clear all overrides (equivalent to ``clear_role_overrides``).
``grants`` and ``revokes`` MUST be disjoint the caller is
responsible for ensuring no permission appears in both.
"""
...
def clear_role_overrides(self, role_id: str) -> None:
"""Delete every override row for ``role_id`` (reset-to-default)."""
...
def effective_role_permissions(self, role_id: str) -> dict[str, list[str]]:
"""Return ``{'baseline': [...], 'grants': [...], 'revokes': [...],
'effective': [...]}`` for a single role, with overrides applied.
Each list is sorted for stable rendering.
"""
...
def effective_role_permissions_bulk(
self, role_ids: list[str]
) -> dict[str, dict[str, list[str]]]:
"""Bulk variant of :meth:`effective_role_permissions`.
Returns ``{role_id: {baseline, grants, revokes, effective}}``
for every role_id in ``role_ids``. Issues at most two queries
regardless of list size (one over ``roles``, one IN-filter over
``role_permission_overrides``). Missing role_ids are omitted
from the result rather than mapped to an empty dict caller
can detect absence directly.
"""
... ...
# -- Organizations --------------------------------------------------------- # -- Organizations ---------------------------------------------------------
+13
View File
@@ -395,6 +395,19 @@ user_roles = sa.Table(
sa.Index("idx_user_roles_role_id", user_roles.c.role_id) sa.Index("idx_user_roles_role_id", user_roles.c.role_id)
role_permission_overrides = sa.Table(
"role_permission_overrides",
metadata,
sa.Column("role_id", sa.Text, nullable=False),
sa.Column("permission", sa.Text, nullable=False),
sa.Column("action", sa.Text, nullable=False), # 'grant' | 'revoke'
sa.Column("created", sa.Text, nullable=False),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.PrimaryKeyConstraint("role_id", "permission"),
)
sa.Index("idx_role_permission_overrides_role", role_permission_overrides.c.role_id)
tool_policies = sa.Table( tool_policies = sa.Table(
"tool_policies", "tool_policies",
metadata, metadata,
+215 -8
View File
@@ -45,6 +45,7 @@ from turnstone.core.storage._schema import (
output_assessments, output_assessments,
output_guard_patterns, output_guard_patterns,
prompt_templates, prompt_templates,
role_permission_overrides,
roles, roles,
scheduled_task_runs, scheduled_task_runs,
scheduled_tasks, scheduled_tasks,
@@ -121,6 +122,9 @@ from turnstone.core.storage._utils import sanitize_text
from turnstone.core.storage._utils import ( from turnstone.core.storage._utils import (
scan_skill_content as _scan_skill_content, scan_skill_content as _scan_skill_content,
) )
from turnstone.core.storage._utils import (
split_perms as _split_perms,
)
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
log = get_logger(__name__) log = get_logger(__name__)
@@ -2417,6 +2421,16 @@ class SQLiteBackend:
def delete_role(self, role_id: str) -> bool: def delete_role(self, role_id: str) -> bool:
with self._conn() as conn: with self._conn() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id)) conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id))
# No FK on role_permission_overrides (migration 057 omitted
# to match the rest of the governance schema), so clean up
# by hand. Orphan rows would otherwise apply silently if
# a role_id were ever reused — deterministic for builtins
# on schema reseed.
conn.execute(
sa.delete(role_permission_overrides).where(
role_permission_overrides.c.role_id == role_id
)
)
result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id)) result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id))
conn.commit() conn.commit()
return result.rowcount > 0 return result.rowcount > 0
@@ -2547,20 +2561,213 @@ class SQLiteBackend:
def get_user_permissions(self, user_id: str) -> set[str]: def get_user_permissions(self, user_id: str) -> set[str]:
with self._conn() as conn: with self._conn() as conn:
rows = conn.execute( role_rows = conn.execute(
sa.select(roles.c.permissions) sa.select(roles.c.role_id, roles.c.permissions, roles.c.builtin)
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id)) .select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
.where(user_roles.c.user_id == user_id) .where(user_roles.c.user_id == user_id)
).fetchall() ).fetchall()
if not role_rows:
return set()
builtin_role_ids = [r[0] for r in role_rows if r[2]]
grants: dict[str, set[str]] = {}
revokes: dict[str, set[str]] = {}
if builtin_role_ids:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.role_id,
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id.in_(builtin_role_ids))
).fetchall()
for rid, perm, action in ov_rows:
if action == "grant":
grants.setdefault(rid, set()).add(perm)
elif action == "revoke":
revokes.setdefault(rid, set()).add(perm)
perms: set[str] = set() perms: set[str] = set()
for r in rows: for rid, perms_str, builtin in role_rows:
if r[0]: role_perms = _split_perms(perms_str)
for p in r[0].split(","): if builtin:
p = p.strip() role_perms = (role_perms | grants.get(rid, set())) - revokes.get(rid, set())
if p: perms |= role_perms
perms.add(p)
return perms return perms
def users_with_permission(
self,
permission: str,
*,
exclude_role_id: str | None = None,
) -> set[str]:
with self._conn() as conn:
q = sa.select(
user_roles.c.user_id,
user_roles.c.role_id,
roles.c.permissions,
roles.c.builtin,
).select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
if exclude_role_id:
q = q.where(user_roles.c.role_id != exclude_role_id)
rows = conn.execute(q).fetchall()
if not rows:
return set()
builtin_role_ids = {r[1] for r in rows if r[3]}
grants: dict[str, set[str]] = {}
revokes: dict[str, set[str]] = {}
if builtin_role_ids:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.role_id,
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id.in_(builtin_role_ids))
).fetchall()
for rid, perm, action in ov_rows:
if action == "grant":
grants.setdefault(rid, set()).add(perm)
elif action == "revoke":
revokes.setdefault(rid, set()).add(perm)
holders: set[str] = set()
for user_id, role_id, perms_str, builtin in rows:
eff = _split_perms(perms_str)
if builtin:
eff = (eff | grants.get(role_id, set())) - revokes.get(role_id, set())
if permission in eff:
holders.add(user_id)
return holders
def list_role_overrides(self, role_id: str) -> list[dict[str, str]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(role_permission_overrides)
.where(role_permission_overrides.c.role_id == role_id)
.order_by(
role_permission_overrides.c.action,
role_permission_overrides.c.permission,
)
).fetchall()
return [dict(r._mapping) for r in rows]
def set_role_overrides(
self,
role_id: str,
grants: set[str],
revokes: set[str],
created_by: str = "",
) -> None:
if grants & revokes:
raise ValueError("grants and revokes must be disjoint")
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.delete(role_permission_overrides).where(
role_permission_overrides.c.role_id == role_id
)
)
rows = [
{
"role_id": role_id,
"permission": p,
"action": "grant",
"created": now,
"created_by": created_by,
}
for p in sorted(grants)
] + [
{
"role_id": role_id,
"permission": p,
"action": "revoke",
"created": now,
"created_by": created_by,
}
for p in sorted(revokes)
]
if rows:
conn.execute(sa.insert(role_permission_overrides), rows)
conn.commit()
def clear_role_overrides(self, role_id: str) -> None:
with self._conn() as conn:
conn.execute(
sa.delete(role_permission_overrides).where(
role_permission_overrides.c.role_id == role_id
)
)
conn.commit()
def effective_role_permissions(self, role_id: str) -> dict[str, list[str]]:
with self._conn() as conn:
role_row = conn.execute(
sa.select(roles.c.permissions, roles.c.builtin).where(roles.c.role_id == role_id)
).fetchone()
if role_row is None:
return {"baseline": [], "grants": [], "revokes": [], "effective": []}
baseline = _split_perms(role_row[0])
grants: set[str] = set()
revokes: set[str] = set()
if role_row[1]:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id == role_id)
).fetchall()
for perm, action in ov_rows:
if action == "grant":
grants.add(perm)
elif action == "revoke":
revokes.add(perm)
effective = (baseline | grants) - revokes
return {
"baseline": sorted(baseline),
"grants": sorted(grants),
"revokes": sorted(revokes),
"effective": sorted(effective),
}
def effective_role_permissions_bulk(
self, role_ids: list[str]
) -> dict[str, dict[str, list[str]]]:
if not role_ids:
return {}
with self._conn() as conn:
role_rows = conn.execute(
sa.select(roles.c.role_id, roles.c.permissions, roles.c.builtin).where(
roles.c.role_id.in_(role_ids)
)
).fetchall()
if not role_rows:
return {}
builtin_role_ids = [r[0] for r in role_rows if r[2]]
grants: dict[str, set[str]] = {}
revokes: dict[str, set[str]] = {}
if builtin_role_ids:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.role_id,
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id.in_(builtin_role_ids))
).fetchall()
for rid, perm, action in ov_rows:
if action == "grant":
grants.setdefault(rid, set()).add(perm)
elif action == "revoke":
revokes.setdefault(rid, set()).add(perm)
out: dict[str, dict[str, list[str]]] = {}
for rid, perms_str, builtin in role_rows:
baseline = _split_perms(perms_str)
role_grants = grants.get(rid, set()) if builtin else set()
role_revokes = revokes.get(rid, set()) if builtin else set()
effective = (baseline | role_grants) - role_revokes
out[rid] = {
"baseline": sorted(baseline),
"grants": sorted(role_grants),
"revokes": sorted(role_revokes),
"effective": sorted(effective),
}
return out
# -- Organizations --------------------------------------------------------- # -- Organizations ---------------------------------------------------------
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None: def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
+7
View File
@@ -143,6 +143,13 @@ def row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
return d return d
def split_perms(value: str | None) -> set[str]:
"""Split the comma-separated ``roles.permissions`` column into a set."""
if not value:
return set()
return {p.strip() for p in value.split(",") if p.strip()}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Field allowlists for governance update methods # Field allowlists for governance update methods
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -0,0 +1,61 @@
"""Add ``role_permission_overrides`` for editing builtin role permissions.
Builtin roles (``builtin-admin``, ``builtin-operator``, ``builtin-viewer``)
are seeded by migration 008 and treated as immutable the ``roles.permissions``
column on those rows is the *baseline* that subsequent feature migrations
extend (most recently ``040_coord_cluster_admin_perms`` and
``042_coord_trust_send_perm``). Some permissions are deliberately
default-ungranted ``model.skills.write`` is the motivating case: it gates
the ``skills(action=create|update|...)`` in-process tool path and an
operator should consciously opt themselves in before a coordinator session
can mutate the skill catalog. Until now there was no UX to grant such a
permission without dropping into SQL.
This table stores per-(role_id, permission) grant/revoke deltas. The
effective set for a role is computed at permission-load time as
``baseline {action=grant} {action=revoke}``; ``roles.permissions``
stays as today (still the baseline on builtin rows, still the full set on
custom rows where overrides do not apply).
Composite PK ``(role_id, permission)`` collapses repeat toggles for the
same permission onto one row. No FK to ``roles`` matches the rest of
the governance schema (migration 008 does not declare FKs either) and
keeps the postgres dialect aligned with sqlite.
Revision ID: 058
Revises: 057
Create Date: 2026-05-24
"""
import sqlalchemy as sa
from alembic import op
revision = "058"
down_revision = "057"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"role_permission_overrides",
sa.Column("role_id", sa.Text, nullable=False),
sa.Column("permission", sa.Text, nullable=False),
sa.Column("action", sa.Text, nullable=False),
sa.Column("created", sa.Text, nullable=False),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.PrimaryKeyConstraint("role_id", "permission"),
)
op.create_index(
"idx_role_permission_overrides_role",
"role_permission_overrides",
["role_id"],
)
def downgrade() -> None:
op.drop_index(
"idx_role_permission_overrides_role",
table_name="role_permission_overrides",
)
op.drop_table("role_permission_overrides")
+19 -1
View File
@@ -4078,11 +4078,28 @@ def create_app(
# both saved AND loaded is a normal display state. # both saved AND loaded is a normal display state.
saved_loaded_lookup=None, saved_loaded_lookup=None,
) )
approve_handler = make_approve_handler(interactive_endpoint_config) # ``accepted_permissions`` gates the lifted body on any one of the
# named perms when ``cfg.permission_gate`` is ``None`` (interactive
# case) — for the interactive kind it IS the primary gate, not a
# fallback. Coord's ``permission_gate`` (admin.coordinator) takes
# precedence on the coord-config side; here we accept ``admin.
# coordinator`` as a parallel allow so a coord session spawning an
# interactive child workstream isn't blocked by the operator-style
# perm requirement. Was a pre-existing security smell: the
# ``workstreams.create`` / ``workstreams.close`` / ``tools.approve``
# perms were declared and seeded into builtin-operator's baseline
# but never wired to a gate — any authenticated user could hit
# these endpoints regardless of role. See PR adding 057_role_
# permission_overrides for the audit that surfaced this.
approve_handler = make_approve_handler(
interactive_endpoint_config,
accepted_permissions=("tools.approve", "admin.coordinator"),
)
close_handler = make_close_handler( close_handler = make_close_handler(
interactive_endpoint_config, interactive_endpoint_config,
audit_emit=_audit_close_workstream, audit_emit=_audit_close_workstream,
supports_close_reason=True, supports_close_reason=True,
accepted_permissions=("workstreams.close", "admin.coordinator"),
) )
cancel_handler = make_cancel_handler(interactive_endpoint_config) cancel_handler = make_cancel_handler(interactive_endpoint_config)
open_handler = make_open_handler( open_handler = make_open_handler(
@@ -4096,6 +4113,7 @@ def create_app(
create_handler = make_create_handler( create_handler = make_create_handler(
interactive_endpoint_config, interactive_endpoint_config,
audit_emit=_audit_workstream_created, audit_emit=_audit_workstream_created,
accepted_permissions=("workstreams.create", "admin.coordinator"),
) )
list_handler = make_list_handler(interactive_endpoint_config) list_handler = make_list_handler(interactive_endpoint_config)
saved_handler = make_saved_handler(interactive_endpoint_config) saved_handler = make_saved_handler(interactive_endpoint_config)