Skip to content

Access and Roles

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

Two independent things decide whether a person can do something in Forge: what this install has switched on, and who that person is. The first is a capability, the second is a role, and they are enforced by different machinery that composes with an AND. Understanding both — and their real limits — is the difference between "I assigned the role and it still doesn't work" and a working access model.

Canonical reference: docs/functional-reference/roles-permissions.mdstale in two places, see the last section. The authoritative sources are forge.api/Data/SeedData.cs for the role set and the [Authorize(Roles = …)] attributes in forge.api for what each role reaches. For the other axis, see Capability Gating.

Two axes, and both have to pass

A capability answers does this feature exist on this box. A role answers may this person use it. They are evaluated separately and independently, so a request has to satisfy both.

Capability enabled Capability disabled
User holds a checked role Works Refused as capability-disabled — a 403 carrying an X-Capability-Disabled header, see API Access
User holds no checked role Refused as an authorization failure Refused

The consequence for an administrator is that turning a capability on does not grant anyone access to it, and assigning a role does not make a switched-off feature appear. Both are separate admin actions, on separate screens.

The same pair drives what people see. Every entry in the sidebar tree carries an optional capability code and an optional list of allowed roles; an entry is rendered only if the capability is enabled and the signed-in user holds one of the roles, and a group whose children all filter out disappears entirely. Two people on the same install, and the same person on two installs, get different navigation. Routes are guarded the same way — roleGuard() and capabilityGuard() are separate guards that both redirect to the dashboard — so hand-typing a URL does not get round either.

The role set is fixed and seeded

Roles are ASP.NET Identity roles, created at first boot from a literal array at the top of SeedData.SeedAsync. Read that array for the current set; it is longer than the six the canonical doc describes.

There is no create-role endpoint. The admin API exposes GET api/v1/admin/roles and nothing that writes to the role list; the role-bundle handlers explicitly refuse any name that is not already a seeded role. Adding a role is a code change, not configuration — which is deliberate, because a role only means something if [Authorize] attributes name it.

Everything else about role assignment is conventional:

  • Roles are additive and multi-assignment. A user holds several roles directly and their access is the union. There is no hierarchy and no inheritance; Manager reaching more than Engineer is a consequence of the attributes, not of a ladder.
  • Roles are baked into the token. Role claims are written when a token is issued, so a role change takes effect on the user's next token — a fresh sign-in or a token refresh — rather than mid-session. Every change writes a RoleAssigned audit row recording the before and after set.
  • To stop access immediately, deactivate the account. The deactivate action revokes every one of that user's sessions and unassigns their open jobs, which is what you want when someone leaves; it is a distinct action from editing the account.
  • Admins never see or set a password or PIN. Account creation issues a setup token and the employee completes their own credentials — see Architecture § Authentication. Kiosk identities are provisioned in one shot with a role, a badge identifier and a PIN; see Shop Floor Kiosk.

Coverage is uneven, and this is the part that surprises people

The roles do not carry equal weight. Some are named on hundreds of endpoints; several are named on none, and exist only as UI labels and grouping keys.

Role Where it is actually checked
Admin Almost everything, and the entire administration surface
Manager Nearly as broad — operations leadership, approvals, scheduling
OfficeManager Back office: vendors, purchase orders, invoices, payments, shipments
Engineer Technical execution: parts, inventory, lots, quality, the board
PM Planning and sales: backlog, planning, leads, quotes, sales orders
Controller The accounting surfaces — GL, aging, bank statements, settlements, conversion
ProductionWorker The shop-floor worker view
Procurement The purchase-order surface, and effectively nowhere else
IT Admin · Production Manager · Production Planner · ComplianceOfficer No API authorization sites. They are navigation, landing and rollup labels

The roles in the last row are not broken and not dangerous — the API denies by default, so a role that is named nowhere grants nothing extra. But it does mean assigning one of them is not how you grant someone access. They do real work elsewhere: they filter the sidebar, they select a landing screen, they are selectable in the MFA policy, and ComplianceOfficer additionally receives read visibility on the regulatory compliance calendar through a data-level grant rather than an attribute check.

The practical rule: before promising someone a permission, look at the [Authorize(Roles = …)] attribute on the controller or action that owns it, and give them a role that appears there. Where a role's only effect is on the UI, say so out loud when you hand it out.

