Skip to content

[RBAC PR 1 follow-up] Fail closed for grant delegation#2339

Open
philipfweiss wants to merge 1 commit into
DataJunction:mainfrom
philipfweiss:rbac-control-plane-readiness
Open

[RBAC PR 1 follow-up] Fail closed for grant delegation#2339
philipfweiss wants to merge 1 commit into
DataJunction:mainfrom
philipfweiss:rbac-control-plane-readiness

Conversation

@philipfweiss

@philipfweiss philipfweiss commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Tracking: #2234 (step 1 follow-up in the RBAC enablement sequence).

DJ's default RBAC policy is permissive, and the endpoints that hand out permissions (create a role, add a scope, assign a role, manage a group) rely on that same fallback. So on a default deployment, any logged-in user can POST /roles/ granting node:* (write on every node) and assign it to themselves. The permission system cannot be enforced if any user can grant themselves any permission.

Why this was missed initially: #2230 added the MANAGE checks and was verified with the policy flipped to restrictive, and its endpoint tests mocked MANAGE denial directly. Neither path ran the real permission logic under the normal permissive default, which is exactly where the hole is. The test database is also built from ORM metadata, so it never hit the Alembic enum mismatch that this PR also fixes.

Background: what "grant delegation" means here

The RBAC admin endpoints are how one principal hands permissions to another. Handing out access is itself a privileged action. The rule this PR enforces: you can only give away access that you already administer. Concretely, if you hold MANAGE on the finance.* namespace, you may create or assign roles scoped inside finance.*, but not roles that touch growth.* or everything.

What this PR changes

These rules now apply to the role and group admin endpoints only. Ordinary metadata calls (creating nodes, etc.) are unchanged.

  1. No permissive fallback for granting permissions. Creating, updating, assigning, or revoking a role, and creating or editing a group, now require the caller to actually hold a matching MANAGE grant. The permissive default no longer counts as authorization for these endpoints. This is the core fix: it is what stops a normal user from self-granting node:*.

  2. You can only delegate what you manage (containment). Every scope in a role you create or assign must fit inside one of your own MANAGE scopes. With a caller who holds MANAGE on namespace:finance.*:

    • allowed: a role granting read on finance.revenue.* (inside finance.*)
    • denied: a role granting read on growth.* (a namespace you do not manage)
    • denied: a role granting write on node:* or namespace:* (everything)
  3. Over-broad and malformed patterns are rejected. Only three scope shapes are accepted for containment: a single namespace/node (finance.revenue), a subtree (finance.*), or global (*). Ambiguous values like finance*, .*, **, or mid-string wildcards can never satisfy the containment check, so they cannot be used to sneak past as an accidental match-all.

  4. Admins still bypass. The existing is_admin break-glass path still authorizes these endpoints, so a fresh deployment can bootstrap its first grants.

  5. Migrated-database fix (found during manual verification). On a real Alembic-migrated database, the RBAC scope enums are stored as lowercase and the history entity enum was missing the RBAC labels, so creating a role returned a 500. This PR serializes the enums with the values Alembic actually created and adds a migration for the missing history labels, so the endpoints work on deployed schemas, not only on test databases.

Behavior change: on the default permissive policy, the role and group admin endpoints now return 403 instead of silently allowing a self-grant. A caller needs an explicit MANAGE grant (or admin). Everything outside these endpoints keeps its current behavior, and the passthrough provider still approves everything for local dev and tests.


Verification:

  1. Started this branch in slot 1, logged in as the seeded non-admin dj user, and confirmed the server was healthy.
./dev.sh up -d
GET http://localhost:8100/health/
-> [{"name":"database","status":"ok"}] HTTP_STATUS:200

POST /basic/login/ username=dj password=dj
-> HTTP_STATUS:200
GET /whoami/
-> {"username":"dj","is_admin":false,...} HTTP_STATUS:200
  1. As a normal user with no grants and the default permissive policy, confirmed the self-grant hole is closed: global, malformed, and group-admin requests are now denied.
POST /roles/ {scope_type:"node", scope_value:"*"}
-> {"message":"Access denied to 1 resource(s): *",...} HTTP_STATUS:403

POST /roles/ {scope_type:"namespace", scope_value:".*"}
-> {"message":"Access denied to 1 resource(s): .*",...} HTTP_STATUS:403

POST /groups/?username=manual-team-final
-> {"message":"Access denied to 1 resource(s): *",...} HTTP_STATUS:403
  1. Used the admin bypass to bootstrap one manager grant (MANAGE namespace:finance.*) assigned to dj, then dropped dj back to a non-admin user.
UPDATE users SET is_admin=true WHERE username='dj';
POST /roles/ manual-finance-manager-final [MANAGE namespace:finance.*]
-> HTTP_STATUS:201
POST /roles/manual-finance-manager-final/assign {principal_username:"dj"}
-> HTTP_STATUS:201
UPDATE users SET is_admin=false WHERE username='dj';
GET /whoami/
-> {"username":"dj","is_admin":false,...} HTTP_STATUS:200
  1. As that non-admin manager, confirmed delegation inside finance.* is allowed while anything outside it is denied.
POST /roles/ manual-finance-reader-final [READ namespace:finance.revenue.*]
-> HTTP_STATUS:201

POST /roles/ manual-growth-reader-final [READ namespace:growth.*]
-> {"message":"Access denied to 1 resource(s): growth.*",...} HTTP_STATUS:403

POST /roles/ manual-global-node-with-finance-final [WRITE node:*]
-> {"message":"Access denied to 1 resource(s): *",...} HTTP_STATUS:403

@netlify

netlify Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy Preview for thriving-cassata-78ae72 canceled.

Name Link
🔨 Latest commit a24b834
🔍 Latest deploy log https://app.netlify.com/projects/thriving-cassata-78ae72/deploys/6a5ada251cf1130008151433

@philipfweiss philipfweiss changed the title Harden RBAC grant delegation and break-glass readiness [RBAC follow-up] Fail closed for grant delegation Jul 17, 2026
@philipfweiss
philipfweiss force-pushed the rbac-control-plane-readiness branch 2 times, most recently from d66b710 to 241c040 Compare July 18, 2026 00:45
@philipfweiss philipfweiss changed the title [RBAC follow-up] Fail closed for grant delegation [RBAC PR 1 follow-up] Fail closed for grant delegation Jul 18, 2026
@philipfweiss
philipfweiss marked this pull request as ready for review July 18, 2026 01:16
Require explicit MANAGE grants containing every delegated scope so permissive fallback cannot authorize role or group administration. Align RBAC enum serialization and history labels with migrated schemas.
@philipfweiss
philipfweiss force-pushed the rbac-control-plane-readiness branch from 241c040 to a24b834 Compare July 18, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant