Skip to content

feat: bind role authoring and assignment to the caller's own permissions - #1022

Open
rohan-chaturvedi wants to merge 3 commits into
mainfrom
feat--role-assignment-scoping
Open

rohan-chaturvedi wants to merge 3 commits into
mainfrom
feat--role-assignment-scoping

Conversation

@rohan-chaturvedi

Copy link
Copy Markdown
Member

🔍 Overview

Role authoring and role assignment were not bound by the acting user's own role. A user who could manage roles could define a role with any permission set, including permissions their own role does not hold. A user who could assign roles could then attach that role, or any other existing role, to another member, an invite, or a service account, and act through that principal.

This PR makes both operations bounded by the caller's own permissions. The rule is a single invariant applied everywhere a role is written or attached to a principal:

A user can only grant permissions that their own role already holds.

Owners and Admins (roles with global access) are exempt and keep their existing delegation abilities.

💡 Proposed Changes

Shared comparator (backend/api/utils/access/permissions.py)

  • get_role_effective_policy resolves a role's real policy. Managed roles resolve from their template (their DB JSON is empty by design), custom roles from stored JSON, and custom roles never have effective global access.
  • role_grant_violations(actor_role, target_policy) returns the permissions in the target that exceed the actor, as <scope>:<resource>:<action> strings. Missing keys grant nothing, unknown resources and malformed legacy shapes are reported as violations rather than coerced to empty, and a missing actor role fails closed.
  • role_update_grant_violations(actor_role, current_role, new_policy) reports only what an edit adds, so existing roles stay editable.
  • roles_grant_violations / role_assignment_error apply the same comparison across several actor roles with union semantics, for team-scoped callers.

Authoring is bounded by the author

  • GraphQL createCustomRole / updateCustomRole and REST POST /v1/roles/ and PUT /v1/roles/{id} now reject a payload carrying permissions the caller does not hold, naming each one.
  • Creation checks the whole payload. Updates check only the permissions the edit adds, so a user can rename, recolour, re-describe, or narrow a role that already holds permissions above their own.
  • Both update paths now read the role with select_for_update() inside transaction.atomic(), so the ceiling is computed against the row being written.

Assignment is bounded by the assigner

  • Members: GraphQL updateOrganisationMemberRole and REST PUT /v1/members/{id}.
  • Invites: GraphQL bulkInviteOrganisationMembers and REST POST /v1/members/invites. Bulk invite now validates every invite in the batch before it creates or emails any of them, so a partially rejected batch creates nothing.
  • Service accounts: GraphQL createServiceAccount / updateServiceAccount and REST create and PUT /v1/service-accounts/{id}.
  • The check runs only when the role actually changes, so renames and no-op updates are unaffected. For team-owned service accounts the caller's permissions are the union of their organisation role and the team's role override, matching how the permission gate resolves the effective role.

Service account key material is verified

The assignment ceiling is only meaningful if a caller cannot simply act as an existing, stronger service account. Caller-supplied key material is now verified rather than trusted:

  • Client-created tokens carry a detached Ed25519 signature over the token material, verified against the service account's identity key, and the submitted identity key must equal the key derived from that identity. Only a holder of the service account keyring can register a token.
  • Server-side keyrings are bound to the service account identity before they are stored (enableServiceAccountServerSideKeyManagement, and service account creation when server keys are supplied) and before any mint path uses them (GraphQL, REST, and the AWS and Azure identity exchanges).
  • Environment keys are wrapped to the key derived from the service account identity rather than to the public key inside the stored blob.
  • Service account lookups in these mutations now exclude soft-deleted accounts.

Managed role change

Manager gains app-level Environments: delete (template version 3). Without it the default Service role is not a subset of Manager, and Managers could no longer create service accounts with the stock role.

Console

  • New shared AssignableRoleOption renders roles the viewer cannot grant as disabled, with a lock icon and an explanation.
  • Applied to the member role selector, the invite dialog, and both service account role pickers.
  • The role dialogs disable individual permission toggles and access templates the viewer cannot grant.
  • The create service account dialog no longer locks in a team role the viewer cannot grant and then fails on submit.
  • Failures that are not GraphQL errors (network errors, client-side key re-wrapping) are surfaced again through a small isHandledGraphQLError helper, instead of being swallowed by empty catch blocks.

Fix picked up along the way

serviceAccountHandlers raised a 500 for the whole organisation if any custom role stored a null or non-dict permissions map, which the validator allows.

🖼️ Screenshots or Demo

