mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
rbac: builtin-role override editor + tighten under-enforced perm gates (#585)
* feat(rbac): editable builtin role permissions via overlay layer
Adds a ``role_permission_overrides`` table that stores per-(role_id,
permission) grant/revoke deltas, applied on top of the immutable
``roles.permissions`` baseline at permission-load time. Builtin roles
(``builtin-admin/operator/viewer``) become customizable through the
admin Roles UI without losing the "reset to default" guarantee — every
override is auditable and reversible.
Motivating case: ``model.skills.write`` is deliberately default-ungranted
on every role so operators must consciously opt in before a coordinator
session can mutate the skill catalog. Until now there was no UX path to
do that opt-in — the only options were dropping into SQL or running a
fresh migration. The overrides editor closes that gap.
Backend
- Migration 057 + storage methods on both sqlite + postgresql backends
- ``get_user_permissions`` merges baseline ∪ grants − revokes for builtin
rows; custom rows pass through unchanged
- ``GET /v1/api/admin/roles/{id}/effective`` for inspect
- ``PUT /v1/api/admin/roles/{id}/overrides`` for write — admin.roles gated,
audited, validates against ``_VALID_PERMISSIONS``, refuses non-builtin
targets, strips no-op grants/revokes before persisting
- Lockout guard: cannot revoke ``admin.roles`` if doing so would leave
zero users with the permission (returns 409)
- ``coordinator.trust.send`` added to ``_VALID_PERMISSIONS`` — was
seeded into builtin-admin by migration 042 but never registered with
the validator, so the very first round-trip through the editor 400'd
on it. Drift-detection test guards future migrations from recreating
the same gap
Frontend
- Roles tab redesign: chevron + permission-count chip replace the
"..." truncation; expand-on-click drawer groups perms by namespace
with baseline / grant (green +) / revoke (red −) chip variants
- Edit modal opens for builtin rows ("Customize Built-in Role" title);
toggles show baseline-default vs override state; submit diffs against
the rendered toggle universe (not raw baseline) so future taxonomy
drift can't silently strip unknown perms
- "Modified +N/-N" pill on rows with active overrides; "Reset to default"
drawer action clears the override set
- ``_PERMISSION_SECTIONS`` brought up to date with all currently-seeded
perms (admin.coordinator, admin.cluster.inspect, admin.models,
admin.nodes, admin.prompt_policies, conversation.modify,
coordinator.trust.send were missing)
Tests
- 7 storage tests covering set/list/clear/effective + overlay merge into
``get_user_permissions`` for both builtin and custom roles
- 11 endpoint tests covering effective/overrides happy paths, validation,
lockout guard, builtin-only restriction, no-op normalization, list
enrichment
* feat(rbac): enforce workstreams.{create,close} + tools.approve gates
These three permissions were declared in ``_VALID_PERMISSIONS``, seeded
into ``builtin-operator``'s baseline by migration 008/017, surfaced in
the admin Roles UI as toggles, and documented in ``bootstrap.py`` as
the operator role's capabilities — and never enforced anywhere. The
audit that ran out of the overlay PR found zero ``require_permission``
sites for any of them; any authenticated user could create workstreams,
close any workstream, or approve any pending tool regardless of role.
Behaviour change for callers without the perms:
- ``POST /v1/api/workstreams/new`` (node + console proxy variants)
now 403 without ``workstreams.create``
- ``POST /v1/api/workstreams/{ws_id}/close`` (and ``/route/`` proxy)
now 403 without ``workstreams.close``
- ``POST /v1/api/workstreams/{ws_id}/approve`` (and ``/route/`` proxy)
now 403 without ``tools.approve``
The OR-fallback to ``admin.coordinator`` keeps coord sessions spawning
interactive children unblocked without needing operator-style perms.
Service-scoped inter-cluster calls bypass via the existing
``allow_service_bypass`` path on the new ``require_any_permission``
helper. Builtin admin and operator both already carry these perms;
viewer correctly loses workstream create/close/approve (it already
couldn't do those in spirit).
Implementation
- ``require_any_permission`` (core/auth.py) — OR-semantics variant of
``require_permission`` with per-conditional comments documenting the
security policy at the choke point. 403 body names every accepted
perm so operators get an actionable remediation
- ``make_{create,close,approve}_handler`` (core/session_routes.py)
accept ``fallback_permissions: tuple[str, ...]`` — checked only when
``cfg.permission_gate is None`` (interactive case). Coord's
``permission_gate=_require_admin_coordinator`` continues to take
precedence on the coord-config side
- Console-side ``create_workstream`` and ``route_create`` inline the
same OR check before proxying — fail fast on a forbidden request
without burning a cluster round-trip
- ``route_proxy`` adds a verb-scoped gate on ``approve`` and ``close``
only; ``send``/``cancel``/``dequeue``/``command``/``plan`` remain
authenticated-only (pre-existing, out of scope for this audit)
Tests
- New ``TestPermissionGatesOnLifecycle`` (4 tests) in test_server_authz
pinning 403-without-perm + non-403-with-perm at the node lift sites
- New ``TestRouteProxyPermissionGates`` (5 tests) in
test_console_routing_proxy covering 403 paths, OR fallback via
``admin.coordinator``, and that ``send`` remains ungated
- ``_make_jwt`` helpers in test_server_authz, test_close_reason_
persistence, test_server_attachments_on_create updated to embed
operator-shaped perms by default so existing tests continue to
exercise the post-gate logic rather than 403'ing on the new check
Docs
- ``bootstrap.py`` operator role line corrected to list every perm
it actually carries (was missing ``tools.approve`` and
``conversation.modify``)
* fix(rbac): close lockout + escalation gaps in role-overrides editor
Three issues surfaced by /review of the overlay layer and gate uplift —
all in the RBAC/auth surface, treated as zero-days.
**F-1: lockout guard misses the grant-removal path.** PUT-replace
semantics on ``set_role_overrides`` mean an existing grant of
``admin.roles`` (added via override to e.g. builtin-operator) is
silently dropped when the new payload omits it. The previous guard
short-circuited on ``"admin.roles" not in revokes`` and never noticed.
Concrete cluster-bricking scenario: grant admin.roles to operator via
override, unassign builtin-admin, click "Reset to default" on operator
→ all users lose admin.roles, recoverable only via SQL.
The rewritten guard simulates the post-PUT effective set on the target
role directly: if ``(baseline | new_grants) - new_revokes`` lacks
admin.roles AND nobody holds it via another role, refuse the change.
The "via another role" question is answered by one bulk query rather
than the prior O(users × roles) round-trip loop.
**F-3: lockout check blocked the event loop on moderate deployments.**
The prior check called ``storage.list_user_roles`` per user and
``storage.effective_role_permissions`` per (user, role) pair —
synchronous SQL inside an async handler. 200 users × 5 roles = 1000
connection cycles long enough to trip reverse-proxy timeouts on a
permission revoke.
Replaced with ``storage.users_with_permission(perm, *,
exclude_role_id)`` — one join over ``user_roles ⋈ roles`` plus one IN
fetch on overrides for the builtin role ids in the result, folded
in-process. Two queries total, independent of cluster size. The whole
check now runs under ``asyncio.to_thread`` so even the bulk read
doesn't stall the loop.
**F-2 reframed: admin_assign_role's subset check ignored the overlay.**
The check at lines 6321-6328 reads ``target_role.get("permissions",
"")`` (baseline column) when computing the perms it requires the
caller to hold. After this branch, an admin.roles holder can grant
e.g. ``model.skills.write`` to builtin-operator via override; an
admin.users holder (who happens to NOT hold that perm) could then
assign operator to a new user, silently escalating the assignee. The
existing two-person-rule by perm split (admin.roles for catalog edits,
admin.users for assignments) only holds if the assignment-time check
considers the overlay. Switched ``target_perms`` to
``storage.effective_role_permissions(role_id)["effective"]``.
Note: this PR retains the existing model where admin.roles is the
catalog-edit superuser (admin_create_role, admin_update_role, and now
admin_role_overrides all skip the caller-holds-grants check). The
two-person rule against escalation lives at the assignment gate, which
this fix reinforces.
**F-7: delete_role left orphaned override rows.** No FK on
``role_permission_overrides.role_id`` (migration 057 omitted FKs to
match the rest of the governance schema). Added explicit cleanup in
both sqlite + postgresql ``delete_role`` implementations so a
re-seeded role_id (deterministic for builtins on schema reseed) can't
silently inherit stale overrides from the prior occupant.
Tests
- storage: ``test_users_with_permission_bulk`` exercises the new bulk
helper including ``exclude_role_id`` and overlay folding
- storage: ``test_delete_role_cleans_up_overrides`` pins the F-7 fix
- endpoint: ``test_overrides_lockout_guard_blocks_grant_removal`` is
the F-1 reproduction — operator-overlay grants admin.roles, builtin-
admin has it removed, attempting to reset operator's overrides 409s
- endpoint: ``test_assign_role_blocks_escalation_via_overlay_grant``
pins the F-2 reframed fix — overlay-poisoned operator can't be
assigned by a caller missing the overlay perms
* refactor(rbac): cleanup batch from /review (#584)
Five non-security findings folded into one commit so the security
batch stays focused. All consistent with the existing intent of
``feat/builtin-role-overrides``.
**F-4: presence check on ``_effectivePerms``.** ``governance.js`` was
guarding on ``Array.isArray(role.effective) && role.effective.length > 0``,
falling through to splitting ``role.permissions`` (the baseline) when
the array was empty. For a builtin role whose overrides legitimately
revoke every baseline perm, that path silently rendered the baseline
chips with no override indicators — the inspector lied about what the
role can do. ``_enrich_role`` always sets ``effective: []``, so
presence is the right sentinel.
**F-5: JS-side drift detector.** Commit 1 added a Python-side test
asserting ``_VALID_PERMISSIONS`` covers every baseline perm; the
mirror invariant on the frontend went uncaught. A new perm added to
``_VALID_PERMISSIONS`` without a matching entry in
``_PERMISSION_SECTIONS`` becomes silently un-customizable through the
admin UI (the only documented grant/revoke path). Test parses the
JS const out via regex and asserts set-equality both directions —
detects "missing in UI" and "extra in UI" so the toggle catalog and
validator can't fork.
**F-6: bulk enrich for ``admin_list_roles``.** Was ``1 +
2*builtin_count + 1*custom_count`` SELECTs per admin-tab open;
collapsed to one ``IN``-filtered query via new
``storage.effective_role_permissions_bulk(role_ids)``. Implemented
on both sqlite + postgresql backends following the existing
``effective_role_permissions`` shape.
**F-8: rename ``fallback_permissions`` → ``accepted_permissions``.**
The lift body uses ``if cfg.permission_gate / elif accepted_permissions``
— mutually exclusive — so when ``permission_gate`` is None this IS
the primary gate, not a fallback to anything. The "fallback" name
suggested a tier-2-after-tier-1 semantic that didn't exist. Renamed
across ``make_{approve,close,create}_handler`` factories, the three
call sites in ``turnstone/server.py``, and the docstrings.
**F-9: positive lift-level tests for ``admin.coordinator``-only.**
``TestPermissionGatesOnLifecycle`` previously had a single positive
test for ``workstreams.create`` alone, plus negative-403 tests for
each verb without perms. The OR-fallback to ``admin.coordinator``
(which keeps coord sessions spawning interactive children unblocked)
had no positive coverage at the lift code path — only at the proxy,
which exercises a different verb-dict gate. Added three tests
(create / close / approve) that pass ``admin.coordinator`` alone and
assert non-403, so a future tightening of the accepted_permissions
tuple can't silently regress coord-driven child workstreams.
Out of scope: nit perf-4 (event-delegation refactor on
``_renderGovRoles``). ``setSafeHtml`` rebuild is the existing
pattern across every admin tab; rewriting one tab's render path on
this branch would be drive-by inconsistent with the surrounding
codebase. Filed as a separate concern if the Roles tab grows past
the scale where it bites.
* fix(rbac-ui): aria-expanded + row-click on Roles drawer (#585)
Two Copilot review findings on governance.js:
- Expand button was missing aria-expanded — screen readers couldn't
announce drawer state. Now reflects the row's expanded flag.
- Comment said "row + chevron both work" but only the chevron was
wired. Added data-expand-role to the row element too so the
existing handler loop (querySelectorAll on the attribute) picks up
both — clicking anywhere in the role row toggles the drawer.
Edit/Delete handlers already stopPropagation so they aren't
triggered by the row-level click.
* fix(migrations): rebase role_permission_overrides to 058
PR #560 mitigation #1 landed 057_output_assessments_llm_judge.py on
main in parallel; my migration claimed the same number, forking
alembic's head and breaking postgres. Renumbered to 058 and
re-pointed down_revision at 057 so the chain stays linear.
No behaviour change — same DDL. Full sweep clean (6730 passed).
* fix(migrations): update 058 revision strings to match filename
Previous commit (ea86aefc) renamed 057_role_permission_overrides.py to
058_* but the in-file revision = "057" / down_revision = "056"
strings stayed — leftover from when the file shipped as 057. Tests
pass because alembic walks the chain by revision string, and the
strings now correctly read revision = "058" / down_revision = "057"
to make the chain linear with main's 057_output_assessments_llm_judge.
Caught locally before re-running CI; my prior `git mv` + content edit
landed as a staged rename + unstaged modification on the previous
push.
This commit is contained in:
@@ -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'}))}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -152,6 +152,112 @@ class TestRoleCRUD:
|
||||
assert db.get_user_permissions("u1") == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role permission overrides (builtin-role customization layer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRolePermissionOverrides:
|
||||
def test_overrides_empty_by_default(self, db):
|
||||
db.create_role("r1", "admin", "Admin", "read,write", builtin=True, org_id="")
|
||||
assert db.list_role_overrides("r1") == []
|
||||
eff = db.effective_role_permissions("r1")
|
||||
assert eff["baseline"] == ["read", "write"]
|
||||
assert eff["grants"] == []
|
||||
assert eff["revokes"] == []
|
||||
assert eff["effective"] == ["read", "write"]
|
||||
|
||||
def test_set_role_overrides_grant_and_revoke(self, db):
|
||||
db.create_role("r1", "admin", "Admin", "read,write", builtin=True, org_id="")
|
||||
db.set_role_overrides("r1", {"approve"}, {"write"}, created_by="u-admin")
|
||||
eff = db.effective_role_permissions("r1")
|
||||
assert eff["baseline"] == ["read", "write"]
|
||||
assert eff["grants"] == ["approve"]
|
||||
assert eff["revokes"] == ["write"]
|
||||
assert eff["effective"] == ["approve", "read"]
|
||||
|
||||
def test_set_role_overrides_replaces_prior_state(self, db):
|
||||
db.create_role("r1", "admin", "Admin", "read,write", builtin=True, org_id="")
|
||||
db.set_role_overrides("r1", {"approve"}, set())
|
||||
db.set_role_overrides("r1", set(), {"write"})
|
||||
rows = db.list_role_overrides("r1")
|
||||
# Prior grant is gone; only the new revoke remains.
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["permission"] == "write"
|
||||
assert rows[0]["action"] == "revoke"
|
||||
|
||||
def test_set_role_overrides_disjoint_required(self, db):
|
||||
db.create_role("r1", "admin", "Admin", "read", builtin=True, org_id="")
|
||||
with pytest.raises(ValueError):
|
||||
db.set_role_overrides("r1", {"write"}, {"write"})
|
||||
|
||||
def test_clear_role_overrides(self, db):
|
||||
db.create_role("r1", "admin", "Admin", "read", builtin=True, org_id="")
|
||||
db.set_role_overrides("r1", {"approve"}, set())
|
||||
assert len(db.list_role_overrides("r1")) == 1
|
||||
db.clear_role_overrides("r1")
|
||||
assert db.list_role_overrides("r1") == []
|
||||
|
||||
def test_get_user_permissions_applies_overlay_to_builtin(self, db):
|
||||
db.create_role("r1", "admin", "Admin", "read,write", builtin=True, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
# Before overrides: baseline only
|
||||
assert db.get_user_permissions("u1") == {"read", "write"}
|
||||
# After overrides: grants in, revokes out
|
||||
db.set_role_overrides("r1", {"approve", "model.skills.write"}, {"write"})
|
||||
assert db.get_user_permissions("u1") == {"read", "approve", "model.skills.write"}
|
||||
|
||||
def test_get_user_permissions_ignores_overlay_on_custom_role(self, db):
|
||||
# Overrides only apply to builtin rows. A custom role with stray
|
||||
# override rows (defensive case — should never happen via the API)
|
||||
# must NOT have them applied.
|
||||
db.create_role("r1", "custom", "Custom", "read", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
db.set_role_overrides("r1", {"approve"}, {"read"})
|
||||
# Effective perms come from the role row only — overlay is dropped.
|
||||
assert db.get_user_permissions("u1") == {"read"}
|
||||
|
||||
def test_users_with_permission_bulk(self, db):
|
||||
# Two roles, three users; only users whose EFFECTIVE perm set
|
||||
# includes the queried perm appear. Drives the lockout-guard
|
||||
# rewrite in admin_role_overrides — one bulk SELECT replaces
|
||||
# the prior per-user/per-role loop.
|
||||
db.create_role("r-adm", "adm", "Adm", "admin.roles,read", builtin=True, org_id="")
|
||||
db.create_role("r-op", "op", "Op", "read,write", builtin=True, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.create_user("u2", "bob", "Bob", "$2b$hash")
|
||||
db.create_user("u3", "cara", "Cara", "$2b$hash")
|
||||
db.assign_role("u1", "r-adm")
|
||||
db.assign_role("u2", "r-op")
|
||||
db.assign_role("u3", "r-op")
|
||||
# Baseline state
|
||||
assert db.users_with_permission("admin.roles") == {"u1"}
|
||||
# Overlay-grant admin.roles to r-op → u2 + u3 now hold it too
|
||||
db.set_role_overrides("r-op", {"admin.roles"}, set())
|
||||
assert db.users_with_permission("admin.roles") == {"u1", "u2", "u3"}
|
||||
# exclude_role_id = r-adm → u1 drops; u2/u3 still hold via r-op
|
||||
assert db.users_with_permission("admin.roles", exclude_role_id="r-adm") == {
|
||||
"u2",
|
||||
"u3",
|
||||
}
|
||||
# Overlay-revoke admin.roles from r-adm → u1 no longer holds via that role
|
||||
db.set_role_overrides("r-adm", set(), {"admin.roles"})
|
||||
assert db.users_with_permission("admin.roles") == {"u2", "u3"}
|
||||
|
||||
def test_delete_role_cleans_up_overrides(self, db):
|
||||
# F-7: no FK on role_permission_overrides. Storage layer must
|
||||
# clean up by hand so a re-seeded role_id (deterministic for
|
||||
# builtins on schema reseed) doesn't silently inherit stale
|
||||
# overrides from the prior occupant.
|
||||
db.create_role("r1", "custom", "Custom", "read", builtin=False, org_id="")
|
||||
db.set_role_overrides("r1", {"approve"}, set())
|
||||
assert len(db.list_role_overrides("r1")) == 1
|
||||
assert db.delete_role("r1") is True
|
||||
assert db.list_role_overrides("r1") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Organizations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -36,6 +36,11 @@ def _make_jwt(user_id: str) -> str:
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
# ``workstreams.create`` is now a real gate on POST /workstreams/new
|
||||
# — see PR adding 057_role_permission_overrides. Embed the perm so
|
||||
# the multipart-create flow under test stays exercising the create
|
||||
# path and not the new 403.
|
||||
permissions=frozenset({"workstreams.create"}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+127
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -138,7 +138,7 @@ model and behavioral settings after deployment through the admin panel.
|
||||
|
||||
## Built-in Roles
|
||||
- **Admin** (`builtin-admin`): Full access — read, write, approve, all admin.* permissions
|
||||
- **Operator** (`builtin-operator`): read, write, workstreams.create, workstreams.close
|
||||
- **Operator** (`builtin-operator`): create / close workstreams, approve tools, modify conversations (read, write, workstreams.create, workstreams.close, tools.approve, conversation.modify)
|
||||
- **Viewer** (`builtin-viewer`): read only
|
||||
|
||||
## Tool Policies
|
||||
|
||||
+235
-6
@@ -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",
|
||||
|
||||
@@ -58,6 +58,100 @@ function loadGovRoles() {
|
||||
});
|
||||
}
|
||||
|
||||
// Set of role_ids whose drawer is open. Persists across re-renders so a
|
||||
// reload (e.g. after edit) doesn't collapse the inspector the user was
|
||||
// looking at.
|
||||
const _govRoleExpanded = new Set();
|
||||
|
||||
function _effectivePerms(role) {
|
||||
// Server's _enrich_role always sets ``effective`` (empty array when
|
||||
// the role legitimately has no perms — e.g. a builtin whose overrides
|
||||
// revoke every baseline entry). Presence is the right sentinel, NOT
|
||||
// length: a length check would silently fall through to the baseline
|
||||
// column for a fully-revoked role, lying to the inspector about what
|
||||
// the role can do. Only the raw-column fallback is for callers that
|
||||
// hit older /v1/api/admin/roles payloads (e.g. mocked tests).
|
||||
if (Array.isArray(role.effective)) return role.effective;
|
||||
return (role.permissions || "")
|
||||
.split(",")
|
||||
.map(function (p) {
|
||||
return p.trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function _renderRoleDrawer(role) {
|
||||
const effective = _effectivePerms(role);
|
||||
const effectiveSet = {};
|
||||
for (let i = 0; i < effective.length; i++) effectiveSet[effective[i]] = true;
|
||||
const baseline = role.builtin
|
||||
? (role.permissions || "")
|
||||
.split(",")
|
||||
.map(function (p) {
|
||||
return p.trim();
|
||||
})
|
||||
.filter(Boolean)
|
||||
: effective;
|
||||
const baselineSet = {};
|
||||
for (let i = 0; i < baseline.length; i++) baselineSet[baseline[i]] = true;
|
||||
const grants = role.grants || [];
|
||||
const revokes = role.revokes || [];
|
||||
const grantSet = {};
|
||||
for (let i = 0; i < grants.length; i++) grantSet[grants[i]] = true;
|
||||
const revokeSet = {};
|
||||
for (let i = 0; i < revokes.length; i++) revokeSet[revokes[i]] = true;
|
||||
|
||||
let body = "";
|
||||
for (let s = 0; s < _PERMISSION_SECTIONS.length; s++) {
|
||||
const section = _PERMISSION_SECTIONS[s];
|
||||
let chips = "";
|
||||
let sectionHasAny = false;
|
||||
for (let i = 0; i < section.permissions.length; i++) {
|
||||
const p = section.permissions[i];
|
||||
const inBase = !!baselineSet[p];
|
||||
const inEff = !!effectiveSet[p];
|
||||
const isGrant = !!grantSet[p];
|
||||
const isRevoke = !!revokeSet[p];
|
||||
if (!inEff && !isRevoke && !isGrant) continue; // hide perms not relevant to this role
|
||||
sectionHasAny = true;
|
||||
let cls = "perm-inspect-chip";
|
||||
if (isGrant) cls += " is-grant";
|
||||
else if (isRevoke) cls += " is-revoke";
|
||||
else if (inBase) cls += " is-baseline";
|
||||
let suffix = "";
|
||||
if (isGrant) suffix = ' <span class="perm-delta-mark">+</span>';
|
||||
else if (isRevoke) suffix = ' <span class="perm-delta-mark">−</span>';
|
||||
chips +=
|
||||
'<span class="' + cls + '">' + escapeHtml(p) + suffix + "</span>";
|
||||
}
|
||||
if (!sectionHasAny) continue;
|
||||
body +=
|
||||
'<div class="role-drawer-section">' +
|
||||
'<div class="role-drawer-section-label">' +
|
||||
escapeHtml(section.label) +
|
||||
"</div>" +
|
||||
'<div class="role-drawer-chips">' +
|
||||
chips +
|
||||
"</div></div>";
|
||||
}
|
||||
const drawerActions =
|
||||
role.builtin && (grants.length || revokes.length)
|
||||
? '<button class="admin-btn-action" data-reset-role="' +
|
||||
escapeHtml(role.role_id) +
|
||||
'">Reset to default</button>'
|
||||
: "";
|
||||
return (
|
||||
'<div class="admin-role-drawer" data-drawer-role="' +
|
||||
escapeHtml(role.role_id) +
|
||||
'">' +
|
||||
body +
|
||||
(drawerActions
|
||||
? '<div class="role-drawer-actions">' + drawerActions + "</div>"
|
||||
: "") +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function _renderGovRoles(items) {
|
||||
const el = document.getElementById("admin-roles-table");
|
||||
if (!items.length) {
|
||||
@@ -67,57 +161,105 @@ function _renderGovRoles(items) {
|
||||
let html = "";
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const r = items[i];
|
||||
// Render permissions as badges
|
||||
const perms = (r.permissions || "").split(",");
|
||||
let badges = "";
|
||||
for (let j = 0; j < perms.length; j++) {
|
||||
const p = perms[j].trim();
|
||||
if (!p) continue;
|
||||
let cls = "scope-badge";
|
||||
if (p === "approve" || p.indexOf("admin.") === 0) cls += " scope-approve";
|
||||
else if (p === "write" || p.indexOf("workstreams.") === 0)
|
||||
cls += " scope-write";
|
||||
badges += '<span class="' + cls + '">' + escapeHtml(p) + "</span>";
|
||||
const effective = _effectivePerms(r);
|
||||
const grants = r.grants || [];
|
||||
const revokes = r.revokes || [];
|
||||
const modified = grants.length > 0 || revokes.length > 0;
|
||||
const expanded = _govRoleExpanded.has(r.role_id);
|
||||
|
||||
let pills = "";
|
||||
if (r.builtin)
|
||||
pills += '<span class="scope-badge scope-channel">builtin</span>';
|
||||
if (modified) {
|
||||
const deltaLabel =
|
||||
"modified " +
|
||||
(grants.length ? "+" + grants.length : "") +
|
||||
(grants.length && revokes.length ? " / " : "") +
|
||||
(revokes.length ? "−" + revokes.length : "");
|
||||
pills +=
|
||||
'<span class="scope-badge scope-write" title="' +
|
||||
escapeHtml(deltaLabel) +
|
||||
'">' +
|
||||
escapeHtml(deltaLabel) +
|
||||
"</span>";
|
||||
}
|
||||
const typeLabel = r.builtin
|
||||
? '<span class="scope-badge scope-channel">builtin</span>'
|
||||
: "";
|
||||
const actions = r.builtin
|
||||
? ""
|
||||
: '<button class="admin-btn-action" data-edit-role="' +
|
||||
escapeHtml(r.role_id) +
|
||||
'">edit</button>' +
|
||||
|
||||
const countChip =
|
||||
'<span class="perm-count-chip">' +
|
||||
effective.length +
|
||||
(effective.length === 1 ? " permission" : " permissions") +
|
||||
"</span>";
|
||||
|
||||
// Builtin rows always get Edit (lands in the overrides editor).
|
||||
// Custom rows get Edit + Delete (existing behavior).
|
||||
let actions =
|
||||
'<button class="admin-btn-action" data-edit-role="' +
|
||||
escapeHtml(r.role_id) +
|
||||
'">edit</button>';
|
||||
if (!r.builtin) {
|
||||
actions +=
|
||||
'<button class="admin-btn-danger" data-delete-role="' +
|
||||
escapeHtml(r.role_id) +
|
||||
'" data-role-name="' +
|
||||
escapeHtml(r.name) +
|
||||
'">delete</button>';
|
||||
}
|
||||
|
||||
const chevron = expanded ? "▾" : "▸";
|
||||
html +=
|
||||
'<div class="admin-row" role="listitem">' +
|
||||
'<div class="admin-row admin-role-row" role="listitem" data-role-id="' +
|
||||
escapeHtml(r.role_id) +
|
||||
'" data-expanded="' +
|
||||
(expanded ? "true" : "false") +
|
||||
'" data-expand-role="' +
|
||||
escapeHtml(r.role_id) +
|
||||
'">' +
|
||||
'<span class="admin-col admin-col-rname">' +
|
||||
'<button type="button" class="role-expand-btn" aria-label="Toggle details" aria-expanded="' +
|
||||
(expanded ? "true" : "false") +
|
||||
'" data-expand-role="' +
|
||||
escapeHtml(r.role_id) +
|
||||
'">' +
|
||||
chevron +
|
||||
"</button>" +
|
||||
escapeHtml(r.display_name) +
|
||||
" " +
|
||||
typeLabel +
|
||||
pills +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-rperms">' +
|
||||
badges +
|
||||
countChip +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-actions">' +
|
||||
actions +
|
||||
"</span></div>";
|
||||
if (expanded) html += _renderRoleDrawer(r);
|
||||
}
|
||||
setSafeHtml(el, html);
|
||||
|
||||
// Bind expand toggle (row + chevron both work)
|
||||
const expandBtns = el.querySelectorAll("[data-expand-role]");
|
||||
for (let k = 0; k < expandBtns.length; k++) {
|
||||
expandBtns[k].addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
const rid = this.getAttribute("data-expand-role");
|
||||
if (_govRoleExpanded.has(rid)) _govRoleExpanded.delete(rid);
|
||||
else _govRoleExpanded.add(rid);
|
||||
_renderGovRoles(_govRoles);
|
||||
});
|
||||
}
|
||||
// Bind edit
|
||||
const editBtns = el.querySelectorAll("[data-edit-role]");
|
||||
for (let k = 0; k < editBtns.length; k++) {
|
||||
editBtns[k].addEventListener("click", function () {
|
||||
editBtns[k].addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
showEditRoleModal(this.getAttribute("data-edit-role"));
|
||||
});
|
||||
}
|
||||
// Bind delete
|
||||
const delBtns = el.querySelectorAll("[data-delete-role]");
|
||||
for (let k = 0; k < delBtns.length; k++) {
|
||||
delBtns[k].addEventListener("click", function () {
|
||||
delBtns[k].addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
const rid = this.getAttribute("data-delete-role");
|
||||
const rname = this.getAttribute("data-role-name");
|
||||
showConfirmModal(
|
||||
@@ -143,6 +285,37 @@ function _renderGovRoles(items) {
|
||||
);
|
||||
});
|
||||
}
|
||||
// Bind reset-to-default (drawer action; builtin + overridden only)
|
||||
const resetBtns = el.querySelectorAll("[data-reset-role]");
|
||||
for (let k = 0; k < resetBtns.length; k++) {
|
||||
resetBtns[k].addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
const rid = this.getAttribute("data-reset-role");
|
||||
showConfirmModal(
|
||||
"Reset overrides",
|
||||
"Drop all overrides on this builtin role? Effective permissions return to the shipped baseline.",
|
||||
"Reset",
|
||||
function () {
|
||||
authFetch("/v1/api/admin/roles/" + rid + "/overrides", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ grant: [], revoke: [] }),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error();
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Overrides cleared");
|
||||
loadGovRoles();
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to reset overrides");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Permission inventory grouped by namespace so the role modal can
|
||||
@@ -165,6 +338,7 @@ const _PERMISSION_SECTIONS = [
|
||||
"admin.roles",
|
||||
"admin.orgs",
|
||||
"admin.policies",
|
||||
"admin.prompt_policies",
|
||||
"admin.skills",
|
||||
"admin.audit",
|
||||
"admin.usage",
|
||||
@@ -174,11 +348,24 @@ const _PERMISSION_SECTIONS = [
|
||||
"admin.memories",
|
||||
"admin.settings",
|
||||
"admin.mcp",
|
||||
"admin.models",
|
||||
"admin.nodes",
|
||||
"admin.coordinator",
|
||||
"admin.cluster.inspect",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Workstreams & Tools",
|
||||
permissions: ["workstreams.create", "workstreams.close", "tools.approve"],
|
||||
permissions: [
|
||||
"workstreams.create",
|
||||
"workstreams.close",
|
||||
"conversation.modify",
|
||||
"tools.approve",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Coordinator",
|
||||
permissions: ["coordinator.trust.send"],
|
||||
},
|
||||
{
|
||||
label: "Model",
|
||||
@@ -196,7 +383,7 @@ const _ALL_PERMISSIONS = (function () {
|
||||
return flat;
|
||||
})();
|
||||
|
||||
function _buildPermCheckboxes(prefix, selected) {
|
||||
function _buildPermCheckboxes(prefix, selected, baseline) {
|
||||
// Emits the toggle-switch component used elsewhere in the admin
|
||||
// modals so each permission reads as a deliberate on/off rather
|
||||
// than a generic checkbox. Sections are wrapped in a
|
||||
@@ -206,6 +393,18 @@ function _buildPermCheckboxes(prefix, selected) {
|
||||
// The underlying ``<input type="checkbox" name="{prefix}-perm">``
|
||||
// shape is preserved so ``_collectPermCheckboxes`` still picks
|
||||
// them up regardless of section.
|
||||
//
|
||||
// ``baseline`` is optional — when provided (builtin-role edits) each
|
||||
// toggle gets a small visual mark showing whether the perm is on by
|
||||
// default and whether the current state is an override (added or
|
||||
// removed). ``submitEditRole`` diffs the toggle state against the
|
||||
// baseline to produce the {grant, revoke} payload for /overrides.
|
||||
const baselineSet = {};
|
||||
if (baseline)
|
||||
for (let i = 0; i < baseline.length; i++) baselineSet[baseline[i]] = true;
|
||||
const selectedSet = {};
|
||||
if (selected)
|
||||
for (let i = 0; i < selected.length; i++) selectedSet[selected[i]] = true;
|
||||
let html = "";
|
||||
for (let s = 0; s < _PERMISSION_SECTIONS.length; s++) {
|
||||
const section = _PERMISSION_SECTIONS[s];
|
||||
@@ -217,9 +416,28 @@ function _buildPermCheckboxes(prefix, selected) {
|
||||
'<div class="perm-grid">';
|
||||
for (let i = 0; i < section.permissions.length; i++) {
|
||||
const p = section.permissions[i];
|
||||
const checked = selected && selected.indexOf(p) >= 0 ? " checked" : "";
|
||||
const isChecked = !!selectedSet[p];
|
||||
const inBase = !!baselineSet[p];
|
||||
const checked = isChecked ? " checked" : "";
|
||||
let extraCls = "";
|
||||
let badge = "";
|
||||
if (baseline) {
|
||||
if (inBase && !isChecked) {
|
||||
extraCls = " is-revoke";
|
||||
badge = '<span class="perm-baseline-mark" title="Revoked">−</span>';
|
||||
} else if (!inBase && isChecked) {
|
||||
extraCls = " is-grant";
|
||||
badge = '<span class="perm-baseline-mark" title="Granted">+</span>';
|
||||
} else if (inBase) {
|
||||
extraCls = " is-baseline";
|
||||
badge =
|
||||
'<span class="perm-baseline-mark is-default" title="Default">•</span>';
|
||||
}
|
||||
}
|
||||
html +=
|
||||
'<label class="toggle-switch perm-toggle">' +
|
||||
'<label class="toggle-switch perm-toggle' +
|
||||
extraCls +
|
||||
'">' +
|
||||
'<input type="checkbox" value="' +
|
||||
p +
|
||||
'" name="' +
|
||||
@@ -230,6 +448,7 @@ function _buildPermCheckboxes(prefix, selected) {
|
||||
'<span class="toggle-track" aria-hidden="true"></span>' +
|
||||
'<span class="toggle-label">' +
|
||||
escapeHtml(p) +
|
||||
badge +
|
||||
"</span></label>";
|
||||
}
|
||||
html += "</div></div>";
|
||||
@@ -326,11 +545,41 @@ function showEditRoleModal(roleId) {
|
||||
const ov = document.getElementById("edit-role-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("er-id").value = roleId;
|
||||
document.getElementById("er-name").value = role.display_name;
|
||||
const selected = (role.permissions || "").split(",");
|
||||
// Builtin rows expose the name field read-only — only display_name and
|
||||
// permissions are mutable on customs; for builtins, only permissions
|
||||
// (via the override layer). The display_name input is kept visible on
|
||||
// builtin rows but disabled to reduce surprise.
|
||||
const nameInput = document.getElementById("er-name");
|
||||
nameInput.value = role.display_name;
|
||||
nameInput.disabled = !!role.builtin;
|
||||
|
||||
const titleEl = document.getElementById("edit-role-title");
|
||||
if (titleEl)
|
||||
titleEl.textContent = role.builtin
|
||||
? "Customize Built-in Role"
|
||||
: "Edit Role";
|
||||
|
||||
const baseline = role.builtin
|
||||
? (role.permissions || "")
|
||||
.split(",")
|
||||
.map(function (p) {
|
||||
return p.trim();
|
||||
})
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const selected = _effectivePerms(role);
|
||||
|
||||
// Persist the baseline + builtin flag on the form so submit can diff
|
||||
// without re-walking _govRoles (which could have been refreshed mid-edit).
|
||||
const form = document.getElementById("edit-role-box");
|
||||
if (form) {
|
||||
form.dataset.builtin = role.builtin ? "1" : "0";
|
||||
form.dataset.baseline = baseline ? baseline.join(",") : "";
|
||||
}
|
||||
|
||||
setSafeHtml(
|
||||
document.getElementById("er-perms-container"),
|
||||
_buildPermCheckboxes("er", selected),
|
||||
_buildPermCheckboxes("er", selected, baseline),
|
||||
);
|
||||
document.getElementById("edit-role-error").classList.remove("is-visible");
|
||||
_erTrapHandler = _installTrap("edit-role-overlay", "edit-role-box");
|
||||
@@ -348,13 +597,61 @@ function hideEditRoleModal() {
|
||||
function submitEditRole() {
|
||||
const roleId = document.getElementById("er-id").value;
|
||||
const dname = document.getElementById("er-name").value.trim();
|
||||
const perms = _collectPermCheckboxes("er");
|
||||
const form = document.getElementById("edit-role-box");
|
||||
const isBuiltin = form && form.dataset.builtin === "1";
|
||||
document.getElementById("er-submit").disabled = true;
|
||||
authFetch("/v1/api/admin/roles/" + roleId, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display_name: dname, permissions: perms }),
|
||||
})
|
||||
|
||||
let fetchOpts;
|
||||
let url;
|
||||
if (isBuiltin) {
|
||||
// Diff against baseline → {grant, revoke}. Display name is immutable.
|
||||
//
|
||||
// Crucial safety property: the diff universe is the set of permissions
|
||||
// we actually RENDERED as toggles, not the full baseline. If the
|
||||
// permission taxonomy in `_PERMISSION_SECTIONS` ever falls behind a
|
||||
// new server-side perm (e.g. migration adds it to builtin-admin
|
||||
// before this file ships the toggle), naïve baseline-vs-selected
|
||||
// diffing would treat every unrendered perm as "user wants this
|
||||
// revoked" and silently strip it. Limiting the universe to rendered
|
||||
// toggles makes the editor a NO-OP for unknown perms — they pass
|
||||
// through untouched.
|
||||
const renderedNodes = document.querySelectorAll('input[name="er-perm"]');
|
||||
const renderedSet = {};
|
||||
for (let i = 0; i < renderedNodes.length; i++)
|
||||
renderedSet[renderedNodes[i].value] = true;
|
||||
const baseline = (form.dataset.baseline || "").split(",").filter(Boolean);
|
||||
const baselineSet = {};
|
||||
for (let i = 0; i < baseline.length; i++) baselineSet[baseline[i]] = true;
|
||||
const selectedStr = _collectPermCheckboxes("er");
|
||||
const selected = selectedStr.split(",").filter(Boolean);
|
||||
const selectedSet = {};
|
||||
for (let i = 0; i < selected.length; i++) selectedSet[selected[i]] = true;
|
||||
const grant = [];
|
||||
const revoke = [];
|
||||
for (const p in selectedSet) {
|
||||
if (!baselineSet[p]) grant.push(p);
|
||||
}
|
||||
for (const p in baselineSet) {
|
||||
// Only revoke perms the user could actually see — unrendered ones
|
||||
// are out of scope for this edit and must round-trip unchanged.
|
||||
if (renderedSet[p] && !selectedSet[p]) revoke.push(p);
|
||||
}
|
||||
url = "/v1/api/admin/roles/" + roleId + "/overrides";
|
||||
fetchOpts = {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ grant: grant, revoke: revoke }),
|
||||
};
|
||||
} else {
|
||||
const perms = _collectPermCheckboxes("er");
|
||||
url = "/v1/api/admin/roles/" + roleId;
|
||||
fetchOpts = {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display_name: dname, permissions: perms }),
|
||||
};
|
||||
}
|
||||
authFetch(url, fetchOpts)
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ---------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -143,6 +143,13 @@ def row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
|
||||
return d
|
||||
|
||||
|
||||
def split_perms(value: str | None) -> set[str]:
|
||||
"""Split the comma-separated ``roles.permissions`` column into a set."""
|
||||
if not value:
|
||||
return set()
|
||||
return {p.strip() for p in value.split(",") if p.strip()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field allowlists for governance update methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Add ``role_permission_overrides`` for editing builtin role permissions.
|
||||
|
||||
Builtin roles (``builtin-admin``, ``builtin-operator``, ``builtin-viewer``)
|
||||
are seeded by migration 008 and treated as immutable — the ``roles.permissions``
|
||||
column on those rows is the *baseline* that subsequent feature migrations
|
||||
extend (most recently ``040_coord_cluster_admin_perms`` and
|
||||
``042_coord_trust_send_perm``). Some permissions are deliberately
|
||||
default-ungranted — ``model.skills.write`` is the motivating case: it gates
|
||||
the ``skills(action=create|update|...)`` in-process tool path and an
|
||||
operator should consciously opt themselves in before a coordinator session
|
||||
can mutate the skill catalog. Until now there was no UX to grant such a
|
||||
permission without dropping into SQL.
|
||||
|
||||
This table stores per-(role_id, permission) grant/revoke deltas. The
|
||||
effective set for a role is computed at permission-load time as
|
||||
``baseline ∪ {action=grant} − {action=revoke}``; ``roles.permissions``
|
||||
stays as today (still the baseline on builtin rows, still the full set on
|
||||
custom rows where overrides do not apply).
|
||||
|
||||
Composite PK ``(role_id, permission)`` collapses repeat toggles for the
|
||||
same permission onto one row. No FK to ``roles`` — matches the rest of
|
||||
the governance schema (migration 008 does not declare FKs either) and
|
||||
keeps the postgres dialect aligned with sqlite.
|
||||
|
||||
Revision ID: 058
|
||||
Revises: 057
|
||||
Create Date: 2026-05-24
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "058"
|
||||
down_revision = "057"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"role_permission_overrides",
|
||||
sa.Column("role_id", sa.Text, nullable=False),
|
||||
sa.Column("permission", sa.Text, nullable=False),
|
||||
sa.Column("action", sa.Text, nullable=False),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.PrimaryKeyConstraint("role_id", "permission"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_role_permission_overrides_role",
|
||||
"role_permission_overrides",
|
||||
["role_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"idx_role_permission_overrides_role",
|
||||
table_name="role_permission_overrides",
|
||||
)
|
||||
op.drop_table("role_permission_overrides")
|
||||
+19
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user