Skip to content

usermanagement: derive token scopes from roles, not just the legacy scope table - #80

Merged
tekrajchhetri merged 1 commit into
improve-ingestion-query-servicefrom
fix/sso-scopes-from-roles
Aug 13, 2026
Merged

usermanagement: derive token scopes from roles, not just the legacy scope table#80
tekrajchhetri merged 1 commit into
improve-ingestion-query-servicefrom
fix/sso-scopes-from-roles

Conversation

@tekrajchhetri

Copy link
Copy Markdown
Collaborator

Fixed inconsistency permission issue for MCP resulting in 403 Insufficient scopes for admin.

…cope table

A Globus SuperAdmin got 403 "Insufficient scopes" from every query_service admin
route, including /api/admin/capabilities, while usermanagement's own admin routes
worked. Setting USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS did not help, and it was
never going to: promote_bootstrap_superadmins assigns the Admin and SuperAdmin ROLES
to a UserProfile and writes nothing else.

Four paths mint tokens, and until now they disagreed about where scopes come from:

  /api/auth/session-exchange   scopes from roles  ("RBAC is authoritative")
  /api/pat/exchange            scopes from roles
  /api/auth/login              scopes from Web_jwtuser_scopes
  /api/auth/exchange           scopes from Web_jwtuser_scopes   <- the MCP path

Web_jwtuser_scopes is the legacy Django table, populated only for accounts created
through the old admin. An OAuth account has no rows in it: its Web_jwtuser row is
the shell created to supply a stable user_id claim. So the refresh-token exchange
minted `roles: ["Admin", "SuperAdmin"]` alongside `scopes: ["read"]`.

query_service gates its admin routes on the scope claim — require_scopes(["admin"])
runs as a dependency, before the rbac.is_admin() check inside the handler that would
have passed — and it has no bootstrap-email allowlist of its own
(config.bootstrap_superadmin_emails appears nowhere in query_service). That
combination is why the symptom looked like a missing audience: the same identity
could list users through usermanagement, which honours the allowlist, and could not
read capabilities through query_service, which trusts the token.

Both refresh-token paths now union the stored scopes with the ones the user's roles
imply. Union rather than replacement, because a legacy account may hold an
explicitly granted scope that no role implies and dropping it would be a silent
downgrade. This also removes the need for a PAT as a workaround — the PAT exchange
only worked because it already derived scopes from roles.

Verified on the pure functions: a Globus SuperAdmin with no scope rows now yields
["admin", "read", "write"], a Curator ["read", "write"], a user with no roles
["read"], and a legacy account keeps a scope no role implies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tekrajchhetri
tekrajchhetri merged commit 99a0ed4 into improve-ingestion-query-service Aug 13, 2026
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