Landing behaviour follows from the same list. A user whose access is dominated by a single role lands on the screen for that role — production worker on the board, engineer on Parts, controller on Accounting — while anyone holding several roles, including Admin and Manager, lands on the dashboard, and an explicit per-user landing preference beats both. See App Surfaces.

Role bundles: what you can create

What is configurable is a role bundle (a role template in the API): a named rollup of existing roles, managed under api/v1/admin/role-templates and on an admin panel. A handful ship seeded — a back-office rollup, a production rollup and an owner rollup, for shops where one person wears several hats. Seeded defaults are protected from edit and delete; your own are soft-deactivated rather than removed, so audit history survives. A bundle may only name roles that already exist.

Be clear about what a bundle is for today, because its history is misleading:

  • A bundle is not how you give a person several roles. The user-side coupling was retired; users now hold each role directly, and a one-time migration expands any historic bundle assignment into direct grants at startup. Someone wearing three hats gets three roles.
  • Its live job is scoping a headless credential. A system API key can be pinned to a bundle, and the key then emits the intersection of the bound user's roles with the bundle's — it can only narrow, never expand, so a key cannot grant a permission its user does not already hold. See API Access for the two key schemes.
  • Presets can seed bundles. Applying a capability preset may install additional bundles as part of its reshaping pass, add-only — see Capability Gating § Presets.

The second factor

Forge's authentication story does not stop at password and SSO. The full second factor is present and is worth switching on before an install faces the internet — see Hardening a Production Install.

  • TOTP. Standard authenticator-app enrolment, verified before the device counts, several devices per user with one default.
  • Passkeys (WebAuthn). Registered from a signed-in session on the account security screen. Note that passkeys here are a second factor, not a passwordless login: the password check happens first, and the passkey ceremony then runs against a single-purpose pending token.
  • Recovery codes. A fixed batch, stored only as hashes, each usable once; regenerating replaces the whole set and the remaining count is shown on the account screen. Issue them at enrolment and store them somewhere that is not the laptop.
  • Trusted devices. After a successful challenge a user can have this browser remembered, which skips the challenge on later sign-ins — the password is still required every time. The token is single-purpose, bound to that user, and signed with a key derived from the main JWT signing key, so it is inert against every other token pipeline. Rotating JWT_KEY invalidates these along with everything else.

For a client, the MFA branch is the thing to code for: a login against an MFA-enabled account returns an empty token with an MFA-required flag and a pending token, and the session is only issued after a challenge, a passkey assertion or a recovery code. API Access has the request shapes.

Requiring MFA by role

An admin panel sets the policy by picking roles. Every user holding one of those roles is flagged as enforced, and enforcement means two concrete things: they cannot disable MFA, and they cannot remove their last verified device. The same panel lists every user with whether they have actually enrolled.

Be realistic about what that buys you. The policy is a floor plus a report, not a login block — a user marked enforced who has not yet enrolled can still sign in, so treat the compliance table as a follow-up list rather than as evidence that the policy is satisfied.

The MFA policy screen is itself capability-gated, while per-user enrolment and the challenge endpoints are bootstrap-exempt. That is the two axes again, and it has a practical edge: switching the MFA capability off removes the admin policy surface, it does not disarm an already-enrolled user's second factor.

Headless identities

Not every principal is a person. A system API key authenticates as a real user, so audit rows and role checks attribute to that identity and deactivating the user kills the key; a BI key is unbound and scoped to a read-only reporting surface. One seeded role is headless by design — a single-purpose sync role bound to a service account and scoped to one narrow intake path — and should never be assigned to a human. Both key schemes and the SSO token exchange are covered on API Access.

One thing SSO does not do is create accounts: a first federated sign-in links to an existing active local user by email, and roles come from the local account, never from the identity provider.

Where the canonical doc is stale

docs/functional-reference/roles-permissions.md is still the most detailed per-controller matrix anywhere, and it is worth reading — but two of its claims no longer hold:

  • It opens by saying Forge defines six roles. The seed creates more than that; count the array in SeedData.cs.
  • It describes MFA as TOTP only. Passkeys, recovery codes and trusted devices all post-date it.

Where the doc and the code disagree, the code wins — the standing rule on Documentation Map, and this is a page where readers hit it.

Clone this wiki locally