Skip to content

Commit b0a33b9

Browse files
authored
Merge pull request #61 from itk-dev/feature/issue-60-adr-user-approval
docs: add ADR 004 — user registration, approval, and account state
2 parents b91aa5c + e0d9f81 commit b0a33b9

2 files changed

Lines changed: 202 additions & 0 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# 006: User registration, approval, and account-state model
2+
3+
| Field | Value |
4+
| ------------------ | -------------------------------------------------- |
5+
| **Created By** | Martin Yde Granath |
6+
| **Date** | 2026-06-12 |
7+
| **Decision Maker** | ITK Dev team |
8+
| **Stakeholders** | ITK Dev developers, future maintainers of ai-lib |
9+
| **Status** | Draft |
10+
11+
## Context
12+
13+
ai-lib serves Danish public-sector organisations. The intended
14+
onboarding flow is:
15+
16+
1. A representative from an approved organisation self-registers at
17+
`/register` using their work e-mail.
18+
2. The system only accepts the registration if the e-mail domain is on
19+
a project-managed allow-list (kommune domains, ministries, etc.).
20+
3. Even after a successful registration, the new account does **not**
21+
get application access until an existing trusted user (a domain
22+
manager) approves it from an admin queue.
23+
4. An approved account can later be **blocked** without being deleted
24+
(audit trail, possible un-block).
25+
26+
Before we build any of that we need to settle one design question that
27+
ripples through the rest of the work: **how is the "can this person
28+
sign in" state modelled on the `User` entity?**
29+
30+
The two candidate approaches the team weighed informally were:
31+
32+
- **A — Identity-state field(s) on `User`.** A `status` enum
33+
(`pending | approved | blocked`) or a pair of booleans (`approved`,
34+
`active`) that's explicit and independent of authorisation.
35+
- **B — Role-based gating.** Treat "no roles" as "no access" — a
36+
pending or blocked user simply has no entries in the `roles` column.
37+
38+
This ADR is scoped to that choice and the surrounding registration /
39+
approval architecture. Tracked in
40+
[#60](https://github.com/itk-dev/ai-lib/issues/60).
41+
42+
### Drivers
43+
44+
- **Functional:**
45+
- Self-signup gated by an allow-list of e-mail domains.
46+
- Distinct "pending" (never approved) and "blocked" (approved, then
47+
revoked) states for the admin UX.
48+
- A single, declarative place for "should this credential succeed?"
49+
so the login flow stays auditable.
50+
- **Non-functional:**
51+
- Stay within Symfony Security idioms — don't fight the framework's
52+
User abstraction or its `UserCheckerInterface` extension point.
53+
- Keep authorisation (`roles`, voters) orthogonal from identity
54+
state so changes to one don't accidentally weaken the other.
55+
56+
### Options Considered
57+
58+
1. **Status enum on `User` (`pending | approved | blocked`).**
59+
- Pros: single source of truth for the identity lifecycle;
60+
distinguishes "never approved" from "approved then revoked";
61+
trivial to query (`status = 'pending'` powers the approval
62+
queue); ergonomic with Symfony's `UserCheckerInterface`.
63+
- Cons: extending the lifecycle later means adding enum cases
64+
(Doctrine + migration), not just toggling a flag.
65+
2. **Two booleans (`approved` + `active`).**
66+
- Pros: smaller migrations to introduce.
67+
- Cons: two flags that mostly want to move in lockstep, with one
68+
impossible / undefined state (`approved=false, active=true`) that
69+
code must remember not to produce; #45's existing `active` field
70+
would need to grow a partner.
71+
3. **Role-based gating (`roles` empty → no access).**
72+
- Pros: no schema change beyond what #12 already implies.
73+
- Cons: Symfony's generated `User::getRoles()` returns
74+
`array_unique([...$this->roles, 'ROLE_USER'])`, so "empty roles"
75+
does **not** actually mean "no access" without removing that
76+
guarantee and fighting framework idioms; can't distinguish
77+
"pending" from "blocked"; conflates *what* a signed-in user can
78+
do with *whether* they may sign in at all.
79+
80+
## Decision
81+
82+
Adopt **option 1 — a `status` enum** on the `User` entity:
83+
84+
```php
85+
enum UserStatus: string
86+
{
87+
case Pending = 'pending';
88+
case Approved = 'approved';
89+
case Blocked = 'blocked';
90+
}
91+
```
92+
93+
`User::getStatus(): UserStatus` becomes the single source of truth
94+
for the identity lifecycle. Authorisation (`roles`, voters,
95+
`domainManager`) remains orthogonal — those answer "what may a
96+
signed-in user do", not "may this person sign in".
97+
98+
### Registration
99+
100+
- Anonymous endpoint `/register` accepts `email`, `password`, and
101+
`name`.
102+
- The submitted e-mail's domain must match an entry on an allow-list.
103+
The list is sourced from a comma-separated env var
104+
(`REGISTRATION_ALLOWED_EMAIL_DOMAINS=aarhus.dk,kk.dk,…`) for the
105+
first cut, with a clear migration path to a dedicated `Domain`
106+
entity if the list later needs CRUD admin tooling.
107+
- A successful registration creates a `User` with
108+
`status = UserStatus::Pending`. The user is shown a "waiting for
109+
approval" page and cannot sign in.
110+
111+
### Login gating
112+
113+
- A `Symfony\Component\Security\Core\User\UserCheckerInterface`
114+
implementation (`App\Security\AccountStatusChecker`) rejects login
115+
for any user whose `status` is not `Approved`, with localised
116+
messages for `Pending` vs. `Blocked`.
117+
- Wired in `security.yaml` via `user_checker:` on the `main` firewall.
118+
119+
### Approval queue
120+
121+
- A route (e.g. `/admin/users/pending`) lists users with
122+
`status = Pending`. Restricted to users with `ROLE_DOMAIN_MANAGER`
123+
(or `ROLE_ADMIN` via role hierarchy) via the voter described in
124+
"Domain manager — a role, not a flag" below.
125+
- The approver can **approve** (`status = Approved`) or **reject**
126+
(`status = Blocked` — preserves the row for audit; a separate
127+
"delete" action can come later if needed).
128+
- An already-approved user can later be blocked (`Approved`
129+
`Blocked`) from the same admin surface; un-blocking is just the
130+
same action in reverse.
131+
132+
### Domain manager — a role, not a flag
133+
134+
Following the same reasoning as the `status` decision above —
135+
capability and identity stay orthogonal — "domain manager" is
136+
modelled as the **role** `ROLE_DOMAIN_MANAGER` on the existing
137+
`User.roles` column rather than a dedicated boolean field.
138+
139+
- Promotion / demotion is just adding or removing the role from the
140+
array.
141+
- A user's **scope** (which domain they manage) is derived from the
142+
part of their own e-mail address after the `@`. No separate
143+
column.
144+
- Authorisation flows through a small voter that combines:
145+
1. `is_granted('ROLE_DOMAIN_MANAGER')` on the acting user, and
146+
2. `emailDomain(currentUser) === emailDomain(targetUser)`.
147+
- Site admin gets `ROLE_ADMIN`. Symfony's `role_hierarchy` is
148+
configured so `ROLE_ADMIN` implies `ROLE_DOMAIN_MANAGER`, and the
149+
voter short-circuits the domain-match check for admins so they can
150+
manage users across all domains from the same screen.
151+
- The user-management view is a single controller. The list query
152+
is scoped: `ROLE_ADMIN` sees everyone; `ROLE_DOMAIN_MANAGER` sees
153+
only users whose email domain matches their own.
154+
155+
If we later need a user to manage a domain *other than* their own
156+
email's, or an organisation that owns multiple e-mail domains, we
157+
introduce a `Domain` entity and a relation. Defer until needed —
158+
removing that complexity later would be the painful direction.
159+
160+
### Implication for #45
161+
162+
Both `active` and `domainManager` from
163+
[#45](https://github.com/itk-dev/ai-lib/issues/45) are **superseded**:
164+
165+
- `active` → replaced by the `status` enum.
166+
- `domainManager` → replaced by the `ROLE_DOMAIN_MANAGER` role on the
167+
existing `User.roles` column.
168+
169+
When #45's implementation lands, the entity ships with **only `name`**
170+
on top of the auth fields from
171+
[#2](https://github.com/itk-dev/ai-lib/issues/2). #45 should be
172+
updated to reflect this so the migration doesn't end up needing
173+
immediate amendment.
174+
175+
## Consequences
176+
177+
### Positive
178+
179+
- Identity state and authorisation stay orthogonal; reasoning about
180+
either in isolation is easier.
181+
- The approval queue is a one-line query (`WHERE status = 'pending'`);
182+
audit and compliance questions ("show me all blocked accounts")
183+
fall out of the same model.
184+
- `UserCheckerInterface` is Symfony's documented hook for exactly this
185+
— no custom event listeners or controller checks scattered around.
186+
- The enum's three named cases read better in code, templates, and
187+
the admin UI than two booleans would.
188+
189+
### Negative / Trade-offs
190+
191+
- Doctrine string-backed enums need a small `Type` mapping (or use
192+
Symfony's built-in support); a tiny amount of extra setup compared
193+
to a bare boolean column.
194+
- Adding a fourth state later (e.g. `awaiting_email_verification`,
195+
`expired`) means a migration to extend the enum domain. We accept
196+
this — it's exactly the kind of change an ADR should make
197+
deliberate.
198+
- The env-var allow-list is the right starting point but will need to
199+
graduate to a `Domain` entity if domains grow or need per-domain
200+
metadata (e.g. a different approver per organisation). Tracked as a
201+
follow-up only if it actually happens.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ See [adr.github.io](https://adr.github.io/) for background on the format.
2626
| [003](003-admin-crud-tooling.md) | Admin / CRUD tooling | Draft | 2026-06-11 |
2727
| [004](004-project-license-mpl-2.md) | Project license: MPL-2.0 | Accepted | 2026-06-10 |
2828
| [005](005-organization-entity-and-assistant-derivation.md) | Organization entity and assistant derivation | Draft | 2026-06-12 |
29+
| [006](006-user-approval-and-account-state.md) | User registration, approval, and account state. | Draft | 2026-06-12 |

0 commit comments

Comments
 (0)