Screenshot From 2026-09-15 14-28-08 Screenshot From 2026-09-15 14-32-36

📝 Release Notes

Role permissions are now bounded by your own role

  • You can only create, edit, or assign roles with permissions that your own role holds. Owners and Admins are not affected.
  • This applies when you create or edit a custom role, change a member's role, invite a member, or create or update a service account.
  • If a role already holds permissions your role does not, you can still rename it, change its description and colour, and remove those permissions. You cannot add them back.
  • Role pickers and permission toggles in the console now show what you cannot grant, instead of failing on save.
  • The REST API returns 403 Forbidden with an error naming each permission you do not hold.

Managed role change

  • The Manager role now includes delete on app environments, in addition to read, create and update. This applies to all existing Managers on upgrade. No migration is required.

Possible impact on narrow custom roles

  • A custom role that can create service accounts but holds fewer permissions than the default Service role can no longer assign that role. An Owner or Admin can widen the custom role, or create the service account.

Service accounts

  • The backend now verifies the cryptographic material of client-created service account tokens and of server-side keyrings. Tokens created through the console are unaffected.
  • The GraphQL createServiceAccountToken mutation takes a new required signature argument. The GraphQL API is session authenticated and console-facing. CLI and SDK clients use the REST API and are unaffected.

🧪 Testing

Full backend suite and full frontend suite run in the dev containers.

  • Backend: 1738 passed. The 19 failures are pre-existing and unrelated (18 identity tests hitting the dev rate limiter with 429, and one file-permission test that cannot fail as root in the container).
  • Frontend: 430 passed, tsc --noEmit clean, ESLint clean.

New test files:

  • backend/tests/graphene/mutations/test_service_account_roles.py and test_member_invite_ceiling.py for the assignment ceiling.
  • backend/tests/graphene/mutations/test_service_account_token_signature.py and test_service_account_ssk_keyring.py for signature and keyring binding, using real Ed25519 keys.
  • backend/tests/api/views/test_service_account_token_keyring.py and backend/tests/api/utils/identity/test_common.py for the REST and identity mint paths.
  • frontend/tests/utils/access/roleGrant.test.ts and frontend/tests/utils/crypto/serviceAccountTokens.test.ts.

Extended: test_permissions.py, test_access_roles.py, test_roles_api.py, test_members_api.py, test_update_member_role.py, test_service_accounts_api.py, queries/test_service_accounts.py, utils/test_environments.py.

Each wired site has a rejection test, an in-ceiling acceptance test, a global-access exemption test, and an unchanged-role test that asserts the comparator is not called. Rejection tests assert the exact violation strings so an earlier guard cannot be what rejects. The exemption tests use a permission the Admin template genuinely lacks, so they fail if the exemption is removed.

Known gaps: the new 403 branches in the AWS and Azure identity views have no view-level test, because those modules are rate-limit flaky in the dev container. The underlying refusal is covered by a unit test on mint_service_account_token.

🎯 Reviewer Focus

  1. backend/api/utils/access/permissions.py is the whole comparison model. Everything else calls into it.
  2. The wiring sites, to check the ceiling sits after the existing guards and cannot be masked by them: backend/backend/graphene/mutations/access.py, organisation.py, service_accounts.py, and backend/api/views/roles.py, members.py, service_accounts.py.
  3. backend/api/utils/service_accounts.py and backend/api/utils/crypto.py for the key binding and signature verification, then the frontend counterpart in frontend/utils/crypto/service-accounts.ts to confirm the signed message is byte identical on both sides.
  4. backend/api/utils/access/roles.py for the Manager template change.

➕ Additional Context

  • Documentation is updated on the rbac-updates branch of the docs repo: the managed role tables now match the templates, and the grant rule is documented on the roles, users, organisation, service accounts, and teams pages, and on the roles, members, invites and service accounts API pages.
  • No new permission resource is introduced, and no database migration is required. Managed role policies resolve from code at runtime, so the Manager change takes effect on deploy.

✨ How to Test the Changes Locally

docker compose -f dev-docker-compose.yml up -d
docker compose -f dev-docker-compose.yml exec backend pytest
cd frontend && npx jest --no-coverage && npx tsc --noEmit

Comment thread backend/api/views/identities/aws/iam.py Fixed
Comment thread backend/api/views/identities/azure/entra.py Fixed
Comment thread backend/api/views/service_accounts.py Fixed
Comment thread backend/api/views/service_accounts.py Fixed
Comment thread backend/api/views/service_accounts.py Fixed
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.

2 participants