Skip to content

Commit 10bb616

Browse files
jekabs-karklinsjekabskarklins
andauthored
docs: add documentation for the roles (#1568)
Co-authored-by: jekabskarklins <jekabs.karklins@ess.eu>
1 parent 784555a commit 10bb616

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

29 KB
Loading
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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+
![alt text](image.png)
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 entirelyroot 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+
_________________________________________________________________________________________________________

documentation/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ nav:
8080
- Email Service: developer-guide/email.md
8181
- Documentation Changes: developer-guide/documentation_changes.md
8282
- Technique Proposals: developer-guide/technique_proposals.md
83+
- Roles: developer-guide/roles.md
8384
- FAQ: faq.md
8485
- Glossary: glossary.md
8586

0 commit comments

Comments
 (0)