|
| 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. |
0 commit comments