diff --git a/tests/test_close_reason_persistence.py b/tests/test_close_reason_persistence.py
index cd431e0a..27f8cae4 100644
--- a/tests/test_close_reason_persistence.py
+++ b/tests/test_close_reason_persistence.py
@@ -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'}))}"
)
}
diff --git a/tests/test_console_routing_proxy.py b/tests/test_console_routing_proxy.py
index 0a5d9f92..b6b21691 100644
--- a/tests/test_console_routing_proxy.py
+++ b/tests/test_console_routing_proxy.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/tests/test_governance_endpoints.py b/tests/test_governance_endpoints.py
index 9f6a84cb..61bf9330 100644
--- a/tests/test_governance_endpoints.py
+++ b/tests/test_governance_endpoints.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/tests/test_governance_storage.py b/tests/test_governance_storage.py
index cb33d60b..654d5f60 100644
--- a/tests/test_governance_storage.py
+++ b/tests/test_governance_storage.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/tests/test_server_attachments_on_create.py b/tests/test_server_attachments_on_create.py
index 2b73636a..18e0b66f 100644
--- a/tests/test_server_attachments_on_create.py
+++ b/tests/test_server_attachments_on_create.py
@@ -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"}),
)
diff --git a/tests/test_server_authz.py b/tests/test_server_authz.py
index ac54bc5f..4bc03328 100644
--- a/tests/test_server_authz.py
+++ b/tests/test_server_authz.py
@@ -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
diff --git a/turnstone/api/console_schemas.py b/turnstone/api/console_schemas.py
index 19431a3b..68617ab7 100644
--- a/turnstone/api/console_schemas.py
+++ b/turnstone/api/console_schemas.py
@@ -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
diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py
index f4e74bbb..582e82e6 100644
--- a/turnstone/api/console_spec.py
+++ b/turnstone/api/console_spec.py
@@ -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",
diff --git a/turnstone/bootstrap.py b/turnstone/bootstrap.py
index 132a3dcb..cdbc4aba 100644
--- a/turnstone/bootstrap.py
+++ b/turnstone/bootstrap.py
@@ -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
diff --git a/turnstone/console/server.py b/turnstone/console/server.py
index 5d90ecc8..ebdb110a 100644
--- a/turnstone/console/server.py
+++ b/turnstone/console/server.py
@@ -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",
diff --git a/turnstone/console/static/governance.js b/turnstone/console/static/governance.js
index 7d016967..530c27d4 100644
--- a/turnstone/console/static/governance.js
+++ b/turnstone/console/static/governance.js
@@ -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 = ' +';
+ else if (isRevoke) suffix = ' −';
+ chips +=
+ '' + escapeHtml(p) + suffix + "";
+ }
+ if (!sectionHasAny) continue;
+ body +=
+ '
' +
+ '
' +
+ escapeHtml(section.label) +
+ "
" +
+ '
' +
+ chips +
+ "
";
+ }
+ const drawerActions =
+ role.builtin && (grants.length || revokes.length)
+ ? ''
+ : "";
+ return (
+ '' +
+ body +
+ (drawerActions
+ ? '
' + drawerActions + "
"
+ : "") +
+ "
"
+ );
+}
+
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 += '' + escapeHtml(p) + "";
+ 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 += 'builtin';
+ if (modified) {
+ const deltaLabel =
+ "modified " +
+ (grants.length ? "+" + grants.length : "") +
+ (grants.length && revokes.length ? " / " : "") +
+ (revokes.length ? "−" + revokes.length : "");
+ pills +=
+ '' +
+ escapeHtml(deltaLabel) +
+ "";
}
- const typeLabel = r.builtin
- ? 'builtin'
- : "";
- const actions = r.builtin
- ? ""
- : '' +
+
+ const countChip =
+ '' +
+ effective.length +
+ (effective.length === 1 ? " permission" : " permissions") +
+ "";
+
+ // Builtin rows always get Edit (lands in the overrides editor).
+ // Custom rows get Edit + Delete (existing behavior).
+ let actions =
+ '';
+ if (!r.builtin) {
+ actions +=
'';
+ }
+
+ const chevron = expanded ? "▾" : "▸";
html +=
- '' +
+ '
' +
'' +
+ '" +
escapeHtml(r.display_name) +
" " +
- typeLabel +
+ pills +
"" +
'' +
- badges +
+ countChip +
"" +
'' +
actions +
"
";
+ 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 ``
``
// 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) {
'
';
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 = '−';
+ } else if (!inBase && isChecked) {
+ extraCls = " is-grant";
+ badge = '+';
+ } else if (inBase) {
+ extraCls = " is-baseline";
+ badge =
+ '•';
+ }
+ }
html +=
- '
";
@@ -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) {
diff --git a/turnstone/console/static/style.css b/turnstone/console/static/style.css
index 7fd4098e..c7528e9e 100644
--- a/turnstone/console/static/style.css
+++ b/turnstone/console/static/style.css
@@ -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);
}
/* ==========================================================================
diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py
index ae3035ca..2dcf59a3 100644
--- a/turnstone/core/auth.py
+++ b/turnstone/core/auth.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/turnstone/core/session_routes.py b/turnstone/core/session_routes.py
index 68820579..527353d8 100644
--- a/turnstone/core/session_routes.py
+++ b/turnstone/core/session_routes.py
@@ -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
diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py
index 7159f63b..c864a85f 100644
--- a/turnstone/core/storage/_postgresql.py
+++ b/turnstone/core/storage/_postgresql.py
@@ -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:
diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py
index 01bbd162..6f075672 100644
--- a/turnstone/core/storage/_protocol.py
+++ b/turnstone/core/storage/_protocol.py
@@ -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 ---------------------------------------------------------
diff --git a/turnstone/core/storage/_schema.py b/turnstone/core/storage/_schema.py
index a3999bff..4f9fa0e1 100644
--- a/turnstone/core/storage/_schema.py
+++ b/turnstone/core/storage/_schema.py
@@ -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,
diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py
index 32818670..59e8550c 100644
--- a/turnstone/core/storage/_sqlite.py
+++ b/turnstone/core/storage/_sqlite.py
@@ -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:
diff --git a/turnstone/core/storage/_utils.py b/turnstone/core/storage/_utils.py
index 2608bc55..a7bf8e6b 100644
--- a/turnstone/core/storage/_utils.py
+++ b/turnstone/core/storage/_utils.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/turnstone/core/storage/migrations/versions/058_role_permission_overrides.py b/turnstone/core/storage/migrations/versions/058_role_permission_overrides.py
new file mode 100644
index 00000000..08edfc32
--- /dev/null
+++ b/turnstone/core/storage/migrations/versions/058_role_permission_overrides.py
@@ -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")
diff --git a/turnstone/server.py b/turnstone/server.py
index d6315fd8..9a274abe 100644
--- a/turnstone/server.py
+++ b/turnstone/server.py
@@ -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)