@lamalibre/create-lamaste 4.6.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +22 -3
  4. package/scripts/pin-release.js +98 -0
  5. package/src/index.js +8 -1
  6. package/src/lib/env.js +19 -0
  7. package/src/lib/service-config.js +7 -0
  8. package/src/tasks/redeploy.js +8 -0
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +6 -2
  13. package/vendor/docs/01-concepts/authorization.md +4 -0
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/security-model.md +28 -3
  18. package/vendor/docs/01-concepts/tickets.md +2 -2
  19. package/vendor/docs/01-concepts/tunneling.md +4 -4
  20. package/vendor/docs/02-guides/agent-setup.md +5 -5
  21. package/vendor/docs/02-guides/certificate-management.md +2 -2
  22. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  23. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  24. package/vendor/docs/02-guides/installation.md +3 -3
  25. package/vendor/docs/02-guides/managing-users.md +5 -11
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/second-domain.md +82 -0
  28. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  29. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  30. package/vendor/docs/03-architecture/installer.md +4 -4
  31. package/vendor/docs/03-architecture/management-flow.md +1 -1
  32. package/vendor/docs/03-architecture/nginx-configuration.md +15 -5
  33. package/vendor/docs/03-architecture/overview.md +5 -5
  34. package/vendor/docs/03-architecture/panel-client.md +6 -6
  35. package/vendor/docs/03-architecture/panel-server.md +2 -2
  36. package/vendor/docs/03-architecture/state-management.md +3 -3
  37. package/vendor/docs/03-architecture/system-overview.md +1 -1
  38. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +17 -13
  39. package/vendor/docs/04-api-reference/app-host.md +3 -3
  40. package/vendor/docs/04-api-reference/domains.md +192 -0
  41. package/vendor/docs/04-api-reference/gatekeeper.md +35 -7
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +9 -0
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  46. package/vendor/docs/04-api-reference/sites.md +5 -0
  47. package/vendor/docs/04-api-reference/tunnels.md +15 -10
  48. package/vendor/docs/04-api-reference/users.md +41 -29
  49. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  50. package/vendor/docs/05-operations/uninstalling.md +1 -1
  51. package/vendor/docs/05-operations/upgrades.md +6 -6
  52. package/vendor/docs/06-reference/config-files.md +1 -1
  53. package/vendor/docs/06-reference/glossary.md +3 -3
  54. package/vendor/docs/06-reference/installer-flags.md +13 -13
  55. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  56. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  57. package/vendor/docs/_index.json +105 -19
  58. package/vendor/docs/external-members-design.md +816 -0
  59. package/vendor/docs/gatekeeper-design.md +8 -8
  60. package/vendor/docs/identity-directory-design.md +3 -3
  61. package/vendor/docs/multi-domain-relay-design.md +836 -0
  62. package/vendor/docs/package.json +1 -1
  63. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  64. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  65. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  67. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  69. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  70. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  72. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  74. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  76. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  77. package/vendor/gatekeeper/dist/server/index.d.ts +5 -1
  78. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.js +28 -2
  80. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  81. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.js +225 -36
  83. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  85. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  87. package/vendor/gatekeeper/package.json +2 -2
  88. package/vendor/server/bin/lamaste-server.js +7 -0
  89. package/vendor/server/package.json +2 -2
  90. package/vendor/server/src/commands/domains.js +214 -0
  91. package/vendor/server/src/commands/plugin-identity.js +2 -16
  92. package/vendor/server/src/commands/plugins.js +112 -8
  93. package/vendor/server/src/commands/registry-credential.js +10 -3
  94. package/vendor/server/src/commands/sites.js +3 -0
  95. package/vendor/server/src/commands/status.js +19 -0
  96. package/vendor/server/src/commands/tunnels.js +4 -1
  97. package/vendor/server/src/commands/users.js +8 -19
  98. package/vendor/server/src/domains.js +92 -0
  99. package/vendor/server/src/fail.js +21 -0
  100. package/vendor/server/src/oidc-client-grants.js +63 -0
  101. package/vendor/server/src/panel-api.js +15 -0
  102. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  103. package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
  104. package/vendor/server-ui/dist/index.html +2 -2
  105. package/vendor/serverd/package.json +2 -2
  106. package/vendor/serverd/src/index.js +42 -16
  107. package/vendor/serverd/src/lib/app-error.js +24 -0
  108. package/vendor/serverd/src/lib/audience.js +12 -3
  109. package/vendor/serverd/src/lib/authelia.js +341 -35
  110. package/vendor/serverd/src/lib/directory.js +67 -52
  111. package/vendor/serverd/src/lib/domains.js +1792 -0
  112. package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
  113. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  114. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  115. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  116. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  117. package/vendor/serverd/src/lib/nginx.js +318 -140
  118. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  119. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  120. package/vendor/serverd/src/lib/plugin-lifecycle.js +1 -0
  121. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  122. package/vendor/serverd/src/lib/plugins.js +45 -5
  123. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  124. package/vendor/serverd/src/lib/services.js +9 -1
  125. package/vendor/serverd/src/lib/state.js +11 -0
  126. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  127. package/vendor/serverd/src/lib/user-surface.js +29 -4
  128. package/vendor/serverd/src/lib/users-lock.js +23 -0
  129. package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
  130. package/vendor/serverd/src/lib/web-surface.js +49 -32
  131. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  132. package/vendor/serverd/src/routes/invite.js +15 -5
  133. package/vendor/serverd/src/routes/management/domains.js +154 -0
  134. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  135. package/vendor/serverd/src/routes/management/identity.js +21 -7
  136. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  137. package/vendor/serverd/src/routes/management/plugins.js +4 -2
  138. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  139. package/vendor/serverd/src/routes/management/sites.js +50 -11
  140. package/vendor/serverd/src/routes/management/tunnels.js +29 -5
  141. package/vendor/serverd/src/routes/management/users.js +184 -64
  142. package/vendor/serverd/src/routes/management.js +2 -0
  143. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  144. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  145. package/vendor/serverd/src/routes/user-access.js +13 -1
  146. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  147. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  148. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  149. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +0 -84
