Skip to content

Permissions & gating

Modgud uses granular per-resource gating: every endpoint and every sidebar item checks a single permission string, and the same evaluator runs IdP-side (Authorization slice) and resource-server-side (Modgud.Client.AspNetCore).

Permission format

Two segments: <resource>:<action>. The string carries no app slug.

The app context is implicit from the caller:

  • For in-process gates inside Modgud, the gate's audience is the Modgud app itself.
  • For resource-server gates, the audience is the RS's own App slug (resolved from the access token's aud claim by the time the request reaches the gate).

This is enforced at write-time: catalog entries are validated against the regex ^[a-z0-9-]+:[a-z0-9-]+$ — exactly two lowercase segments, hyphens allowed inside a segment, no colon-prefix.

Modgud's own admin surface uses two App slugs internally:

  • modgud — the realm-internal admin surface (users, groups, roles, OAuth clients, login providers, etc.). Seeded into every realm.
  • control-plane — the cross-realm admin surface (realm CRUD). Seeded only into the realm flagged IsControlPlane = true.

A consuming SaaS app gets its own App slug and registers its own catalog of <resource>:<action> permissions. The slug is the implicit context — it never appears in the permission string itself.

Permission (catalog string)Meaning
user:readRead user list/detail
user:writeCreate/edit users
user:adminResource-wide bypass for all user actions
oauth-client:readRead OAuth clients
oauth-client:writeCreate/edit OAuth clients
permission-role:readRead roles
authorization-group:writeCreate/edit groups
login-provider:read / :writeLogin-provider management
auth-log:readRead the auth log
gdpr:adminPermanent-erase GDPR operations
realm:read / realm:writeRealm CRUD (control-plane app)
realm:adminRealm-wide bypass (every app, every resource, every action)

realm:admin is the one exception: it's a realm-constant — a PermissionRole carries IsRealmAdmin = true and the resolver injects the literal grant. It bypasses every check anywhere in the realm.

Bypass tiers

Only two tiers, both checked by PermissionEvaluator:

GrantEffect
realm:adminEverything in every app — the realm-wide emergency exit
<resource>:adminAll actions on that resource in the calling app

Evaluate(grants, "user:read") returns true when:

  1. the user holds realm:admin, or
  2. the user holds user:read directly, or
  3. the user holds user:admin (resource-wide bypass).

There is no app-wide bypass tier (<app>:admin was discussed but never shipped — bypass is either realm-wide or resource-wide, nothing in between). Per-area owners typically get per-resource <resource>:admin (e.g. an OAuth owner gets oauth-client:admin + oauth-scope:admin

  • oauth-api:admin, but not user:admin).

The canonical evaluator implementation lives in Modgud.Permissions.Abstractions/PermissionEvaluator.cs — pure, no I/O, reused on both ends of the wire.

Resources

modgud app catalog (realm-internal — every realm)

ResourceWhat for
appApp registration management
userUser management (ApplicationUser)
permission-roleRole management
authorization-groupGroup management
sessionPer-user session management
service-accountService-account identity layer
auth-logRead AuthLog
gdprPermanent-erase GDPR operations
oauthOAuth admin surface umbrella
oauth-clientOAuth client management
oauth-scopeOAuth scope management
oauth-apiOAuth API resource management
login-providerInternal/external login providers
realm-settingsPer-realm settings (self-reg, DCR, branding)
assetAsset library
observabilityRead-only observability view
scheduled-jobQuartz scheduled job admin
inbox-settingsInbox retention configuration

control-plane app catalog (only on the Control-Plane realm)

