Roles
User Office controls what a user can see and do through roles. A user can hold several roles, but acts under one current role at a time.
There are two kinds of role:
- Root roles — the fixed, built-in roles that ship with the system.
- Derived roles — roles a User Officer creates at runtime by taking a root role as a base and attaching extra configuration and tags.
Root roles
A root role is one of the roles hard-coded in the Roles enum (apps/backend/src/models/Role.ts). In the database their row has is_root_role = true.
Think of a root role as a permission archetype. Its behaviour is baked into the code: the authorization layer has explicit logic for each root role, and that logic does not change. Root roles are not tag-filtered — a root role sees everything its archetype is allowed to see.
The current root roles are:
| Role | Short code | In plain terms |
|---|---|---|
| User | user |
A visiting scientist who submits and manages their own proposals. |
| User Officer | user_officer |
Administrator of the user program. Sees and manages everything. |
| FAP Chair | fap_chair |
Leads the review panel for proposals assigned to their FAP. |
| FAP Secretary | fap_secretary |
Administrative support for a FAP's review process. |
| FAP Reviewer | fap_reviewer |
Reviews the proposals assigned to them within a FAP. |
| Instrument Scientist | instrument_scientist |
Responsible for instruments; sees proposals tied to their instruments/techniques. |
| Experiment Safety Reviewer | experiment_safety_reviewer |
Reviews the safety of proposed experiments. |
| Internal Reviewer | internal_reviewer |
Reviews technical/internal aspects of assigned proposals. |
| Proposal Reader | proposal_reader |
Role that is intended for set of users who need same level of read access as user officer but without possibility to alter the data |
Derived roles
A derived role (also called a dynamic role in some older code) is a role a User Officer creates through the UI. In the database its row has is_root_role = false.
A derived role is built from three parts:
- A base root role — stored in the
short_codecolumn. The derived role inherits the permission archetype of that root role. - Config — a JSON blob (
configcolumn) that fine-tunes what the role can access. The shape of the config depends on the base root role. - Tags — a set of tags attached through the
roles_has_tagsjoin table. Tags scope what the role can see.
So where a root role says "this is a Proposal Reader, and Proposal Readers behave like X", a derived role says "this is a Proposal Reader restricted to these tags and with these access flags turned on".
What tags mean
Tags are the heart of derived-role access control. For a PROPOSAL_READER-based role the rule is:
- No tags attached → access to everything. An empty tag set is treated as "unrestricted".
- One or more tags attached → access is narrowed. The role can only read proposals whose call or instrument is associated with one of those tags.
This logic lives in ProposalAuthorization.hasReadRights (apps/backend/src/auth/ProposalAuthorization.ts):
case Roles.PROPOSAL_READER:
const userTags = (
await this.roleDataSource.getTagsByRoleId(agent!.currentRole!.id)
).map((tag) => tag.id);
if (userTags.length === 0) {
hasAccess = true; // no tags => see everything
break;
}
// otherwise: access is limited to proposals whose call or
// instrument is linked to one of the role's tags
const userInstruments = ...;
const userCalls = ...;
hasAccess =
(proposal.callId && userCalls.includes(proposal.callId)) ||
proposalInstruments.some((i) => userInstruments.includes(i.id));
break;
The Proposal Reader config flags
A PROPOSAL_READER derived role carries four boolean flags (ProposalReaderRoleConfig in Role.ts). Each one unlocks a slice of the system on top of the basic proposal-read access:
| Flag | What it grants |
|---|---|
hasLogAccess |
Visibility of proposal/event logs. |
hasTechnicalReviewAccess |
Permission to read technical (feasibility) reviews. |
hasFapAccess |
Permission to read FAP reviews. |
hasAdminAccess |
Administrative privileges within the role's scope. |
These are checked in the relevant authorization classes — e.g. TechnicalReviewAuthorization gates on hasTechnicalReviewAccess, and ReviewAuthorization gates on hasFapAccess.

The default config applied when none is supplied is defined in defaultConfig in apps/backend/src/datasources/postgres/RoleDataSource.ts.
How derived roles are stored
Everything lives in the roles table plus one join table:
| Column / table | Purpose |
|---|---|
roles.short_code |
The base root role this role derives from (e.g. proposal_reader). |
roles.config |
JSON config, shape depends on short_code. |
roles.is_root_role |
false for derived roles, true for built-in root roles. |
roles_has_tags |
Many-to-many link between a role and its scoping tags. |
role_user |
Links users to the roles they hold. |
Creating, updating and deleting derived roles, and assigning their tags, all require the USER_OFFICER role. See RoleMutations and RoleTagsMutation.
injectDynamicRoleArgs — how tag-scoping is enforced
You won't usually call tag logic by hand. The @Authorized decorator (apps/backend/src/decorators/Authorized.ts) does it for you when the current role is a derived (non-root) role.
The @Authorized decorator takes the allowed root roles as arguments, controlling which roles may call the query/mutation. For derived roles it additionally populates any parameter marked with @AgentTags with the current role's tags.
The mechanism:
-
Mark which arguments should receive tags. On a query/mutation method, decorate the tag parameter with
@AgentTags. This records the argument's position in metadata. -
At call time,
@Authorizedchecks the current role. Ifagent.currentRole.isRootRole === falseand the method has@AgentTags-marked arguments, it callsinjectDynamicRoleArgs:const injectDynamicRoleArgs = async (agent, args, indices) => { const tags = await roleDataSource.getTagsByRoleId(agent.currentRole!.id); const tagIds = tags.map((tag) => tag.id); indices.tags.forEach((index) => { args[index] = tagIds; // overwrite the marked argument with the role's tag ids }); }; -
The resolver receives the role's tags automatically. The derived-role user never passes a tag filter themselves — the system silently overwrites the marked argument with the role's tags, so the underlying query is scoped to exactly what the role is allowed to see.
For root roles (isRootRole === true) this injection is skipped entirely — root roles are never tag-scoped.
Mental model
@AgentTags says "this argument is a tag filter". injectDynamicRoleArgs says "if you're a derived role, you don't get to choose the filter — I'll set it to your role's tags."
Caveats: choosing where to apply tag authorization
How you enforce tag access depends on whether your query returns a single entity or a collection.
Single entity — fetch the entity first, then check access with the authorizer after the query. If the user is not allowed to see it, return null. This post-fetch check is simple and sufficient because there is only one result to vet.
Array of entities — push the tags down into the data source layer and filter out inaccessible entities as part of the database query, rather than after it. This matters for pagination: a paginated query must return a fixed-size page, so filtering after the fact would leave short or inconsistent pages. Filtering in the query keeps page sizes correct and avoids leaking the existence of entities the user cannot access.
Extending the system
Adding a new config flag to a derived role
- Add the field to the relevant config type in
apps/backend/src/models/Role.ts(e.g.ProposalReaderRoleConfig). - Add the matching GraphQL input field in
RoleConfigInput.ts(e.g.ProposalReaderRoleConfigInput). - Update
defaultConfig/resolveConfiginRoleDataSource.ts. - Enforce the new flag in the appropriate authorization class.
Introducing a new root role to derive from
- Add the new short code to the
Rolesenum (or use existing one if you are simply adding support for the existing role) and a corresponding config type inRole.ts, then extendcreateRole's switch. - Add a config input type and wire it into
RoleConfigInput. - Handle the new short code in
defaultConfig/resolveConfig. - Add a
casefor the role in the authorization classes that need it (e.g.ProposalAuthorization.hasReadRights). - If the role should be tag-scoped, make sure the relevant resolvers mark their tag argument with
@AgentTags.