Role Levels

This page defines the shared Octopus role-level policy.

Role Matrix

Role

Level

Viewer

1

User

2

Operator

3

Engineer

4

Admin

5

Default for setting-level gates:

  • If a setting does not define level, it is treated as level: 0 (visible to all logged-in users).

Role naming and ROLE_PREFIX

By default, the token role names expected by the backend are:

octopus-viewer, octopus-user, octopus-operator, octopus-engineer, octopus-admin

Set the ROLE_PREFIX key in config.js (and the matching env var on the backend) to scope roles to a specific telescope or site:

ROLE_PREFIX

Expected role names

(empty)

octopus-viewer, octopus-admin, …

low

octopus-low-viewer, octopus-low-admin, …

mid

octopus-mid-viewer, octopus-mid-admin, …

stfc

octopus-stfc-viewer, octopus-stfc-admin, …

ROLE_PREFIX affects the default names used for Keycloak role matching. For MSAL, role resolution is done server-side — the backend ROLE_PREFIX is authoritative. The frontend prefix must match the backend so Keycloak role derivation from JWT claims stays in sync.

Selection rules when a user holds multiple roles:

  • Roles belonging to a different prefix are ignored — only this deployment’s prefix is matched.

  • When a user holds multiple roles under the same prefix (e.g. octopus-low-operator and octopus-low-admin), the highest level wins.

Per-provider overrides — individual role names can be pinned via KEYCLOAK_ROLE_* keys in config.js. Explicit overrides always take precedence over ROLE_PREFIX.

Semantics

  • A user can access a feature/setting only when: userRoleLevel >= requiredLevel.

  • Role names are canonicalized case-insensitively (admin, ADMIN -> Admin).

  • Unknown/invalid role names are treated as Viewer.

  • Role level must be enforced server-side for security. Frontend checks are UX only.

Security enforcement chain

All authorization decisions for mutations are made server-side from the signed Entra/Keycloak token. The frontend role state (localStorage) controls UI visibility only and is never trusted by the backend.

Per-request flow

  1. Token arrives — every GraphQL request carries an Entra Bearer token in the Authorization header.

  2. Signature verified — the backend fetches Microsoft’s public JWKS and cryptographically verifies the token. A forged or tampered token is rejected with HTTP 401 before any claims are read.

  3. Role derived from claims — the backend reads either:

    • roles claim (Entra app roles, matched against MSENTRA_ROLE_* env vars, e.g. octopus-stfc-operator)

    • groups claim (security group Object IDs, matched against OCTOPUS_ROLE_*_ID env vars)

    This produces a User object with role and role_level that is authoritative for the lifetime of the request.

  4. Operation gate — each resolver calls _require_role_level(user, minimum, detail). If user.role_level < minimum the request fails with HTTP 403.

Operation minimums

Operation

Minimum level

Minimum role

Read own preferences / workspaces

1

Viewer

Switch current workspace

1

Viewer

Write limited own preferences (currentWorkspace, timeRange, theme, headerClocks, variables)

1

Viewer

Create / rename / save / delete own workspaces

2

User

Write unrestricted own preferences (layouts, panels, etc.)

2

User

Send commands (dynamic mutations)

3

Operator

Read or write another user’s preferences / workspaces

5

Admin

Viewers are blocked server-side from modifying any preference key outside the allowed set. Any other key returns HTTP 403 for Viewer-level requests.

Admin access to another user’s workspaces is enforced in the backend and exposed in the UI via the Impersonated Workspaces pane (Admin-only). There is no UI path for a non-Admin user to touch another user’s data.

userId matching for preference mutations

Preference and workspace mutations include a userId parameter that must match the authenticated user’s username. The backend enforces this with _same_principal(user.username, userId) (case-insensitive). A mismatch returns HTTP 403 even if the role level is sufficient.

user.username is derived from the verified token claims in priority order: preferred_usernameupnsub. Entra access tokens sometimes omit preferred_username, causing the backend to fall back to sub (an Object ID GUID). The frontend uses the useCurrentUserId() hook, which reads the username echoed back by /auth/me after login rather than reading it directly from the MSAL account object. This guarantees the userId sent in mutations always matches what _same_principal() sees, regardless of which claim the access token carries.

Backend

  • Source-of-truth mapping lives in auth/roles.py.

  • Auth session/token claims include role and role_level.

  • GraphQL authorization enforces minimum levels for sensitive operations.

  • Dynamic endpoint-defined GraphQL mutations require Operator (level 3) or higher. Queries are available to any authenticated user.

  • Preference access is user-scoped by default:

    • A user can read/update only their own preferences (userId must match the authenticated user).

    • If userId belongs to someone else, the request is denied.

    • Only Admin (level 5) can read/update another user’s preferences.

Frontend

  • Frontend reads the role echoed by /auth/me and caches it in localStorage for UI behavior.

  • Frontend fetches canonical role levels from backend /auth/roles and uses them as the primary mapping.

  • If backend role levels are temporarily unavailable, frontend uses cached values (or built-in defaults) as fallback.

  • Widget settings UI hides fields where field.level > userRoleLevel.

  • Frontend filtering is not a security boundary. The backend independently enforces every operation regardless of what the frontend sends.

SDK

  • Widget config fields support optional level?: number.

  • JSON-schema style widget settings can define level per property.

  • SDK schema conversion preserves level so host apps can enforce visibility.

Example

const schema = {
  type: 'object',
  properties: {
    useLiveData: {
      type: 'boolean',
      title: 'Use Live Data',
      level: 5,
      default: true
    },
    title: {
      type: 'string',
      title: 'Title',
      default: '' // implicit level 0
    }
  }
};