ResourceWhat for
realmRealm CRUD (/api/admin/realms/*)

The control-plane slug is intentionally decoupled from the product name. If the IdP is ever rebranded, cross-realm permissions don't need a migration.

How resource servers receive permissions

Resource servers read permissions from a standard OIDC UserInfo call. For each audience (aud) the access token names, Modgud emits a resource_access block on /connect/userinfo, Keycloak-style:

json
{
  "sub": "…",
  "resource_access": {
    "billing-api": {
      "permissions": ["invoice:read", "invoice:write"],
      "roles": ["billing-owner"]
    },
    "shipping-api": {
      "permissions": ["shipment:read"],
      "roles": []
    }
  }
}

What's in the block:

  • Bypass-pre-expansionrealm:admin is expanded server-side into every concrete catalog string of every reachable App; a <r>:admin bypass is expanded into every <r>:* string in the App's catalog. Consumers do straight exact-match — no PermissionEvaluator port required client-side.
  • Per-RS-subset narrowing — each audience block is narrowed to OAuthApi.PermissionIds (the catalog subset the resource server declared as its gating surface). Anything outside that subset is excluded — no permission strings leak from one microservice into a sibling's block.
  • Roles vs Permissions are gated by separate scopes (roles, permissions) — request scope=permissions to see the permissions block; without it you get just the roles list.

The Modgud.Client.AspNetCore helper lib's IClaimsTransformation flattens the matching audience block onto the principal so standard ASP.NET Core [Authorize(Roles="…")] and RequiresPermission(…) work out of the box.

Backend gating: RequiresPermission

Endpoints gate via a RouteHandlerBuilder extension:

csharp
app.MapGet("/api/user", async (...) => { ... })
   .RequiresPermission("user:read");

app.MapPost("/api/user", async (...) => { ... })
   .RequiresPermission("user:write");

app.MapPost("/api/admin/realms", async (...) => { ... })
   .RequiresPermission("realm:write");      // control-plane app context

The filter (PermissionEndpointFilter):

  1. Reads ClaimTypes.NameIdentifier from HttpContext.User
  2. Resolves the user's effective permissions via IPermissionService.GetUserPermissionsAsync(userId, appSlug) (BFS through groups, BoundTo-filtered, role-filtered by AppSlug, already bypass-pre-expanded)
  3. Calls PermissionEvaluator.Evaluate(grants, "user:read")

Frontend gating: sidebar + buttons

The auth.store.ts (Pinia) loads the effective permissions of the current user at login and uses the same evaluator logic:

typescript
// grants: string[]  e.g. ["user:read", "user:write"]

function hasPermission(needed: string): boolean {
  const grants = permissions.value
  if (grants.includes('realm:admin')) return true       // tier 1
  if (grants.includes(needed)) return true              // exact match

  const parts = needed.split(':')
  if (parts.length === 2) {                             // tier 2
    if (grants.includes(`${parts[0]}:admin`)) return true
  }
  return false
}

Sidebar items in views/admin/AdminView.vue declare which permissions make them visible:

typescript
const allNavItems: NavItem[] = [
  { section: 'authorization', label: 'nav.users', icon: 'users',
    path: '/admin/users', requirePermissions: ['user:read'] },
  { section: 'oauth', label: 'admin.oauthClients.title', icon: 'app-window',
    path: '/admin/oauth/clients', requirePermissions: ['oauth-client:read'] },
  { section: 'system', label: 'admin.realms.title', icon: 'globe',
    path: '/admin/realms', requirePermissions: ['realm:read'] },
  { section: 'system', label: 'nav.settings', icon: 'settings',
    path: '/plattform/settings', requirePermissions: ['realm:admin'] },
  // ...
]

Sections are hidden when all their items are filtered out. A user with only user:read sees just the Authorization section with "Users" — no OAuth, no System.

Control-Plane separation

Because the control-plane App catalog is only seeded into the Control-Plane realm's tenant DB, a tenant realm physically cannot grant realm:* permissions under the control-plane context — the resource registry in that tenant DB doesn't list the control-plane App, so the backend permission validator rejects the grant.

That's the third of three layers protecting the cross-realm admin surface. The other two:

  1. ControlPlaneGateMiddleware — runs before authentication. Returns 404 on /api/admin/realms/* from non-CP hosts. The route is discoverable only on the Control-Plane realm.
  2. RequireControlPlaneFilter — per-endpoint filter on the realm admin route group. Same 404 behaviour, even if the routing layer were misconfigured.

See Concepts: Control Plane / Data Plane for the full defence-in-depth diagram.

Default roles

The first admin in every realm is created via one of the bootstrap paths. Atomic with the user creation, three default PermissionRoles are seeded (idempotent — re-bootstrapping doesn't duplicate them):

System Admin

IsRealmAdmin: true

The new admin is added to the Administratoren group with BoundTo: ["*"] (active in every app), and that group carries the System Admin role. Realm-wide bypass — sees and can do everything in every app.

User Manager

AppId:        <modgud app id>
PermissionIds:[user:read, user:write,
               session:read, session:write,
               authorization-group:read,
               permission-role:read,
               auth-log:read]

Maintains users + groups + sessions, reads roles + auth log.

Viewer

AppId:        <modgud app id>
PermissionIds:[user:read,
               authorization-group:read,
               permission-role:read]

Read-only auditor.

Admins can adjust these roles or create more — they aren't hard-coded.

Permission resolution in detail

Request with cookie/bearer comes in

PermissionEndpointFilter

ClaimTypes.NameIdentifier → UserId
needed permission "<resource>:<action>" (2 segments)
app context = the calling app's slug (modgud, control-plane, billing-api, …)

IPermissionService.GetUserPermissionsAsync(userId, appSlug)
  ├── BFS through all group memberships (transitive, with visited set)
  ├── filter to groups whose BoundTo contains appSlug or "*"
  ├── for each group: load PermissionRole refs
  ├── filter to roles whose AppId == this app (or IsRealmAdmin = true)
  ├── for each role: resolve PermissionIds → catalog strings
  ├── bypass-pre-expand: realm:admin → all reachable catalog strings;
  │                     <r>:admin → all <r>:* in this app's catalog
  └── Set<string> of fully-expanded permissions

PermissionEvaluator.Evaluate(grants, "<resource>:<action>"):
  has "realm:admin"? → ✓
  has the exact permission? → ✓
  has "<resource>:admin"? → ✓
  otherwise → 403

Resolution is scoped per request, not cached. That is intentional: permissions change live (an admin removes a user from a group), and Modgud is not performance-critical (admin UI traffic, not a hot path).

If that ever changes: an IMemoryCache with sliding expiration (e.g. 30 seconds) and cache invalidation on GroupMembershipRecomputedEvent would suffice.

Released under the Apache-2.0 License.