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]:
# ``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 {
"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
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
# ---------------------------------------------------------------------------
+332
View File
@@ -28,6 +28,8 @@ from turnstone.console.server import (
admin_list_policies,
admin_list_roles,
admin_list_user_roles,
admin_role_effective,
admin_role_overrides,
admin_unassign_role,
admin_update_org,
admin_update_policy,
@@ -100,6 +102,12 @@ def client(storage):
Route("/api/admin/roles", admin_create_role, methods=["POST"]),
Route("/api/admin/roles/{role_id}", admin_update_role, methods=["PUT"]),
Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]),
Route("/api/admin/roles/{role_id}/effective", admin_role_effective),
Route(
"/api/admin/roles/{role_id}/overrides",
admin_role_overrides,
methods=["PUT"],
),
# Users
Route(
"/api/admin/users/{user_id}",
@@ -217,6 +225,94 @@ class TestRoles:
assert resp.status_code == 200, resp.json()
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):
"""Unknown permission strings are rejected — guards the validator
against typos in the constant list and would-be capability inflation
@@ -311,6 +407,242 @@ class TestRoles:
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
# ---------------------------------------------------------------------------
+106
View File
@@ -152,6 +152,112 @@ class TestRoleCRUD:
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
# ---------------------------------------------------------------------------
@@ -36,6 +36,11 @@ def _make_jwt(user_id: str) -> str:
source="test",
secret=_TEST_JWT_SECRET,
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!"
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
return create_jwt(
@@ -30,11 +44,17 @@ def _make_jwt(user_id: str, *, scopes: frozenset[str] | None = None) -> str:
source="test",
secret=_TEST_JWT_SECRET,
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]:
return {"Authorization": f"Bearer {_make_jwt(user, scopes=scopes)}"}
def _auth(
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
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:
def test_refresh_title_requires_live_session(self, app_client):
# Trusted-team model: scope-level auth is the gate; any caller
+19
View File
@@ -189,6 +189,13 @@ class RoleInfo(BaseModel):
org_id: str
created: 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):
@@ -206,6 +213,18 @@ class ListRolesResponse(BaseModel):
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):
role_id: str
+19
View File
@@ -81,7 +81,9 @@ from turnstone.api.console_schemas import (
ParseSkillResponse,
RegistryInstallRequest,
RegistrySearchResponse,
RoleEffectiveResponse,
RoleInfo,
RoleOverridesRequest,
RouteCreateResponse,
RouteResponse,
SetNodeMetadataValueRequest,
@@ -449,6 +451,23 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[400, 404],
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(
"/v1/api/admin/users/{user_id}/roles",
"GET",
+1 -1
View File
@@ -138,7 +138,7 @@ model and behavioral settings after deployment through the admin panel.
## Built-in Roles
- **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
## 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`` 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
# 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)
if isinstance(body, JSONResponse):
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
we do not parse the body just to peek at the metadata.
"""
from turnstone.core.auth import require_any_permission
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
ring_ready = router is not None and router.is_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;
DELETE on ``/send`` is treated as dequeue for audit attribution.
"""
from turnstone.core.auth import require_any_permission
t0 = time.monotonic()
# Extract verb name from URL tail: /v1/api/route/.../send -> "send".
# DELETE on /send is the dequeue path — audit attribution diverges.
verb = request.url.path.rsplit("/", 1)[-1]
if verb == "send" and request.method == "DELETE":
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
ring_ready = router is not None and router.is_ready()
if not ring_ready:
@@ -5904,6 +5945,11 @@ _VALID_PERMISSIONS = frozenset(
# explicitly before their coordinator sessions can mutate the
# catalog.
"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",
"workstreams.create",
"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:
"""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.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")
if 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:
@@ -6074,6 +6151,148 @@ async def admin_delete_role(request: Request) -> JSONResponse:
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:
"""GET /v1/api/admin/users/{user_id}/roles — list roles assigned to a user."""
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:
return JSONResponse({"error": "Cannot modify own role assignments"}, status_code=403)
# Ensure caller holds all permissions present in the target role
target_perms = set(
p.strip() for p in target_role.get("permissions", "").split(",") if p.strip()
)
# Ensure caller holds all permissions present in the target role.
# Read the EFFECTIVE perm set (post-overlay) rather than the raw
# ``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 (
auth_result
and auth_result.permissions
@@ -12507,6 +12730,12 @@ def create_app(
Route("/api/admin/roles", admin_create_role, methods=["POST"]),
Route("/api/admin/roles/{role_id}", admin_update_role, methods=["PUT"]),
Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]),
Route("/api/admin/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",
+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) {
const el = document.getElementById("admin-roles-table");
if (!items.length) {
@@ -67,57 +161,105 @@ function _renderGovRoles(items) {
let html = "";
for (let i = 0; i < items.length; i++) {
const r = items[i];
// Render permissions as badges
const perms = (r.permissions || "").split(",");
let badges = "";
for (let j = 0; j < perms.length; j++) {
const p = perms[j].trim();
if (!p) continue;
let cls = "scope-badge";
if (p === "approve" || p.indexOf("admin.") === 0) cls += " scope-approve";
else if (p === "write" || p.indexOf("workstreams.") === 0)
cls += " scope-write";
badges += '<span class="' + cls + '">' + escapeHtml(p) + "</span>";
const effective = _effectivePerms(r);
const grants = r.grants || [];
const revokes = r.revokes || [];
const modified = grants.length > 0 || revokes.length > 0;
const expanded = _govRoleExpanded.has(r.role_id);
let pills = "";
if (r.builtin)
pills += '<span class="scope-badge scope-channel">builtin</span>';
if (modified) {
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 actions = r.builtin
? ""
: '<button class="admin-btn-action" data-edit-role="' +
escapeHtml(r.role_id) +
'">edit</button>' +
const countChip =
'<span class="perm-count-chip">' +
effective.length +
(effective.length === 1 ? " permission" : " permissions") +
"</span>";
// 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="' +
escapeHtml(r.role_id) +
'" data-role-name="' +
escapeHtml(r.name) +
'">delete</button>';
}
const chevron = expanded ? "▾" : "▸";
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">' +
'<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) +
" " +
typeLabel +
pills +
"</span>" +
'<span class="admin-col admin-col-rperms">' +
badges +
countChip +
"</span>" +
'<span class="admin-col admin-col-actions">' +
actions +
"</span></div>";
if (expanded) html += _renderRoleDrawer(r);
}
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
const editBtns = el.querySelectorAll("[data-edit-role]");
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"));
});
}
// Bind delete
const delBtns = el.querySelectorAll("[data-delete-role]");
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 rname = this.getAttribute("data-role-name");
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
@@ -165,6 +338,7 @@ const _PERMISSION_SECTIONS = [
"admin.roles",
"admin.orgs",
"admin.policies",
"admin.prompt_policies",
"admin.skills",
"admin.audit",
"admin.usage",
@@ -174,11 +348,24 @@ const _PERMISSION_SECTIONS = [
"admin.memories",
"admin.settings",
"admin.mcp",
"admin.models",
"admin.nodes",
"admin.coordinator",
"admin.cluster.inspect",
],
},
{
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",
@@ -196,7 +383,7 @@ const _ALL_PERMISSIONS = (function () {
return flat;
})();
function _buildPermCheckboxes(prefix, selected) {
function _buildPermCheckboxes(prefix, selected, baseline) {
// Emits the toggle-switch component used elsewhere in the admin
// modals so each permission reads as a deliberate on/off rather
// 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">``
// shape is preserved so ``_collectPermCheckboxes`` still picks
// 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 = "";
for (let s = 0; s < _PERMISSION_SECTIONS.length; s++) {
const section = _PERMISSION_SECTIONS[s];
@@ -217,9 +416,28 @@ function _buildPermCheckboxes(prefix, selected) {
'<div class="perm-grid">';
for (let i = 0; i < section.permissions.length; 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 +=
'<label class="toggle-switch perm-toggle">' +
'<label class="toggle-switch perm-toggle' +
extraCls +
'">' +
'<input type="checkbox" value="' +
p +
'" name="' +
@@ -230,6 +448,7 @@ function _buildPermCheckboxes(prefix, selected) {
'<span class="toggle-track" aria-hidden="true"></span>' +
'<span class="toggle-label">' +
escapeHtml(p) +
badge +
"</span></label>";
}
html += "</div></div>";
@@ -326,11 +545,41 @@ function showEditRoleModal(roleId) {
const ov = document.getElementById("edit-role-overlay");
ov.style.display = "flex";
document.getElementById("er-id").value = roleId;
document.getElementById("er-name").value = role.display_name;
const selected = (role.permissions || "").split(",");
// Builtin rows expose the name field read-only — only display_name and
// 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(
document.getElementById("er-perms-container"),
_buildPermCheckboxes("er", selected),
_buildPermCheckboxes("er", selected, baseline),
);
document.getElementById("edit-role-error").classList.remove("is-visible");
_erTrapHandler = _installTrap("edit-role-overlay", "edit-role-box");
@@ -348,13 +597,61 @@ function hideEditRoleModal() {
function submitEditRole() {
const roleId = document.getElementById("er-id").value;
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;
authFetch("/v1/api/admin/roles/" + roleId, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_name: dname, permissions: perms }),
})
let fetchOpts;
let url;
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) {
if (!r.ok)
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-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
# ---------------------------------------------------------------------------
+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``.
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
``__budget_override__`` filter (interactive-only coord workstreams
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
async def approve(request: Request) -> Response:
@@ -714,6 +728,10 @@ def make_approve_handler(cfg: SessionEndpointConfig) -> Handler:
err = cfg.permission_gate(request)
if err is not None:
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)
if err503 is not None:
return err503
@@ -826,6 +844,7 @@ def make_close_handler(
*,
audit_emit: CloseAuditEmitter | None = None,
supports_close_reason: bool = False,
accepted_permissions: tuple[str, ...] = (),
) -> Handler:
"""Lifted body for ``POST {prefix}/{ws_id}/close``.
@@ -870,10 +889,16 @@ def make_close_handler(
async def close(request: Request) -> Response:
import asyncio
from turnstone.core.auth import require_any_permission
if cfg.permission_gate is not None:
err = cfg.permission_gate(request)
if err is not None:
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)
if err503 is not None:
return err503
@@ -1726,6 +1751,7 @@ def make_create_handler(
cfg: SessionEndpointConfig,
*,
audit_emit: CreateAuditEmitter | None = None,
accepted_permissions: tuple[str, ...] = (),
) -> Handler:
"""Lifted body for ``POST {prefix}/new`` — workstream creation.
@@ -1861,6 +1887,7 @@ def make_create_handler(
IMAGE_SIZE_CAP,
validate_and_save_uploaded_files,
)
from turnstone.core.auth import require_any_permission
from turnstone.core.web_helpers import (
read_json_or_400,
read_multipart_create_or_400,
@@ -1870,6 +1897,10 @@ def make_create_handler(
err = cfg.permission_gate(request)
if err is not None:
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)
if err503 is not None:
return err503
+215 -8
View File
@@ -45,6 +45,7 @@ from turnstone.core.storage._schema import (
output_assessments,
output_guard_patterns,
prompt_templates,
role_permission_overrides,
roles,
scheduled_task_runs,
scheduled_tasks,
@@ -121,6 +122,9 @@ from turnstone.core.storage._utils import sanitize_text
from turnstone.core.storage._utils import (
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
log = get_logger(__name__)
@@ -2267,6 +2271,16 @@ class PostgreSQLBackend:
def delete_role(self, role_id: str) -> bool:
with self._conn() as conn:
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))
conn.commit()
return result.rowcount > 0
@@ -2385,20 +2399,213 @@ class PostgreSQLBackend:
def get_user_permissions(self, user_id: str) -> set[str]:
with self._conn() as conn:
rows = conn.execute(
sa.select(roles.c.permissions)
role_rows = conn.execute(
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))
.where(user_roles.c.user_id == user_id)
).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()
for r in rows:
if r[0]:
for p in r[0].split(","):
p = p.strip()
if p:
perms.add(p)
for rid, perms_str, builtin in role_rows:
role_perms = _split_perms(perms_str)
if builtin:
role_perms = (role_perms | grants.get(rid, set())) - revokes.get(rid, set())
perms |= role_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 ---------------------------------------------------------
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]:
"""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 ---------------------------------------------------------
+13
View File
@@ -395,6 +395,19 @@ user_roles = sa.Table(
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",
metadata,
+215 -8
View File
@@ -45,6 +45,7 @@ from turnstone.core.storage._schema import (
output_assessments,
output_guard_patterns,
prompt_templates,
role_permission_overrides,
roles,
scheduled_task_runs,
scheduled_tasks,
@@ -121,6 +122,9 @@ from turnstone.core.storage._utils import sanitize_text
from turnstone.core.storage._utils import (
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
log = get_logger(__name__)
@@ -2417,6 +2421,16 @@ class SQLiteBackend:
def delete_role(self, role_id: str) -> bool:
with self._conn() as conn:
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))
conn.commit()
return result.rowcount > 0
@@ -2547,20 +2561,213 @@ class SQLiteBackend:
def get_user_permissions(self, user_id: str) -> set[str]:
with self._conn() as conn:
rows = conn.execute(
sa.select(roles.c.permissions)
role_rows = conn.execute(
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))
.where(user_roles.c.user_id == user_id)
).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()
for r in rows:
if r[0]:
for p in r[0].split(","):
p = p.strip()
if p:
perms.add(p)
for rid, perms_str, builtin in role_rows:
role_perms = _split_perms(perms_str)
if builtin:
role_perms = (role_perms | grants.get(rid, set())) - revokes.get(rid, set())
perms |= role_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 ---------------------------------------------------------
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
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
# ---------------------------------------------------------------------------
@@ -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.
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(
interactive_endpoint_config,
audit_emit=_audit_close_workstream,
supports_close_reason=True,
accepted_permissions=("workstreams.close", "admin.coordinator"),
)
cancel_handler = make_cancel_handler(interactive_endpoint_config)
open_handler = make_open_handler(
@@ -4096,6 +4113,7 @@ def create_app(
create_handler = make_create_handler(
interactive_endpoint_config,
audit_emit=_audit_workstream_created,
accepted_permissions=("workstreams.create", "admin.coordinator"),
)
list_handler = make_list_handler(interactive_endpoint_config)
saved_handler = make_saved_handler(interactive_endpoint_config)