RBAC and permissions

Flux's permission model — wildcard syntax, built-in roles, custom roles, and how permissions attach to principals.

Authentication answers who is calling. Authorization answers what they may do. Flux’s authorization layer is a small RBAC system: a principal holds one or more roles, a role holds a list of permission strings, and a permission string is a colon-delimited path with wildcards. The engine lives in auth_service.py (where the four built-in roles are defined) and identity.py (where the wildcard match runs). permissions.py only generates the permission tree for introspection.

The permission shape

A permission is a string of colon-delimited segments. The shapes that appear in the codebase fall into a few families:

FamilyExampleWhat it gates
workflow:{ns}:{name}:{verb}workflow:billing:invoice:runRun / read / register a specific workflow
workflow:{ns}:{name}:task:{task}:executeworkflow:billing:invoice:task:load:executeExecute one task inside a workflow
schedule:{*}:{verb}schedule:*:manageCreate, pause, delete schedules
execution:{*}:readexecution:*:readRead execution history and events
agent:{*}:{verb}agent:*:createCreate / read / update / delete AI agents
config:{*}:{verb}config:*:readRead or manage the config store
service:{*}:{verb}service:*:manageManage workflow-as-service exposure
worker:{*}:{*}worker:*:*Internal — worker callbacks (claim, checkpoint, heartbeat)
admin:{area}:{verb}admin:roles:manageRoles, principals, secrets, metrics

Workflow permissions are the only ones that carry the namespace and workflow name as path segments. Everything else uses the family-plus-verb pattern with * filling the middle.

Built-in roles

Four roles are seeded on first boot (flux/security/auth_service.py::BUILT_IN_ROLES). They are persisted with built_in=True and the API blocks update and delete on them — you must clone, not edit:

Custom roles

Custom roles are first-class. Create them through the CLI, the REST API, or the SDK — they are stored as RoleModel rows alongside the built-ins:

flux roles create namespace-owner-billing \
    --permissions "workflow:billing:*:run" \
    --permissions "workflow:billing:*:read" \
    --permissions "execution:*:read"

flux roles clone operator --name audit-operator
flux roles update audit-operator --add-permissions "admin:metrics:read"
flux roles delete audit-operator

The same calls are wired into POST /admin/roles, PATCH /admin/roles/{name}, etc. Permission strings are validated against PERMISSION_PATTERN before insert; built-in roles reject both update and delete.

Wildcard semantics

The matcher is _wildcard_match in flux/security/identity.py. The rules are not the same as glob:

Worked examples:

How permissions reach the request

Roles attach to principals through PrincipalRoleModel (flux/security/principals.py) — a join table keyed on (principal_id, role_name). On every request AuthService.resolve_permissions walks the principal’s roles, unions their permission lists, and FluxIdentity.has_permission checks the route’s required string against the set. Routes declare what they need with Depends(require_permission("...")) from flux/security/dependencies.py; a miss returns 403 Permission denied: requires '<perm>'.

Provider errors during authentication are logged at error; a 403 itself is returned via FastAPI’s exception flow and shows up in the HTTP access log with the path and the missing permission. There is no separate audit table — the access log is the audit trail.

When it goes wrong

Three failure modes cover most 403s in practice: