Skip to content

Document the permission-gating contract #64

Description

@a-effort

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 (isPermissionDeniedPermissionDenied) 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

  1. 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.
  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

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions