Summary
Client-side permission gates read GET /rbac/my/permissions, but that endpoint does not reflect the backend's is_admin bypass. For most users the two agree, but for one specific class of user they diverge, and the UI hides a card the API would serve.
This is not currently causing a known bug: bootstrap keeps the two in sync. But the divergence is non-obvious, affects every gated card, and will cause long debugging sessions.
How the backend decides (mcpgateway/services/permission_service.py:130):
elif allow_admin_bypass and await self._is_user_admin(user_email):
return True
A DB is_admin user is allowed regardless of their role permissions.
How the client decides (src/auth/AuthContext.tsx:251-259) exact match plus * against the list from GET /rbac/my/permissions.
What that endpoint returns (mcpgateway/routers/rbac.py:600): the result of get_user_permissions(), which is purely role-derived. It unions role permissions and returns; there is no is_admin branch and no * injected for admins. The bypass that grants access server-side is invisible to the client.
More details
bootstrap_db.py assigns the wildcard platform_admin role (permissions: ["*"]) to the admin user and synchronizes the is_admin flag with that role assignment (:580-596). When that works, admins carry * in /rbac/my/permissions and the client wildcard branch agrees with the backend bypass.
Notably, bootstrap_db.py:605 and :609 already log warnings for the failure path, in the same terms:
Admin UI routes using allow_admin_bypass=False will return 403.
So the codebase already knows role-assignment failure creates a gap.
An user with DB is_admin = true but without the wildcard role — e.g. promoted by direct DB flag, an SSO admin mapping, or a bootstrap role assignment that failed — will have a /rbac/my/permissions list lacking audit:read (or any other gated permission). Result:
- Client gate says no → the card is hidden or shows
PermissionDenied
- API would have said yes → the data was available all along
This fails closed, which is the safe direction; no data leaks. The two-layer gating pattern cannot recover it: layer 2 (isPermissionDenied → PermissionDenied) only reacts to a 403 that actually came back, and layer 1 (enabled) means the request is never sent. The user simply sees nothing, with no error to diagnose from.
Proposal
- Document the contract where the gate is computed:
hasPermission reflects role grants only, not the server is_admin bypass, and that the gap fails closed and is invisible to layer 2.
- Consider a single shared helper for "can this user see gated card X" so the note lives in one place rather than being repeated per card.
Root cause
The durable fix is for get_user_permissions() to reflect the effective permission set: i.e. return ["*"] (or the admin-equivalent) for is_admin users so /rbac/my/permissions means "what you can actually do" rather than "what your roles grant." That is a change in IBM/mcp-context-forge. Worth filing there if we want the asymmetry removed rather than documented.
Related
- The
useRecentActivity fetch-gating issue, which introduces another consumer of this pattern
PermissionDenied.tsx / isPermissionDenied(): the layer-2 half
Summary
Client-side permission gates read
GET /rbac/my/permissions, but that endpoint does not reflect the backend'sis_adminbypass. For most users the two agree, but for one specific class of user they diverge, and the UI hides a card the API would serve.This is not currently causing a known bug: bootstrap keeps the two in sync. But the divergence is non-obvious, affects every gated card, and will cause long debugging sessions.
How the backend decides (
mcpgateway/services/permission_service.py:130):A DB
is_adminuser is allowed regardless of their role permissions.How the client decides (
src/auth/AuthContext.tsx:251-259) exact match plus*against the list fromGET /rbac/my/permissions.What that endpoint returns (
mcpgateway/routers/rbac.py:600): the result ofget_user_permissions(), which is purely role-derived. It unions role permissions and returns; there is nois_adminbranch and no*injected for admins. The bypass that grants access server-side is invisible to the client.More details
bootstrap_db.pyassigns the wildcardplatform_adminrole (permissions: ["*"]) to the admin user and synchronizes theis_adminflag with that role assignment (:580-596). When that works, admins carry*in/rbac/my/permissionsand the client wildcard branch agrees with the backend bypass.Notably,
bootstrap_db.py:605and:609already log warnings for the failure path, in the same terms:So the codebase already knows role-assignment failure creates a gap.
An user with DB
is_admin = truebut without the wildcard role — e.g. promoted by direct DB flag, an SSO admin mapping, or a bootstrap role assignment that failed — will have a/rbac/my/permissionslist lackingaudit:read(or any other gated permission). Result:PermissionDeniedThis fails closed, which is the safe direction; no data leaks. The two-layer gating pattern cannot recover it: layer 2 (
isPermissionDenied→PermissionDenied) only reacts to a 403 that actually came back, and layer 1 (enabled) means the request is never sent. The user simply sees nothing, with no error to diagnose from.Proposal
hasPermissionreflects role grants only, not the serveris_adminbypass, and that the gap fails closed and is invisible to layer 2.Root cause
The durable fix is for
get_user_permissions()to reflect the effective permission set: i.e. return["*"](or the admin-equivalent) foris_adminusers so/rbac/my/permissionsmeans "what you can actually do" rather than "what your roles grant." That is a change inIBM/mcp-context-forge. Worth filing there if we want the asymmetry removed rather than documented.Related
useRecentActivityfetch-gating issue, which introduces another consumer of this patternPermissionDenied.tsx/isPermissionDenied(): the layer-2 half