|
| 1 | +# Roles |
| 2 | + |
| 3 | +_________________________________________________________________________________________________________ |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +There are two kinds of role: |
| 8 | + |
| 9 | +- **Root roles** — the fixed, built-in roles that ship with the system. |
| 10 | +- **Derived roles** — roles a User Officer creates at runtime by taking a root role as a base and attaching extra configuration and tags. |
| 11 | + |
| 12 | +_________________________________________________________________________________________________________ |
| 13 | + |
| 14 | +# Root roles |
| 15 | + |
| 16 | +A **root role** is one of the roles hard-coded in the `Roles` enum ([`apps/backend/src/models/Role.ts`](https://github.com/UserOfficeProject/user-office-core/blob/develop/apps/backend/src/models/Role.ts)). In the database their row has `is_root_role = true`. |
| 17 | + |
| 18 | +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. |
| 19 | + |
| 20 | +The current root roles are: |
| 21 | + |
| 22 | +| Role | Short code | In plain terms | |
| 23 | +| --- | --- | --- | |
| 24 | +| User | `user` | A visiting scientist who submits and manages their own proposals. | |
| 25 | +| User Officer | `user_officer` | Administrator of the user program. Sees and manages everything. | |
| 26 | +| FAP Chair | `fap_chair` | Leads the review panel for proposals assigned to their FAP. | |
| 27 | +| FAP Secretary | `fap_secretary` | Administrative support for a FAP's review process. | |
| 28 | +| FAP Reviewer | `fap_reviewer` | Reviews the proposals assigned to them within a FAP. | |
| 29 | +| Instrument Scientist | `instrument_scientist` | Responsible for instruments; sees proposals tied to their instruments/techniques. | |
| 30 | +| Experiment Safety Reviewer | `experiment_safety_reviewer` | Reviews the safety of proposed experiments. | |
| 31 | +| Internal Reviewer | `internal_reviewer` | Reviews technical/internal aspects of assigned proposals. | |
| 32 | +| 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 | |
| 33 | + |
| 34 | + |
| 35 | +_________________________________________________________________________________________________________ |
| 36 | + |
| 37 | +# Derived roles |
| 38 | + |
| 39 | +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`. |
| 40 | + |
| 41 | +A derived role is built from three parts: |
| 42 | + |
| 43 | +1. **A base root role** — stored in the `short_code` column. The derived role inherits the permission archetype of that root role. |
| 44 | +2. **Config** — a JSON blob (`config` column) that fine-tunes what the role can access. The shape of the config depends on the base root role. |
| 45 | +3. **Tags** — a set of tags attached through the `roles_has_tags` join table. Tags *scope* what the role can see. |
| 46 | + |
| 47 | +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**"*. |
| 48 | + |
| 49 | +## What tags mean |
| 50 | + |
| 51 | +Tags are the heart of derived-role access control. For a `PROPOSAL_READER`-based role the rule is: |
| 52 | + |
| 53 | +- **No tags attached → access to everything.** An empty tag set is treated as "unrestricted". |
| 54 | +- **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. |
| 55 | + |
| 56 | +This logic lives in `ProposalAuthorization.hasReadRights` ([`apps/backend/src/auth/ProposalAuthorization.ts`](https://github.com/UserOfficeProject/user-office-core/blob/develop/apps/backend/src/auth/ProposalAuthorization.ts)): |
| 57 | + |
| 58 | +```ts |
| 59 | +case Roles.PROPOSAL_READER: |
| 60 | + const userTags = ( |
| 61 | + await this.roleDataSource.getTagsByRoleId(agent!.currentRole!.id) |
| 62 | + ).map((tag) => tag.id); |
| 63 | + |
| 64 | + if (userTags.length === 0) { |
| 65 | + hasAccess = true; // no tags => see everything |
| 66 | + break; |
| 67 | + } |
| 68 | + |
| 69 | + // otherwise: access is limited to proposals whose call or |
| 70 | + // instrument is linked to one of the role's tags |
| 71 | + const userInstruments = ...; |
| 72 | + const userCalls = ...; |
| 73 | + hasAccess = |
| 74 | + (proposal.callId && userCalls.includes(proposal.callId)) || |
| 75 | + proposalInstruments.some((i) => userInstruments.includes(i.id)); |
| 76 | + break; |
| 77 | +``` |
| 78 | + |
| 79 | +## The Proposal Reader config flags |
| 80 | + |
| 81 | +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: |
| 82 | + |
| 83 | +| Flag | What it grants | |
| 84 | +| --- | --- | |
| 85 | +| `hasLogAccess` | Visibility of proposal/event logs. | |
| 86 | +| `hasTechnicalReviewAccess` | Permission to read technical (feasibility) reviews. | |
| 87 | +| `hasFapAccess` | Permission to read FAP reviews. | |
| 88 | +| `hasAdminAccess` | Administrative privileges within the role's scope. | |
| 89 | + |
| 90 | +These are checked in the relevant authorization classes — e.g. `TechnicalReviewAuthorization` gates on `hasTechnicalReviewAccess`, and `ReviewAuthorization` gates on `hasFapAccess`. |
| 91 | + |
| 92 | + |
| 93 | + |
| 94 | +The default config applied when none is supplied is defined in `defaultConfig` in [`apps/backend/src/datasources/postgres/RoleDataSource.ts`](https://github.com/UserOfficeProject/user-office-core/blob/develop/apps/backend/src/datasources/postgres/RoleDataSource.ts). |
| 95 | + |
| 96 | +_________________________________________________________________________________________________________ |
| 97 | + |
| 98 | +# How derived roles are stored |
| 99 | + |
| 100 | +Everything lives in the `roles` table plus one join table: |
| 101 | + |
| 102 | +| Column / table | Purpose | |
| 103 | +| --- | --- | |
| 104 | +| `roles.short_code` | The base root role this role derives from (e.g. `proposal_reader`). | |
| 105 | +| `roles.config` | JSON config, shape depends on `short_code`. | |
| 106 | +| `roles.is_root_role` | `false` for derived roles, `true` for built-in root roles. | |
| 107 | +| `roles_has_tags` | Many-to-many link between a role and its scoping tags. | |
| 108 | +| `role_user` | Links users to the roles they hold. | |
| 109 | + |
| 110 | +Creating, updating and deleting derived roles, and assigning their tags, all require the `USER_OFFICER` role. See `RoleMutations` and `RoleTagsMutation`. |
| 111 | + |
| 112 | +_________________________________________________________________________________________________________ |
| 113 | + |
| 114 | +# `injectDynamicRoleArgs` — how tag-scoping is enforced |
| 115 | + |
| 116 | +You won't usually call tag logic by hand. The `@Authorized` decorator ([`apps/backend/src/decorators/Authorized.ts`](https://github.com/UserOfficeProject/user-office-core/blob/develop/apps/backend/src/decorators/Authorized.ts)) does it for you when the current role is a derived (non-root) role. |
| 117 | + |
| 118 | +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. |
| 119 | + |
| 120 | +The mechanism: |
| 121 | + |
| 122 | +1. **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. |
| 123 | + |
| 124 | +2. **At call time, `@Authorized` checks the current role.** If `agent.currentRole.isRootRole === false` and the method has `@AgentTags`-marked arguments, it calls `injectDynamicRoleArgs`: |
| 125 | + |
| 126 | + ```ts |
| 127 | + const injectDynamicRoleArgs = async (agent, args, indices) => { |
| 128 | + const tags = await roleDataSource.getTagsByRoleId(agent.currentRole!.id); |
| 129 | + const tagIds = tags.map((tag) => tag.id); |
| 130 | + indices.tags.forEach((index) => { |
| 131 | + args[index] = tagIds; // overwrite the marked argument with the role's tag ids |
| 132 | + }); |
| 133 | + }; |
| 134 | + ``` |
| 135 | + |
| 136 | +3. **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. |
| 137 | + |
| 138 | +For **root roles** (`isRootRole === true`) this injection is skipped entirely — root roles are never tag-scoped. |
| 139 | + |
| 140 | +!!! tip "Mental model" |
| 141 | + |
| 142 | + `@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."* |
| 143 | + |
| 144 | +## Caveats: choosing where to apply tag authorization |
| 145 | + |
| 146 | +How you enforce tag access depends on whether your query returns a single entity or a collection. |
| 147 | + |
| 148 | +**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. |
| 149 | + |
| 150 | +**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. |
| 151 | + |
| 152 | + |
| 153 | +_________________________________________________________________________________________________________ |
| 154 | + |
| 155 | +# Extending the system |
| 156 | + |
| 157 | +## Adding a new config flag to a derived role |
| 158 | + |
| 159 | +1. Add the field to the relevant config type in [`apps/backend/src/models/Role.ts`](https://github.com/UserOfficeProject/user-office-core/blob/develop/apps/backend/src/models/Role.ts) (e.g. `ProposalReaderRoleConfig`). |
| 160 | +2. Add the matching GraphQL input field in `RoleConfigInput.ts` (e.g. `ProposalReaderRoleConfigInput`). |
| 161 | +3. Update `defaultConfig` / `resolveConfig` in `RoleDataSource.ts`. |
| 162 | +4. Enforce the new flag in the appropriate authorization class. |
| 163 | + |
| 164 | +## Introducing a new root role to derive from |
| 165 | + |
| 166 | +1. Add the new short code to the `Roles` enum (or use existing one if you are simply adding support for the existing role) and a corresponding config type in `Role.ts`, then extend `createRole`'s switch. |
| 167 | +2. Add a config input type and wire it into `RoleConfigInput`. |
| 168 | +3. Handle the new short code in `defaultConfig` / `resolveConfig`. |
| 169 | +4. Add a `case` for the role in the authorization classes that need it (e.g. `ProposalAuthorization.hasReadRights`). |
| 170 | +5. If the role should be tag-scoped, make sure the relevant resolvers mark their tag argument with `@AgentTags`. |
| 171 | + |
| 172 | +_________________________________________________________________________________________________________ |
0 commit comments