@@ -0,0 +1,816 @@
1
+ # External Members of a Domain — Design Document
2
+
3
+ > **Status:** agreed 2026-10-26 (all ten decisions as recommended; Redis left for a later, separate design).
4
+ > **Base:** the multi-domain relay (`multi-domain-relay-design.md`, Lamaste 4.7.0, PR lamalibre/lamaste#1, not merged).
5
+ > **Pilot:** corral (`corral.codelama.com.tr`): the client's people are external members of `codelama.com.tr`.
6
+ > **Companions:** `herd/docs/r730-workload-plan.md` §15.2a (scope), §15.2 (delegated invitations, which come after this),
7
+ > `identity-directory-design.md`, `gatekeeper-design.md`.
8
+
9
+ ## Goal
10
+
11
+ A domain membership has a **kind**: `member` or `external`.
12
+
13
+ - A **member** reaches what 4.7 gives a member: every `authenticated` host
14
+ and OIDC client of the domain, and `restricted` ones by grant.
15
+ - An **external** member reaches only what names them (or a Lamaste group
16
+ they belong to) explicitly:
17
+ - `restricted` tunnels, plugin user surfaces and web surfaces, with a
18
+ Gatekeeper grant;
19
+ - static sites whose `allowedUsers` names them;
20
+ - a web surface's shell only when granted (the shell is the web surface's
21
+ static front, `/authz/user-surface-shell`);
22
+ - OIDC clients granted to them.
23
+ - External members never join an **Authelia** group (`admins`, `feria-*`);
24
+ **Lamaste groups** (Gatekeeper's) in grants still apply to them.
25
+ - External members count toward a plugin's audience when entitled.
26
+ - Enforced in Authelia's rendered configuration **and** in Gatekeeper
27
+ wherever Gatekeeper is in the path.
28
+ - An invitation names the membership kind.
29
+ - Changing an external member's membership or grants does not sign anyone
30
+ out.
31
+
32
+ ## What exists in 4.7
33
+
34
+ | Piece | 4.7 |
35
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | Membership | `domain_members (domain, user_id, added_at, added_by, seq)` (state.db `0016`). No kind. |
37
+ | Authelia `access_control` | `renderAccessControl` (core `server/authelia.ts`): per protected static site — `allowedUsers ∩ members` as `user:` subjects, `[group:admins, user:<m>]` per member, deny; per domain — members as `user:` subjects on `[d, *.d]`, deny; `default_policy: deny`. |
38
+ | OIDC | `renderOidcAuthorizationPolicies`: one policy per domain and factor, members as `user:` subjects; a user-facing client points at its domain's policy. |
39
+ | Rendering | serverd `lib/domains.js` `syncDomainAccess` under `withDomainRendering`: projection `domains.json` `{v:1, domains:[{name, members}]}`, then `updateDomainAccess` (cookies, `access_control`, OIDC policies) through `rewriteAutheliaConfig`, which **restarts Authelia** whenever the section changed. |
40
+ | Users file | `changeUsers` (serverd `lib/directory.js`) writes `users.yml`, renders the domains, and **restarts Authelia** (its file backend has no `watch`: Authelia reads `users.yml` only at start). Authelia's own password reset writes the file from what it read at start, so a panel write can be lost (`expect` re-applies it). |
41
+ | Gatekeeper | `authenticatedSession` → Authelia, then `checkDomain` against the projection (member or refusal page), then the access mode: `public`/`authenticated` allow, `restricted` checks grants. User surfaces and the web-surface shell the same. `setAutheliaHeaders` passes `Remote-Groups` through; plugin tunnels sign them into the relay assertion. |
42
+ | Audience | entitled (Gatekeeper `plugin` grants, Lamaste groups expanded) ∩ members of the plugin's domain (`lib/audience.js`). |
43
+ | Sessions | Authelia's session store is **in memory** (no Redis). A restart ends every session on every domain — residual risk **R3**. |
44
+
45
+ So today **every** membership change, user creation, deletion, disable and
46
+ password-unrelated `users.yml` edit restarts Authelia and signs everyone out.
47
+ With a client's people invited and revoked often (and by delegated
48
+ invitations later), that is the cost this design removes.
49
+
50
+ ## Authelia facts this design rests on (4.39.28, read from the source)
51
+
52
+ Checked against `github.com/authelia/authelia` tag `v4.39.28`:
53
+
54
+ 1. **Users-file watch.** `authentication_backend.file.watch: true` starts a
55
+ watcher on the file's directory (`internal/service/file_watcher.go`); a
56
+ `Write` or `Create` event on `users.yml` — an atomic rename into place is
57
+ a `Create` — reloads the in-memory database (`FileUserProvider.Reload`).
58
+ A reload within 0.5 s of the previous one (or of Authelia's own save) is
59
+ **skipped** (`ErrWatcherCooldown`), not queued.
60
+ 2. **Fresh groups on every request.** With `watch` and no explicit
61
+ `refresh_interval`, the refresh interval becomes `always`
62
+ (`validator/authentication.go`): every forward-auth request
63
+ (`handleSessionValidateRefresh`) re-reads the user's details from the
64
+ in-memory database; a user who is gone or `disabled` invalidates the
65
+ session. So a `users.yml` change applies to existing sessions at their
66
+ next request, without a restart.
67
+ 3. **OIDC authorization-code reads fresh groups too — the device-code
68
+ flow does not.** The authorization-code handlers call the same refresh
69
+ before evaluating the client's authorization policy against
70
+ `userSession.Groups` (`handler_oauth2_authorization_consent_core.go`).
71
+ The device-code handlers (`handler_oauth2_device_authorization.go`, the
72
+ consent POST in `handler_oauth2_consent.go`) evaluate the groups the
73
+ session last refreshed (found by the security review, S1; residual R5).
74
+ 4. **Group subjects from a hostname.** An `access_control` rule's
75
+ `domain_regex` with a named group `(?P<Group>…)` matches only when the
76
+ captured text is one of the user's groups (`RegexpGroupStringSubjectMatcher`,
77
+ case-insensitive); a rule may carry `subject` too (all conditions ANDed,
78
+ `AccessControlRule.IsMatch`).
79
+ 5. **The standard `groups` OIDC claim cannot be redefined or filtered**
80
+ (`validateOIDCScopes` refuses a custom definition of a standard scope or
81
+ claim): an OIDC client that asks for `groups` receives every group the
82
+ user has.
83
+ 6. Sessions are in memory unless `session.redis` is configured.
84
+
85
+ ## Design
86
+
87
+ ### The idea: who is named lives in `users.yml`, the rules stay still
88
+
89
+ 4.7 writes _who_ into `configuration.yml` (`user:` subjects), so every change
90
+ of who restarts Authelia. This design writes **who into `users.yml`** as
91
+ groups Lamaste owns, and **rules that name those groups into
92
+ `configuration.yml`**. With the users file watched (fact 1) and refreshed on
93
+ every request (facts 2, 3), a membership, kind, grant or `allowedUsers`
94
+ change becomes a `users.yml` write: Authelia reloads it in place and applies
95
+ it at each user's next request. `configuration.yml` changes — and Authelia
96
+ restarts — only when the **structure** changes: a domain added, activated or
97
+ removed; a static site protected, unprotected, created or removed; the set
98
+ of OIDC clients or a plugin's domain.
99
+
100
+ The same switch removes the restart from every other `users.yml` change
101
+ (user created, invited, deleted, disabled, groups changed) for members too,
102
+ and makes Authelia's own password-reset write follow the file (fact 1) —
103
+ narrowing the lost-write window `changeUsers`' `expect` exists for.
104
+
105
+ ### Groups Lamaste owns
106
+
107
+ Rendered by the panel into each user's `groups` in `users.yml`, beside the
108
+ groups an admin assigns:
109
+
110
+ | Group | Holds |
111
+ | ------------------------- | ----------------------------------------------------------------------------------------- |
112
+ | `lamaste:m:<key>` | active members (kind `member`) of the active domain whose group key is `<key>` |
113
+ | `lamaste:x:<key>` | active external members of that domain |
114
+ | `lamaste:s:<site id>` | the listed users (`allowedUsers`) of a protected static site who are people of its domain |
115
+ | `<fqdn>` (a hostname) | **host group**: the external members granted a Gatekeeper-gated restricted host — below |
116
+ | `lamaste:oidc@<clientId>` | active external members of the client's domain with a grant on the client (OIDC, below) |
117
+
118
+ The **group key** is 16 random hex characters on the domain record
119
+ (`domains.group_key`, state.db `0018`): a group names no domain, so a
120
+ relying party that asks for the `groups` claim learns no tenant's name
121
+ (security review, decision 8 as revised). A site group is named by the
122
+ site's id for the same reason (replacing 4.7's `user:` subjects, editing
123
+ `allowedUsers` no longer restarts Authelia).
124
+
125
+ Host group `<fqdn>` — a **Gatekeeper-gated restricted host**: an enabled
126
+ `restricted` app or plugin tunnel (a tunnel without an access mode is
127
+ restricted, as Gatekeeper reads it), a `restricted` tunnel bound as a user
128
+ surface, a `restricted` web surface: the **external** members of its domain
129
+ who hold a grant on it (direct, or through a Lamaste group) — decision 3.
130
+ Members do not need it: the domain rule admits them and Gatekeeper checks
131
+ their grants, as in 4.7. Only these must be hostnames: Authelia's
132
+ `(?P<Group>…)` capture compares a host's name with the user's groups.
133
+
134
+ `:` never occurs in a hostname, and a host group is always a hostname under
135
+ a relay domain, so no Lamaste group can be mistaken for a host or the other
136
+ way round.
137
+
138
+ **Reserved names.** Groups an admin assigns (users API, invitations, the
139
+ panel's editor) must match `^[A-Za-z0-9_-]{1,64}$` (`admins`, `feria-admins`
140
+ fit); a name with `.`, `:` or `@` is refused (400 `reserved_group`). The
141
+ render owns every group that starts with `lamaste:` or is a hostname under a
142
+ relay domain (any domain record, pending included, and every domain removed
143
+ from the relay — `retired_domains`, state.db `0018`), compared folded as
144
+ Authelia compares groups (NFKC, then lower case: Go's `EqualFold` takes `ſ`
145
+ for `s` and the Kelvin sign for `k`): it adds what is desired and removes
146
+ the rest, so a stray or look-alike host group cannot survive a render.
147
+ Groups outside both stay as they are (an older admin group with a dot is
148
+ left alone, but cannot be newly assigned).
149
+
150
+ Lamaste-owned groups are **never shown** as groups (users list, user
151
+ editor, `/api/identity/users`, `/api/identity/self`): the user's domains
152
+ with their kinds are shown instead. Gatekeeper strips them from
153
+ `Remote-Groups` and from a plugin tunnel's relay assertion
154
+ (`groups`); they reach only Authelia — and, unavoidably, an OIDC client that
155
+ asks for the `groups` claim (fact 5; residual R4).
156
+
157
+ ### `access_control`
158
+
159
+ `renderGroupAccessControl(domains, sites)` — `domains` are
160
+ `{ name, groupKey }` (no usernames: the rules name groups); `m(d)` /
161
+ `x(d)` below are `lamaste:m:<key of d>` / `lamaste:x:<key of d>`:
162
+
163
+ 1. **Per protected static site** (domain `d`):
164
+ - `allowedUsers` set: `subject: group:lamaste:s:<site id>`, `two_factor`
165
+ (a site listing users without an id gets none: admins only);
166
+ - `subject: [[group:admins, group:m(d)]]`, `two_factor`
167
+ (an admin who is a member — never external, below);
168
+ - `deny`.
169
+ 2. **Per domain `d`**, in this order:
170
+ - the sign-in portal: `domain: [auth.d]`, `subject: [group:m(d),
171
+ group:x(d)]`, `two_factor` — nginx never asks Authelia about the portal for
172
+ a resource but the desktop's `/api/user-access/authorize`, which refuses
173
+ external members itself; Gatekeeper asks it to learn who a refusal was
174
+ for (below);
175
+ - members: `domain: [d, '*.d']`, `subject: group:m(d)`, `two_factor`;
176
+ - external members on hosts that name them:
177
+ `domain_regex: '^(?P<Group>(?:[a-z0-9-]+\.)+<d quoted>)$'`,
178
+ `subject: group:x(d)`, `two_factor` — admitted only on a host whose
179
+ name is one of their groups (fact 4), and only while they are an
180
+ external member of `d`. The apex is not matched: it is never a
181
+ Gatekeeper-gated host;
182
+ - `domain: [d, '*.d']`, `deny`.
183
+ 3. `default_policy: deny` (unchanged).
184
+
185
+ Every rule above is fixed for a given set of domains and protected sites:
186
+ memberships, kinds, grants and `allowedUsers` never change it. A static
187
+ site's rules come first and end with `deny`, so the external rule never
188
+ reaches a static site's host — `allowedUsers` alone decides there, as the
189
+ plan asks.
190
+
191
+ **Restricted hosts appear nowhere in the configuration**: a tunnel created,
192
+ deleted or switched between `authenticated` and `restricted` changes only
193
+ which host groups the panel renders. A new `authenticated` tunnel is closed
194
+ to external members from its first request (no host group, the member rule
195
+ does not match them, deny) — fail closed by construction.
196
+
197
+ ### OIDC: per-client grants and policies
198
+
199
+ - **Grant.** A Gatekeeper grant with `resourceType: 'oidc-client'`,
200
+ `resourceId: <client id>`, principal a user or a Lamaste group — the grant
201
+ model and API Gatekeeper already has (validated by the panel: the client
202
+ exists and is user-facing). Gatekeeper is not in the OIDC path; it only
203
+ stores the grant. The panel expands it (Lamaste groups too, as
204
+ `lib/audience.js` does) into `lamaste:oidc@<clientId>` for the external
205
+ members of the client's domain.
206
+ - **Policy per user-facing client** (replacing 4.7's per-domain policies),
207
+ named `lamaste_client_<client id>` (ids are `[a-z0-9-]`, no dot for
208
+ Authelia's loader to split on):
209
+
210
+ ```yaml
211
+ authorization_policies:
212
+ lamaste_client_feria-cli:
213
+ default_policy: deny
214
+ rules:
215
+ - policy: two_factor # the client's declared factor
216
+ subject: 'group:lamaste:m:<key of codelama.com.tr>'
217
+ - policy: two_factor
218
+ subject: [['group:lamaste:x:<key of codelama.com.tr>', 'group:lamaste:oidc@feria-cli']]
219
+ ```
220
+
221
+ Members of the client's domain are admitted as in 4.7; external members
222
+ only with the grant. The policy changes only with the client set or a
223
+ plugin's domain; a grant is a `users.yml` change, applied at the user's
224
+ next authorization (fact 3 — the authorization-code path; the
225
+ device-code path reads the session's groups as they were, so a narrowing
226
+ that reaches a device-code client restarts Authelia: security review S1).
227
+ Pure `client_credentials` clients keep `one_factor` (unchanged).
228
+
229
+ - **Tokens already issued** survive a revocation until they expire. Plugin
230
+ clients get no refresh tokens (4.7, R1), so the bound is the access and ID
231
+ token lifespan: Authelia's default is one hour; decision 6 proposes
232
+ 15 minutes for user-facing clients. Registry tokens Feria minted from a
233
+ login are revoked by Lamaste when the grant behind them ends (below).
234
+
235
+ ### Gatekeeper
236
+
237
+ - **Projection v2** (`domains.json`):
238
+ `{ "v": 2, "domains": [{ "name": "…", "members": ["…"], "externals": ["…"] }] }`.
239
+ An unknown version is a missing projection: every check refuses (fail
240
+ closed, as 4.7). Panel and Gatekeeper ship together (one release).
241
+ - `checkDomain` returns the kind. When Authelia refuses a signed-in
242
+ session (its 403 names nobody), Gatekeeper asks Authelia once more about
243
+ the same session for `https://auth.<d>/` (the portal rule admits the
244
+ domain's members and external members): an external member is then told
245
+ they are not granted (the access-request page, or `forbidden`), anyone
246
+ else gets the domain refusal. Never cached, and the request stays
247
+ refused. Then, on every route:
248
+ - **tunnel** — `public` hosts are not gated; `authenticated`: a member
249
+ passes, an external member is refused with the **access-request page**
250
+ (403, "ask the administrator for access" — they are on the domain, only
251
+ not granted), logged `external_not_granted`; `restricted`: grants, as
252
+ today, for both kinds.
253
+ - **user surface** — `authenticated`: external → 403 JSON `forbidden`
254
+ (`X-Lamalibre-Refusal: forbidden`); `restricted`: the host's grant.
255
+ - **web-surface shell** — `authenticated`: external → access-request
256
+ page; `restricted`: audience + `site` grant (unchanged).
257
+ - `Remote-Groups` and the plugin-tunnel assertion's `groups` drop
258
+ Lamaste-owned groups (core `isLamasteOwnedGroup`).
259
+ - Session cache: unchanged (keyed by session and host, busted on every
260
+ change the panel makes).
261
+
262
+ Authelia and Gatekeeper each refuse an external member on an
263
+ `authenticated` host on their own: Authelia because no rule admits them
264
+ (no host group), Gatekeeper because the projection says `external`. On a
265
+ `restricted` host both check the grant: Authelia through the host group,
266
+ Gatekeeper from its database.
267
+
268
+ ### The directory
269
+
270
+ `state.db` migration `0017_membership_kind.sql`:
271
+
272
+ ```sql
273
+ ALTER TABLE domain_members ADD COLUMN kind TEXT NOT NULL DEFAULT 'member'
274
+ CHECK (kind IN ('member', 'external'));
275
+ ```
276
+
277
+ - Existing memberships stay `member` (no change of behaviour).
278
+ - A kind change is an update; a trigger stamps `seq` on it (as on insert),
279
+ audited `directory.membership.changed` `{ domain, userId, from, to }`.
280
+ `directory.membership.added` gains `kind`.
281
+ - **Authelia groups and external members** (decision 4): a user who is an
282
+ external member of any domain holds no Authelia group. `PUT /api/users/:u`
283
+ adding a group to such a user, or making a user with groups external
284
+ anywhere, is refused (409 `external_member_groups`); an invitation naming
285
+ both groups and an external membership is refused (400). The admin removes
286
+ the groups first.
287
+ - **Disable / delete** as in 4.7 (disable keeps memberships and their kinds;
288
+ delete removes them).
289
+ - **New users** on a one-domain relay join it as `member` (4.7, decision 1);
290
+ an invitation or user creation may name `external` explicitly.
291
+
292
+ ### Rendering — one writer, two files
293
+
294
+ `withDomainRendering` stays the one writer. `syncDomainAccess` becomes:
295
+
296
+ 1. read domains (with their group keys), memberships with kinds, protected
297
+ sites, Gatekeeper-gated restricted hosts, the grants on them and on OIDC
298
+ clients (Gatekeeper's API, Lamaste groups expanded); when the grants
299
+ cannot be read, external members hold no granted host or client group
300
+ (fail closed) and the step counts as failed (audited, notified);
301
+ 2. write **`users.yml`'s Lamaste-owned groups** (one file lock, compare
302
+ first — `renderOwnedGroups`, pure, in core);
303
+ 3. write the projection;
304
+ 4. write `configuration.yml` (cookies, `access_control`) — compare first;
305
+ restart only when it changed (structural); the OIDC provider follows in
306
+ `syncOidcClients`, after the domains at start too;
307
+ 5. restart Authelia when it must read `users.yml` anew: its configuration
308
+ does not have it hold the file as Lamaste needs (watch, refresh always,
309
+ its own password writes off) and the file changed; or a narrowing
310
+ reached a device-code OIDC client (security review S1).
311
+
312
+ Then, outside the lock, every enabled user who lost an owned group since
313
+ the last complete rendering — whatever narrowed them, a revocation with
314
+ Gatekeeper's own CLI included — is audited (`directory.access.narrowed`,
315
+ by id and a count) and loses the registry tokens the registry's sign-in
316
+ would refuse now.
317
+
318
+ **Order.** `users.yml` is always written before the configuration: a new
319
+ site rule or policy names a group that is already filled, and a revoked
320
+ group is empty before any rule could matter (a removed site's group goes
321
+ first too: its rule then denies everyone until it goes).
322
+
323
+ **The reload cooldown** (fact 1). A write that lands within 0.5 s of the
324
+ previous reload is skipped by Authelia, silently. So `users.yml` writes are
325
+ serialized under one file lock and each is **followed by a touch** once
326
+ 0.6 s have passed (`touchUsersFile`: read again, the owned groups applied
327
+ again, written), guaranteeing a reload after the cooldown with Lamaste's
328
+ groups even if something else wrote the file meanwhile; the identity tick
329
+ and the start touch it too. Authelia's own writes of the file (password
330
+ change and reset) are disabled. A write never drops a user it was not told
331
+ to delete, never adds owned groups to a user the last rendering did not
332
+ know (created since, perhaps under a deleted user's username), and only
333
+ narrows after a rendering failed.
334
+
335
+ **Triggers.** Every membership, kind, user, site, tunnel, user-surface,
336
+ web-surface and plugin-domain change already renders; additionally every
337
+ grant or Lamaste-group write the panel proxies to Gatekeeper
338
+ (`/api/gatekeeper/*`, entitlements) renders afterwards, and the identity
339
+ tick (every minute) renders — which catches grants made with Gatekeeper's
340
+ own CLI on the relay (Gatekeeper enforces them at once; Authelia's host
341
+ group follows within a minute).
342
+
343
+ **Revocations.** A user narrowed loses their registry tokens when none of
344
+ the registry's own sign-in clients — the user-facing clients whose audience
345
+ is the registry — admits them any more: as a member of the client's domain,
346
+ or as an external member granted it. No such client, or one that cannot be
347
+ read, is "no" (fail closed).
348
+
349
+ ### What ends when a member is made external
350
+
351
+ Their `lamaste:m:<key>` group becomes `lamaste:x:<key>` in one write:
352
+ at their next request Authelia admits them only on hosts naming them, and
353
+ Gatekeeper (projection, session cache busted) refuses `authenticated` hosts
354
+ — within nginx's 10 s authorization cache (`AUTHZ_SUCCESS_MAX_AGE_SECONDS`,
355
+ plugin tunnels and user surfaces are never cached). Their Authelia groups
356
+ must have been removed first (decision 4). OIDC: their next authorization is
357
+ refused (a device-code client of the domain: Authelia restarts so no
358
+ session keeps the group it last read); issued access tokens live out their
359
+ 15-minute lifespan. Desktop user access (primary domain only) checks the
360
+ kind at every request.
361
+
362
+ ### Audience
363
+
364
+ Unchanged rule: entitled ∩ (members ∪ external members) of the plugin's
365
+ domain. Whether the plugin is told the kind is decision 7.
366
+
367
+ ### Invitations
368
+
369
+ `domains` entries name a kind (decision 2):
370
+ `domains: [{ "name": "codelama.com.tr", "kind": "external" }]`. Accepting
371
+ adds the memberships with their kinds in the same `changeUsers`. The
372
+ invitation list shows the kinds. A delegated invitation (§15.2, later) will
373
+ only be able to create `external` memberships.
374
+
375
+ ### API, panel, desktop, CLI
376
+
377
+ - `GET /api/domains/:name/members` adds `kind`; `PUT
378
+ /api/domains/:name/members/:user` takes `{ kind }` (default `member`;
379
+ re-putting with another kind changes it); `GET /api/users` and
380
+ `PUT /api/users/:username` carry `domains: [{ name, kind }]`;
381
+ `GET /api/domains` counts members and external members.
382
+ - `POST /api/gatekeeper/grants` accepts `resourceType: 'oidc-client'`
383
+ (validated by the panel); `GET /api/plugins/:name/oidc-clients` lists each
384
+ user-facing client's grants.
385
+ - **Panel**: an "External" badge on Users, on the domain's member list and
386
+ on invitations; the membership editor picks the kind per domain; the
387
+ Authelia group editor is disabled for external users with the reason; the
388
+ plugin card's OIDC clients section grants and revokes; the People panel
389
+ marks external audience members.
390
+ - **AdminClient** (enforced at build time): membership methods take/return
391
+ `kind`; `getOidcClientGrants`, `grantOidcClient`, `revokeOidcClientGrant`;
392
+ web client, desktop client and `admin_*` Tauri commands.
393
+ - **CLI** `lamaste-server`: `domains members <d> add <user> [--external]`,
394
+ `domains members <d> kind <user> member|external`, the kind in
395
+ `users list`/`domains members` output, `plugins oidc-clients <name>
396
+ grants|grant|revoke <clientId> --user <u>|--group <g>`.
397
+
398
+ ### Upgrade in place
399
+
400
+ 1. Migrations 0017 (every membership `member`) and 0018 (a group key per
401
+ domain, `retired_domains`); Gatekeeper 0004 (access grants never
402
+ consumed).
403
+ 2. First start after the upgrade renders the domains before the OIDC
404
+ clients: `users.yml` gains each member's `lamaste:m:<key>`, then
405
+ `configuration.yml` the users-file settings and the group-based rules —
406
+ **one** restart (sessions end once, at the update, as every update does
407
+ today); the projection becomes v2.
408
+ 3. create-lamaste's redeploy keeps `users.yml` writable by Authelia's account
409
+ and the panel (0660 `lamaste:lamaste-authelia`, the directory 2770) — the
410
+ watcher needs nothing more.
411
+ 4. A relay with no external members behaves exactly as 4.7 (same people
412
+ reach the same hosts), minus the restarts.
413
+
414
+ ## Security notes
415
+
416
+ - Two independent gates, one source: the panel renders both from `state.db`
417
+ (and Gatekeeper's grants). Authelia: host groups, member/external groups,
418
+ `default_policy: deny`. Gatekeeper: the projection's kind and its own
419
+ grants.
420
+ - **Fail closed everywhere new**: a host with no host group refuses external
421
+ members; an unknown projection version refuses everyone; a skipped reload
422
+ is re-triggered; a render failure is audited and notified (4.7, S4).
423
+ - `users.yml` becomes security-relevant for _access_, not only credentials:
424
+ it was already (Authelia groups, `admins`). Who may write it is unchanged
425
+ (the panel and Authelia's account).
426
+ - **R3 narrowed**: no membership, kind, grant, user or `allowedUsers` change
427
+ restarts Authelia — but for a narrowing that reaches a device-code OIDC
428
+ client (R5). A restart (all sessions end) remains for structural changes
429
+ — a domain added/activated/removed, a static site
430
+ protected/unprotected/created/removed, the OIDC client set or a plugin's
431
+ domain changed — all admin actions, none per person. Redis sessions,
432
+ which would remove it, are a later, separate design.
433
+ - **R4 (narrowed by the review)**: an OIDC client that asks for `groups`
434
+ (Feria's `feria login`) sees the user's Lamaste-owned groups. Member,
435
+ external and site groups are opaque (random keys, site ids), so no
436
+ domain's or site's name leaks; what remains is that two users sharing a
437
+ key share a domain, the OIDC client ids granted to an external member,
438
+ and an external member's granted hostnames (on their own domain).
439
+ - **R5 (new)**: Authelia's device-code flow checks a client's policy
440
+ against the groups a session last refreshed, never re-reading them
441
+ (`handler_oauth2_device_authorization.go`, `handler_oauth2_consent.go` in
442
+ 4.39.28). A narrowing that reaches a device-code client therefore
443
+ restarts Authelia, ending every session on every domain: membership
444
+ ended or made external on a domain serving one, or a grant on one
445
+ revoked. Corral's external members hold no such grant; Code Lama's
446
+ members leave `codelama.com.tr` rarely. To be reported upstream, so the
447
+ flow refreshes like the authorization-code one.
448
+ - External members can never hold `admins` (decision 4), so the
449
+ `[group:admins, …member@d]` clause cannot admit one, and Authelia's
450
+ `admins` bypass of static-site lists stays members-only.
451
+
452
+ ## Proof (rodeo)
453
+
454
+ Written 2026-10-26 in `packages/tools/lamaste-rodeo` (not run yet; each
455
+ `.json` generated from its `.sh` with rodeo's `parseBashTest`, Prettier,
456
+ `testScriptSchema`-valid; `rodeo.config.json` lists 49–51 after 01 and
457
+ single-VM 36 after 03). New three-VM specs on top of
458
+ `multi-domain-harness.sh`, which gained `user_kinds`, `domain_group_key`,
459
+ `user_groups` / `wait_group` (users.yml read as root, groups only),
460
+ `authelia_pid`, `md_kind`, `md_grant`, `wait_status`,
461
+ `assert_access_request` and `signed_in`; `tests/e2e/helpers.sh` gained
462
+ `AUTHELIA_MEMBER_KEY`, `AUTHELIA_USERS_GROUPS_JSON`, a rules filter that
463
+ includes the external `domain_regex` rule, and
464
+ `write_oidc_grant_fixture_plugin`.
465
+
466
+ - **49 — an external member reaches only what names them** (B): the
467
+ external member holds `lamaste:x:<key>` alone; an `authenticated` tunnel →
468
+ access-request page, and still 403 with Gatekeeper's projection held to
469
+ call them a member (Authelia alone refuses); a `restricted` tunnel —
470
+ refused, a direct grant (users.yml gives the hostname group) → through,
471
+ revoked → refused within the settle time, a Lamaste-group grant → through,
472
+ left the group → refused; a protected static site listing the member
473
+ (listing users is structural, so before the baseline) → refused, its list
474
+ edited to name them (`lamaste:s:<site id>`, never the hostname) → through.
475
+ After every change the member's session of before still reaches the
476
+ tunnel and the site, both sessions stay signed in, Authelia's `MainPID`
477
+ never changes.
478
+ - **50 — kind changes**: a member on an authenticated tunnel, turned
479
+ external → the same session refused (access-request page), audited
480
+ `directory.membership.changed` by id, users.yml `m` → `x`; turned back →
481
+ through; making a user in `e2e50team` external → 409
482
+ `external_member_groups`; giving it to an external member → 409; a
483
+ hostname-like or `lamaste:` group name → 400 `reserved_group`; users.yml
484
+ holds the member group, `GET /api/users` and the backend's `Remote-Groups`
485
+ (an echo backend) carry `e2e50team` alone; no restart.
486
+ - **51 — a plugin's web surface and audience** (the corral case): an
487
+ identity + web-surface fixture moved to B, a member and an external
488
+ member entitled as viewers — both in the audience and the agent half's
489
+ projection with their role; an `authenticated` web surface: the member
490
+ reaches the shell and the API, the entitled external member gets the
491
+ access-request page and 403 `{error: forbidden}`; a `restricted` one:
492
+ refused without the site's grant (the member too), the grant → shell and
493
+ API with their directory id and role, revoked → refused; no restart.
494
+ - **single-VM 36 — OIDC by grant**: each client's policy is
495
+ `lamaste_client_<id>` (`m:<key>`, `x:<key>`+`oidc@<id>`); an
496
+ authorization-code client — the member admitted throughout, the external
497
+ member refused, granted → the same session admitted, revoked → refused,
498
+ no restart; a device-code client — revoking the external member's grant
499
+ restarts Authelia (audited `authelia.restarted` `device-code`, new
500
+ `MainPID`) and their session of before is gone.
501
+ - **Upgrade (`tests/e2e-upgrade/01`, extended rather than a new spec)**:
502
+ a user signed in on the previous release and admitted to an
503
+ authenticated tunnel; after the Panel Update every membership is `member`,
504
+ the projection is v2 with no external member, Authelia has `watch: true`,
505
+ `refresh_interval: always`, password change and reset off, rules on the
506
+ member group naming no user, every users.yml user holds it, and the user,
507
+ signed in again, is answered as before.
508
+ - **Existing specs updated** for groups instead of user subjects: three-VM
509
+ 45, 46 (a removal now refuses the same session, no restart), 47 (members'
510
+ groups part of the snapshot), 40 (comments), single-VM 34 and 35 (per-client
511
+ policies), the domain listings' `{name, kind}`.
512
+
513
+ Not provable without running: Authelia's admission of external members
514
+ through the `(?P<Group>…)` capture on a real host; the access-request page
515
+ after Gatekeeper's identity probe; timing within rodeo's ten minutes (51
516
+ installs and hosts a plugin). The device-code approval itself is not driven
517
+ (it needs Authelia's consent UI); 36 proves the mitigation — the restart and
518
+ the ended session.
519
+
520
+ ## Waves
521
+
522
+ 1. **Core** — done. Root `owned-groups.ts` (browser-safe): `memberGroup`,
523
+ `externalGroup`, `oidcClientGroup`, `isLamasteOwnedGroup`,
524
+ `ADMIN_GROUP_NAME_RE` / `isAssignableGroupName`, `renderOwnedGroups`
525
+ (throws on a user both member and external of one domain, or a
526
+ non-username — never naming it) and `mergeOwnedGroups` (replaces exactly
527
+ the owned groups, keeps the administrator's in order, unchanged entries
528
+ stay the same objects). Root `domains.ts`: `MembershipKind`,
529
+ `MEMBERSHIP_KINDS`, projection v2 (`parseDomainsProjection` refuses v1,
530
+ unknown versions and a user of both kinds), `DomainsIndex.kinds`,
531
+ `domainMembership` → `{ domain, kind }`. Root `constants.ts`:
532
+ `OIDC_CLIENT_ID_RE`, `OIDC_CLIENT_ID_MAX_LENGTH`, `isOidcClientId` (the
533
+ manifest schema uses them). Server `authelia.ts`:
534
+ `renderGroupAccessControl`, `externalHostRegex`,
535
+ `renderOidcClientPolicies`, `oidcClientPolicyName`,
536
+ `ensureUsersFileWatched` (run by every `rewriteAutheliaConfig`),
537
+ `watch: true` in `writeAutheliaConfig`, and the 15-minute
538
+ `OIDC_USER_LIFESPAN` named by every user-facing client (rendered into
539
+ `lifespans.custom` only when such a client exists). Tests: serverd
540
+ `external-members-core.test.js`; `multi-domain-core` (projection v2),
541
+ `plugin-oidc` (lifespan), `domains` and Gatekeeper fixtures (v2).
542
+
543
+ **Deviations, for wave 2:**
544
+ - The new renderers sit **beside** 4.7's `renderAccessControl`,
545
+ `renderOidcAuthorizationPolicies` and `oidcAuthorizationPolicyName`,
546
+ which stay wired: switching before users.yml holds the owned groups
547
+ would deny everyone. Wave 2 switches `updateDomainAccess`,
548
+ `buildOidcProviderSection` and `renderOidcClient`'s
549
+ `authorization_policy` to them and removes the old three (4.7-only
550
+ names: edit `public-api.json` by hand). `renderGroupAccessControl` takes
551
+ the domain names only — the rules name groups, so who is in them does
552
+ not matter (the design's `{ name, hasMembers, hasExternals }` was not
553
+ needed).
554
+ - The projection is already v2 on both sides: the panel writes every
555
+ member as `member` and `externals: []` (lib/domains.js, until kinds
556
+ exist), and Gatekeeper admits only `kind === 'member'` (external
557
+ members are refused everywhere until wave 2 checks the kind per route).
558
+ - The user-facing lifespan applies from this wave on (harmless before
559
+ the switch: shorter tokens only).
560
+
561
+ 2. **serverd + Gatekeeper** — done. state.db `0017_membership_kind.sql`
562
+ (`kind`, a trigger stamping `seq` on a kind change). `lib/domains.js`:
563
+ `MembershipInputSchema` (a name, or `{ name, kind }`),
564
+ `normalizeMemberships` (400 `conflicting_membership`), kinds through
565
+ `listDomainMembers`, `domainsByUser` / `domainsOfUser` (`{ name, kind }`),
566
+ `domainsOverview` (`externalMembers`), `domainsForNewUser`,
567
+ `insertMembershipsSync`, `addDomainMember(…, { kind })` (a kind change
568
+ audited `directory.membership.changed` `{ domain, userId, from, to }`),
569
+ `setUserDomains` (kinds), invitations (`invitationMemberships`; stored
570
+ 4.7 strings read as members); `adminGroups` / `adminGroupsOf`,
571
+ `assertAssignableGroups` (400 `reserved_group`; a group the user already
572
+ holds is kept), `externalMemberGroupsError` / `assertNoGroupsForExternal`
573
+ (409 `external_member_groups` — 400 when an invitation or a new user asks
574
+ for both). `syncDomainAccess` renders, under the one writer: users.yml's
575
+ owned groups (`lib/owned-groups.js` `desiredOwnedGroups` → core
576
+ `renderOwnedGroups` → `lib/authelia.js` `writeOwnedGroups`), the
577
+ projection v2, then `updateDomainAccess` (cookies + `renderGroupAccessControl`
578
+ only — compare-first, so a restart only on a structural change or a
579
+ configuration not yet watching users.yml). The OIDC section renders one
580
+ policy per user-facing client (`renderOidcClientPolicies`; clients name
581
+ `lamaste_client_<id>`); 4.7's `renderAccessControl`,
582
+ `renderOidcAuthorizationPolicies`, `oidcAuthorizationPolicyName`,
583
+ `AutheliaDomain` are gone (`public-api.json` edited). `writeAutheliaConfig`
584
+ takes no members; onboarding renders before Authelia first starts.
585
+ `changeUsers` renders and busts Gatekeeper's sessions — it restarts
586
+ nothing (`renderDomains: false` replaces `restartAuthelia: false` for
587
+ onboarding). Every write of users.yml goes through one file lock in
588
+ `lib/authelia.js` (`writeUsers`, `writeOwnedGroups`; `createUser` and
589
+ `createUserFromInvitation` moved there from core's helpers), keeps the
590
+ owned groups the last rendering decided, and is written again
591
+ `USERS_FILE_TOUCH_DELAY_MS` (600 ms) after the last write unless the file
592
+ changed meanwhile. Users and identity APIs show no owned group; the users
593
+ API keeps them across an edit. Gatekeeper proxy: `oidc-client` grants
594
+ checked (`checkOidcClientGrant`: 400 `unknown_oidc_client` /
595
+ `service_oidc_client`), every change re-renders, and the users a revoked
596
+ client grant or a group change reached are checked for registry
597
+ revocation. `revokeRegistryTokensFor` asks `reachesRegistry`: a member of
598
+ a registry domain, or an external member of one granted a user-facing
599
+ client of it (Gatekeeper unreachable: no). `listPluginOidcClients` adds
600
+ `userFacing` and `grants`. Gatekeeper: `checkDomain` returns the kind,
601
+ `externalRefused` on every route (tunnel → access-request page,
602
+ `external_not_granted`; user surface → 403 `forbidden`; shell →
603
+ access-request page), `shownGroups` strips owned groups from
604
+ `Remote-Groups` and the assertion. Tests: serverd
605
+ `external-members.test.js` (kinds, refusals, hidden and kept groups,
606
+ host groups direct and through Lamaste groups, Gatekeeper unreachable,
607
+ tunnel changes, static sites, OIDC grants, registry revocations, the
608
+ touch), Gatekeeper `authz-external-members.test.js` (kind matrix,
609
+ stripping); `domains`, `identity-directory`, `plugin-oidc`,
610
+ `multi-domain-core`, `agent-hosted-plugins` updated (no restart per
611
+ change; groups instead of user subjects).
612
+
613
+ **Deviations, for review:**
614
+ - **The owned groups are always written first**, a site's removal too
615
+ (the design said configuration first there). A host group never
616
+ outlives the rule that narrows its host: a static site whose rule went
617
+ while its listed external member kept the host group would be opened to
618
+ them by their domain's external rule. Writing the groups first only
619
+ ever narrows in between.
620
+ - **Tunnel and site changes render on their own**: `lib/state.js`
621
+ `writeTunnels` / `writeSites` call `scheduleHostRender` (100 ms,
622
+ coalesced, only while some domain has external members) — every path
623
+ that changes a restricted host (creation, mode, release on revocation,
624
+ web surfaces) is covered without each route asking.
625
+ - **Gatekeeper unreachable**: the hosts and clients each external member
626
+ holds now are kept (a warning), rather than failing the step — a
627
+ revocation is a Gatekeeper write, so it cannot have happened; a
628
+ membership's own group still comes from state.db.
629
+ - Core `renderOwnedGroups` ignores a listed user or a grant's principal
630
+ who is none of the domain's people (it threw): a typo in a site's list
631
+ or a stale grant must not stop the whole rendering.
632
+ - The users lock moved to `lib/users-lock.js`; a kind change takes it
633
+ before the domains lock (never the other way round), so it and a
634
+ groups edit are serialized. The rendering takes neither: the file lock
635
+ and the owned-group cache keep a concurrent read-modify-write from
636
+ writing an older set back.
637
+ - **Left for wave 3**: every client of the changed shapes — the panel UI
638
+ and desktop show `domains` as objects now (`{ name, kind }`), the CLI
639
+ (`domains members`, `users list`), `AdminClient`'s types
640
+ (`AdminUser.domains`, members' `kind`, `externalMembers`, OIDC client
641
+ `grants`), the API reference (users, domains, invitations, Gatekeeper
642
+ grants with `oidc-client`, registry-and-oidc), CHANGELOG, CLAUDE.md.
643
+
644
+ 3. **UI, desktop, CLI, docs** — done. Core `types.ts`: `DomainMembership`
645
+ / `DomainMembershipInput` (`AdminUser.domains`, `domains` on user
646
+ creation, edits and invitations), `DomainView.externalMembers`,
647
+ `DomainMemberView.kind`, `addDomainMember(name, user, { kind })`,
648
+ `getPluginOidcClients` with `PluginOidcClientView` / `OidcClientGrantView`.
649
+ Web client and desktop client implement them (`admin_add_domain_member`
650
+ takes `kind`, checked before curl — `domain_member_body`, cargo-tested;
651
+ `admin_get_plugin_oidc_clients`). Panel: UI helpers `admin/lib/domains.js`
652
+ (`membershipsOf`, `isExternalAnywhere`, `membershipKind`,
653
+ `withMembership`, `sameMemberships`, `externalUserIds`,
654
+ `EXTERNAL_MEMBER_HINT`; tests), `useMembershipChoice` /
655
+ `DomainMembershipChoice` with a kind per domain (a one-domain relay shows
656
+ one **External member** checkbox and sends `domains` only when it is
657
+ ticked), `ExternalBadge` on users, invitations, domain members and a
658
+ plugin's audience, `DomainChips` marking external memberships; Users'
659
+ `GroupsField` closes for an external member and says to remove groups
660
+ first (the form refuses before asking the panel); the Domains page adds
661
+ members of either kind and switches a kind (**Make external** /
662
+ **Make member**) and counts external members; the plugin card's
663
+ `PluginOidcClients` (**OIDC sign-in**) grants a user-facing client to
664
+ external members of the plugin's domain or Lamaste groups and revokes.
665
+ CLI: `domains members <d> add <user> [--external]`,
666
+ `domains members <d> kind <user> member|external` (pure
667
+ `memberRequest`), kinds in `domains members`, `domains list` and
668
+ `users list` (`membershipsText`), `plugins oidc-clients <name> [grants]`
669
+ with each client's grants, `grant|revoke <clientId> --user|--group`
670
+ (`src/oidc-client-grants.js`); tests. Docs: `04-api-reference/domains.md`
671
+ (kinds, where enforced), `users.md` (no restart, kinds, groups Lamaste
672
+ owns), `registry-and-oidc.md` (per-client policies, client grants, 15-minute
673
+ tokens), `gatekeeper.md` (projection v2, the external-member matrix, OIDC
674
+ client grants), `01-concepts/security-model.md` (R3 narrowed, R4),
675
+ `02-guides/second-domain.md` (External members), and the restart
676
+ wording of `managing-users.md`, `state-management.md`,
677
+ `authentication.md`; CHANGELOG 4.7.0; CLAUDE.md.
678
+
679
+ **Deviations, for review:**
680
+ - One new `AdminClient` method, `getPluginOidcClients`: granting and
681
+ revoking go through the existing `createGatekeeperGrant` /
682
+ `revokeGatekeeperGrant` (the panel's proxy checks an `oidc-client`
683
+ grant and re-renders), not dedicated `grantOidcClient` /
684
+ `revokeOidcClientGrant` methods — one grant model, one path.
685
+ - The panel marks an external member of a plugin's audience from the
686
+ users list (their membership of the plugin's domain), not from the
687
+ audience rows, which do not carry the kind (decision 7).
688
+ - The CLI's `revoke` finds the grant through
689
+ `GET /api/plugins/:name/oidc-clients` and revokes it by id; its listing
690
+ now labels a client's OAuth grant types `flows:` (`grants:` is the
691
+ access grants).
692
+
693
+ 4. **Security review** — done (2026-10-26, below): three independent
694
+ reviews, every confirmed finding fixed with a test that fails without
695
+ it; four decided with the user (S1 device code, S2 Authelia's own
696
+ writes, S3 grant sweep, S4 opaque names).
697
+ 5. **Rodeo** — run on fresh Multipass VMs on 2026-10-26, this branch's 16
698
+ packages published to the dev Feria as 4.7.0 (on top of `aac7ce3`):
699
+
700
+ | Suite | Result |
701
+ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
702
+ | single-VM | 35/35 specs (36 — OIDC by grant, device-code restart — 35/35 checks) |
703
+ | three-VM | 51/51 specs: 49 (104 checks), 50 (54), 51 (71); 47 passed 62/62 on a rerun after the harness fix below |
704
+ | upgrade | 01 Panel Update from 4.6.0: 38/38 (memberships `member`, watch, `refresh_interval`, password change/reset off, member group on every user, same answers) |
705
+
706
+ **Harness fix.** Spec 47 first failed one sign-in: Authelia refused a
707
+ TOTP code as already used. `md_sign_in` waited for the next 30 s step by
708
+ the orchestrator's clock while `sign_in` makes the code on the visitor,
709
+ so a visitor a moment behind made the same code again. Steps are read
710
+ from the visitor's clock now (`md_visitor_seconds`).
711
+
712
+ ## Security review (2026-10-26)
713
+
714
+ Three independent reviews (code, threat model, access-decision matrix) of
715
+ `21c092c..551f36a`, each given the code, not the design's conclusions. Each
716
+ confirmed finding was fixed with a unit test that fails without the fix.
717
+
718
+ | # | Finding | Fix | Test |
719
+ | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
720
+ | S1 | Authelia's device-code flow checks a client's policy against the groups a session last refreshed (fact 3 holds for the authorization-code path only): with no restart, someone narrowed could approve a `feria login` for the session's life. | **Decided: targeted restart.** The rendering measures every narrowing against the last complete rendering; when an enabled user lost a group admitting them to a device-code client (core `reachedOidcClients`), Authelia restarts (audited `authelia.restarted` `device-code`) and Gatekeeper's sessions are busted. Unreadable clients → restart (fail closed). Residual R5. | external-members (made external, grant revoked: restart; web client, host grants: none), external-members-core (reach) |
721
+ | S2 | Authelia's own password change/reset writes users.yml from its in-memory copy, which may predate a skipped reload: revoked groups written back, repeatedly by someone changing their password. | **Decided: disabled.** `authentication_backend.password_change.disable` and `password_reset.disable` rendered (they could not work: the notifier is a file) and migrated on every rewrite, with `refresh_interval: always`; `usersFileWatched` requires all four. | external-members-core (settings, each one required) |
722
+ | S3 | Gatekeeper created tunnel, site, agent-plugin and OIDC-client grants consumed, and its sweep deleted them 90 days later, silently. | **Decided: fixed in 4.7.0 itself** (`ed6f931` on the PR branch, with the same fix for core's user-plugin-access store in `9767c38`). Grants are created unused; the sweep deletes consumed local plugin grants only; Gatekeeper migration 0004 un-consumes the others. | gatekeeper grants-retention |
723
+ | S4 | The `groups` claim told an OIDC relying party every domain a user belongs to (another tenant's name) and static-site hostnames. | **Decided: opaque names** (decision 8 revised). `lamaste:m:<key>` / `lamaste:x:<key>`, a random key per domain record (state.db 0018), `lamaste:s:<site id>`; hostname groups only for external members on Gatekeeper-gated hosts. | external-members-core (names, rules name no domain), domains, external-members |
724
+ | S5 | Revocations relied on Authelia watching users.yml, unchecked; an explicitly set `refresh_interval` would lag them. | The settings are rendered and checked (`autheliaWatchesUsersFile`); a rendering restarts Authelia (`unwatched`) after users.yml changed while it does not hold the file so. Start renders the domains before the OIDC clients (an upgrade's first policies name filled groups). | external-members (unwatched restarts once a change, not otherwise) |
725
+ | S6 | A failed rendering left the owned-groups cache stale; a username deleted and taken again got the deleted user's groups (static sites, OIDC — no Gatekeeper there). | A write adds owned groups only to users the rendering knew (`known`), forgets a deleted user's, and only narrows after a failed rendering (`narrowOnly`, core `mergeOwnedGroups` options). | external-members-core (merge options), external-members (reused username, narrow only) |
726
+ | S7 | Gatekeeper unreachable: external members kept the host and OIDC groups they held — a revocation Gatekeeper took just before stayed in Authelia (OIDC has no second gate). | Fail closed: no granted host or client group until the grants can be read; the membership groups are rendered all the same; the step counts as failed (audited, notified). A grant revoked meanwhile is found lost by the next complete rendering. | external-members (unreachable: groups dropped, audited, restored) |
727
+ | S8 | A grant change whose rendering failed answered success with no warning. | The proxy answers `warnings` and renders again within 5 s (`scheduleRenderRetry`). | external-members (warnings on a failed rendering) |
728
+ | S9 | Registry tokens were kept when the user reached any user-facing client of the registry's domain. | Only the registry's own sign-in clients count — user-facing clients whose audience is the registry; none or unreadable → revoked. Revocations follow every narrowing (a revocation with Gatekeeper's own CLI too, found by the tick). | external-members (another client's grant revoked, the CLI revocation), domains |
729
+ | S10 | `oidc-client` grants outlived their plugin's uninstall: a later install of the same client id admitted whoever was granted. | Uninstall revokes every grant on the plugin's clients (`revokeOidcClientGrants`). | external-members (uninstall) |
730
+ | S11 | The touch skipped a file something else had written; a skipped reload or a panel restart within 0.6 s of a write left Authelia on old groups. | The touch re-applies the owned groups to whatever the file holds and always writes; the start and the identity tick touch too. | external-members (a hand edit gets the groups back) |
731
+ | S12 | Ownership compared with JavaScript lower case; Authelia folds with Go's `EqualFold` (`ſ`, Kelvin sign). | `foldGroupName`: NFKC, then lower case. | external-members-core (look-alike spellings owned) |
732
+ | S13 | Host groups of a removed domain became "admin groups": shown, passed in `Remote-Groups`, blocking a kind change. | Removed domains are kept in `retired_domains`; hostnames under them stay owned and are stripped. | external-members (retired domain) |
733
+ | S14 | The CLI's `revoke` removed only the first of several grants naming the principal. | `findClientGrants` returns every match; all are revoked. | cli oidc-client-grants |
734
+ | S15 | An external member Authelia refused (no host group) got the domain refusal ("not a member") and a user surface `not_a_member`. | The portal rule admits members and external members on `auth.<d>`; on Authelia's 403 Gatekeeper asks once more for `https://auth.<d>/` and, for an external member, answers the access-request page / `forbidden`. Never cached; the request stays refused. The desktop's `authorize` (the one forward-auth on the portal) refuses external members itself. | gatekeeper authz-external-members, authz-domain-membership; external-members-core (portal rule) |
735
+ | S16 | A web surface moved off a tunnel left the disabled tunnel's grants in the host group. | `restrictedHosts` skips disabled tunnels (and agent panels). | external-members (disabled tunnel) |
736
+ | S17 | A tunnel without an access mode (restricted for Gatekeeper) got no host group: the two gates disagreed. | Absent or unknown modes are restricted, as Gatekeeper reads them. | external-members (mode-less tunnel) |
737
+ | S18 | An empty or partial users.yml read could be written back, dropping users. | A write re-reads an empty file before believing it and refuses to drop a user it was not told to delete (`deleting`). | external-members (empty read, undeclared drop) |
738
+ | S19 | Narrowings made past the panel (Gatekeeper's CLI) were neither audited nor followed; Gatekeeper's secret compared with `!==`. | Every narrowing found by a rendering is audited (`directory.access.narrowed`, id and count) and followed (registry, device code); the secret is compared in constant time (`secretMatches`). | external-members (audit), gatekeeper grants-retention (secret) |
739
+ | S20 | Desktop user access (primary domain) admitted external members and kept a member made external signed in. | `authorize`, `exchange` and every session request require a `member` of the primary domain (`isPrimaryMember`). | external-members (desktop session) |
740
+
741
+ The apex of a domain is never a Gatekeeper-gated host, so the external
742
+ host rule not matching it (a reviewer's note) refuses nothing that exists.
743
+
744
+ **Residual, documented in `01-concepts/security-model.md`:** R3 narrowed
745
+ (structural changes restart; a device-code narrowing too — R5), R4
746
+ narrowed (opaque groups; the relation "these users share a domain", client
747
+ ids and an external member's granted hostnames remain), R5 (Authelia's
748
+ device flow does not refresh groups — to be reported upstream).
749
+
750
+ ## Decisions (agreed 2026-10-26)
751
+
752
+ The user agreed to every recommendation below; the alternatives are kept for the record.
753
+
754
+ 1. **How membership changes avoid restarts.**
755
+ - **(a) Recommended:** Lamaste-owned groups in `users.yml` with
756
+ `watch: true`; rules in `configuration.yml` name groups (this design).
757
+ Removes the restart from every per-person change, for members too, with
758
+ no new component. Revises 4.7's decision 8 (membership as `user:`
759
+ subjects) before 4.7 is released.
760
+ - (b) Keep `user:` subjects and move Authelia's sessions to **Redis**
761
+ (local unix socket, ACL, ~5–10 MB): restarts no longer sign anyone out,
762
+ but every change still restarts Authelia (a few seconds of refused
763
+ authorizations each time, on every domain), and adds a service to
764
+ install, harden and update.
765
+ - (c) Both: (a) now, Redis later to remove the remaining structural
766
+ restarts. _Recommended as a follow-up_, not in this feature.
767
+ 2. **Invitation and membership shape.** _Recommended:_
768
+ `domains: [{ name, kind }]` everywhere 4.7 has `domains: string[]`
769
+ (users, invitations, the members API returns objects); a bare string is
770
+ still accepted as `member` on input, so 4.7 clients and specs keep
771
+ working. Alternative: a separate `externalDomains: string[]`.
772
+ 3. **What the Authelia host group holds for a restricted host.**
773
+ _Recommended:_ external members **with a grant** on it (Authelia enforces
774
+ grants for external members too; a grant made with Gatekeeper's own CLI
775
+ reaches Authelia within a minute). Alternative: every external member of
776
+ the domain on every restricted host (simpler; Authelia then enforces only
777
+ member/external, Gatekeeper alone the grant).
778
+ 4. **Authelia groups.** _Recommended:_ a user who is external on **any**
779
+ domain holds no Authelia group (relay-wide; groups are relay-wide).
780
+ Alternative: per domain — groups kept, but every rule ANDs them with
781
+ the domain's member group (Authelia) and Gatekeeper ignores them for external
782
+ hosts; leaves `feria-*` reaching Feria through OIDC's `groups` claim for a
783
+ user external on Feria's domain, so not recommended.
784
+ 5. **OIDC grant model.** _Recommended:_ Gatekeeper grants with
785
+ `resourceType: 'oidc-client'` (one grant model, Lamaste groups apply,
786
+ proxied API exists), one authorization policy per user-facing client.
787
+ Alternative: a panel-side `oidc_client_grants` table (no Lamaste groups).
788
+ 6. **OIDC token lifespan.** _Recommended:_ 15 minutes for access and ID
789
+ tokens of user-facing plugin clients (a custom lifespan the clients name),
790
+ bounding what survives a revocation; `feria login` exchanges its token at
791
+ once, so it is unaffected. Alternative: keep Authelia's default (1 h) and
792
+ document it.
793
+ 7. **Does the plugin learn the kind?** _Recommended: no_ in this feature —
794
+ the plugin's roles express what a person may do there, and the identity
795
+ feed stays protocol 1 (4.5 agent halves unchanged). Alternative: an
796
+ optional `membership: 'member'|'external'` attribute in audience changes
797
+ (a manifest opt-in, protocol 2).
798
+ 8. **R4 — Lamaste-owned groups in the OIDC `groups` claim.** _Recommended:_
799
+ accept and document (readable names, public-anyway hostnames; only
800
+ admin-installed relying parties; Feria matches `feria-*` only).
801
+ Alternative: opaque names (`lamaste:m:<random id>`) for the member and
802
+ external groups — hides which domains, but host groups must stay
803
+ hostnames (fact 4), and the panel and logs get harder to read.
804
+ **Revised 2026-10-26 (security review S4, the user's decision): opaque
805
+ names** — member, external and site groups; hostname groups only for
806
+ external members on Gatekeeper-gated hosts.
807
+ 9. **User and web surfaces for external members.** _Recommended:_ as the
808
+ plan says — only `restricted` surfaces with the surface's grant; a plugin
809
+ entitlement (role) alone does not admit them to an `authenticated`
810
+ surface. Alternative: an entitlement counts as "naming them" on an
811
+ `authenticated` surface (fewer grants to manage for corral, but a second
812
+ meaning of `authenticated`).
813
+ 10. **Version.** _Recommended:_ fold into **4.7.0** (unreleased; the group
814
+ model replaces 4.7's user subjects before anyone runs them, and the
815
+ upgrade restarts once instead of twice). Alternative: 4.8.0 after 4.7.0
816
+ ships.