Skip to content

Role management

Operator guide for the lumnik RBAC catalog.

Conceptual model

Lumnik distinguishes two layers of authorisation metadata:

  • Realm roles — declared in Keycloak, attached to user accounts. Currently four: lm_user (read business surfaces, e.g. /api/entities/*), lm_integrator (drive connectors and ingestion), lm_admin (administer a tenant), lm_superadmin (platform-wide: tenant list + impersonation).

No hierarchy. The interceptor matches role names flat — lm_admin does not imply lm_user. Grant every role a user needs explicitly (the self-host demo realm does: integrator carries integrator+admin+user, hubadmin admin+user+superadmin; the native-claims k8s variant grants hubadmin only lm_admin). - Permissions — declared per module in META-INF/module-manifest.yaml (permissions: list). Examples: repair.read, repair.manage, request.manage.

Métier-scope grants are realm roles too, by naming convention: scope:<tag> (e.g. scope:achat). They are opt-in — a user with none is unscoped (tenant-wide RAG access); a user with one or more may only query those scopes. No entry in roles.yaml is needed; see Scopes.

The mapping between the two — which role grants which permission — lives in lumnik-hub/src/main/resources/META-INF/roles.yaml and is exposed in the admin console under Rôles (/admin/{tid}/roles).

Current enforcement model: @RequiresPermission(...) annotations on REST resources still reference realm roles as magic strings (e.g. @RequiresPermission("lm_admin")). The permission catalog shipped here is declarative metadata — it documents the mapping but does not (yet) drive the interceptor. A future plan will refactor the interceptor to resolve permissions through RoleRegistry.

Adding a new role

  1. Edit lumnik-hub/src/main/resources/META-INF/roles.yaml and add the role under roles:.
  2. Declare the role in the Keycloak realm lumnik (or your environment's equivalent).
  3. Restart the platform. Boot logs should show Roles loaded: N roles from M roles.yaml files.
  4. Visit /admin/{tid}/roles and confirm the new role appears.

If the platform refuses to start with a message like Role 'lm_foo' references unknown permission 'bar.baz', either remove the permission from the role grant or add it to the relevant module's module-manifest.yaml.

Adding a new permission

  1. Add the permission id to the relevant module-manifest.yaml:
    permissions:
      - id: invoice.export
        label:
          en: "Export invoices"
          fr: "Exporter les factures"
    
  2. Grant the permission to the roles that should have it by adding the id under roles.yamlroles[].permissions.
  3. Restart the platform. If the permission is not yet granted by any role, you will see a WARN log: Orphan permissions (declared by modules, granted by no role): [invoice.export].
  4. Use the permission id in a @RequiresPermission annotation. Today, until the interceptor refactor lands, this still resolves against realm roles — so the annotation value must remain the realm role name (lm_admin etc.). The permission catalog is only metadata at this stage.

Dev-bypass

Setting lumnik.security.dev-bypass=true in application.properties (or via the env var LUMNIK_SECURITY_DEV_BYPASS=true) injects the three realm roles {lm_superadmin, lm_admin, lm_user} into every request. This is enabled by default in %dev and %test profiles.

To test access refusals locally, set the flag to false and provide a valid JWT with the required roles in the groups claim. There is no header that overrides roles — dev-bypass grants the fixed set above (identity comes from X-Tenant-ID / X-User-ID headers); in production the roles always come from the JWT.

The entity-read scope gate

Realm roles answer may this user call the endpoint; scope grants answer which métier's data may they see there. A scope-bound user (one or more scope:<tag> realm roles) hitting a read surface outside their granted set gets the uniform refusal:

403 {"error":"scope not granted"}

The check is ScopeGrants.allowsAny(...) (lumnik-common) against the scope tags on the resource — e.g. EntityReadResource refuses GET /api/entities/{name} when the entity manifest's scopes don't intersect the user's grants. The same gate covers saved-view discovery, view render/detail/generate, and chat. Unscoped users (no scope:* role) pass everything — the boundary is opt-in. Full semantics: Scopes → Binding users to scopes.

Release security checklist

Before each release, walk through the catalog at /admin/{tid}/roles and confirm:

  • The list of roles matches what is declared in Keycloak.
  • Only lm_superadmin carries the wildcard: true flag.
  • No permission is declared by a module without being granted by at least one role (the boot log will warn — investigate each orphan).
  • Every @RequiresPermission annotation in the codebase uses a role id present in roles.yaml:
    grep -rn '@RequiresPermission' lumnik-*/src/main/java
    

Audit logging

PermissionInterceptor (in io.lumnik.platform.security) logs every access denial at WARN level with the required roles, the caller's roles, and the method name. SIEM consumers should subscribe to these log lines for security monitoring. The interceptor does not currently write to a structured audit table — that is on the roadmap.

See also

  • The role → permission mapping: lumnik-hub/src/main/resources/META-INF/roles.yaml
  • The bundled demo realm (roles + users): infra/keycloak/lumnik-realm.json
  • The database layer beneath RBAC: RLS & database roles