@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,836 @@
1
+ # Multi-Domain Relay — Design Document
2
+
3
+ > **Status:** agreed 2026-10-25 (all nine decisions as recommended); implemented in Lamaste 4.7.0 (2026-10-26), proven by the rodeo campaigns recorded under Waves.
4
+ > **Pilot:** `codelama.com.tr` next to `samplepark.tr` on the relay `138.68.65.228`.
5
+ > **Companions:** `identity-directory-design.md` (the slice 6 directory this builds on),
6
+ > `gatekeeper-design.md`, `herd/docs/r730-workload-plan.md` §14 (why, and the live steps).
7
+
8
+ ## Goal
9
+
10
+ One relay serves several **base domains**. A person has **one account** (one
11
+ password, one TOTP) in the relay's one directory, and reaches a domain only
12
+ when they are an explicit **member** of it.
13
+
14
+ - Code Lama's developers are members of `codelama.com.tr` and `samplepark.tr`.
15
+ - samplepark's users are members of `samplepark.tr` only, and never reach a
16
+ `codelama.com.tr` application.
17
+ - Adding a customer domain changes nobody's access to any other domain.
18
+
19
+ ## Settled (not reopened here)
20
+
21
+ - One relay, one user directory: one account, password and TOTP per person.
22
+ - An explicit member list per domain.
23
+ - A sign-in page (`auth.<domain>`) and an Authelia session cookie per domain.
24
+ - Membership is rendered into Authelia's `access_control` **and** checked
25
+ again in Gatekeeper, which shows a refusal page instead of today's 401 login
26
+ loop.
27
+ - `authenticated` means "member of this domain"; `restricted` plus Gatekeeper
28
+ grants still narrows one application.
29
+ - Admin stays relay-wide (the mTLS admin certificate manages every domain).
30
+ The panel (`panel.`) and chisel (`tunnel.`) stay on the primary domain.
31
+ - No per-domain external identity provider yet.
32
+ - The shared blast radius (one droplet, nginx, panel, Gatekeeper and Authelia
33
+ for every domain) is accepted and documented, not engineered around.
34
+
35
+ ## What exists today (4.6 base, `feat/app-plugins-slice7` `7211da5`)
36
+
37
+ | Piece | Single-domain assumption |
38
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
+ | `panel.json` | One `domain` (`server/daemon/src/lib/config.js` `ConfigSchema`). Every route that creates a hostname reads `getConfig().domain`. |
40
+ | Tunnels | `createTunnelLocked` (`core/lib/src/server/tunnels.ts`) builds `fqdn = ${subdomain}.${domain}` and **stores** it. Uniqueness is by bare `subdomain` (`SUBDOMAIN_IN_USE`). The nginx writers (`NginxDeps`) take `(subdomain, domain, …)` and recompute the hostname; vhost files are `lamalibre-lamaste-app-<subdomain>` / `lamalibre-lamaste-agent-panel-<subdomain>`. |
41
+ | Startup reconcile | `expectedTunnelVhost` (`server/daemon/src/lib/vhost-reconcile.js`) already derives a tunnel's domain from its stored `fqdn` (`fqdn.slice(subdomain.length + 1)`); `reconcileSiteVhosts(log, config.domain)` renders the panel, auth and every site vhost with the one domain. |
42
+ | Sites | `createSite` (`core/lib/src/server/sites.ts`): managed `fqdn = ${name}.${domain}`, custom `fqdn = customDomain`; `aliases` on custom sites. Collision checks rebuild tunnel hosts as `${t.subdomain}.${domain}` rather than reading `t.fqdn`. `createSite` does not refuse an `agent-` name (gap). Site vhosts are `lamalibre-lamaste-site-<uuid>`. |
43
+ | Web surfaces (slice 7) | `createWebSurface` / `hostnameTaken` (`server/daemon/src/lib/web-surface.js`) build `${subdomain}.${config.domain}`. |
44
+ | User surfaces (slice 6) | `userSurfaceHosts` (`core/lib/src/user-surface.ts`) works from stored `fqdn`s only — no domain involved. |
45
+ | Reserved names | `CORE_SUBDOMAINS = ['panel','auth','tunnel']`, `RESERVED_SUBDOMAINS` (+ `www`, `mail`, `ftp`, `api`), `AGENT_PANEL_SUBDOMAIN_PREFIX = 'agent-'` (`core/lib/src/constants.ts`); a copy in `server/ui/src/admin/lib/web-surface.js`. |
46
+ | Authelia session | `writeAutheliaConfig(domain, …)` (`core/lib/src/server/authelia.ts`) renders one `session.cookies` entry; `rewriteAutheliaConfig` already loops over the array. |
47
+ | Authelia `access_control` | `updateAccessControl(sites)` renders only per-site rules (allowed users, `group:admins`, deny); `default_policy: two_factor` — any Authelia user passes on every other host. |
48
+ | nginx 401 | Gatekeeper tunnel vhosts (`buildGatekeeperVhost`) and web-surface vhosts (`renderWebSurfaceVhost`) write `error_page 401 =302 https://auth.${domain}/?rd=…`. Static sites and the auth vhost use Authelia's own `$redirection_url`. |
49
+ | Auth vhost | `renderAuthVhost(domain)` (`server/daemon/src/lib/nginx.js`), file `lamalibre-lamaste-auth`, declares the http-level `limit_req_zone … zone=user_access_auth` and serves `/invite/`, `/api/invite/` and `/api/user-access/authorize`. No `X-Forwarded-Host` — Authelia sees `Host`. |
50
+ | Gatekeeper | `authorizeTunnelRequest` (`sdks/gatekeeper/src/server/routes/authz.ts`): `public`/`authenticated` → allow any Authelia user; `validateWithAuthelia` maps **every** non-200 (Authelia's 403 too) to `null` → 401 → nginx sends the browser back to the sign-in page, which (already signed in) sends it back: a loop. Session cache keyed by host already. |
51
+ | OIDC | `buildOidcProviderSection(domain, …)` writes no issuer; Authelia derives it per request from `X-Forwarded-Host`/`Host` (`IssuerURL()` in Authelia's `internal/middlewares/authelia_context.go`, checked against 4.39.13; pinned 4.39.28). `{domain}` in a client's `audience`/`redirectUris` expands to the one domain (`expandDomain`). Clients carry `authorization_policy` `one_factor`/`two_factor` — Authelia's `access_control` does **not** apply to OIDC authorization. |
52
+ | Registry credential | `registry-credential.json` stores no issuer; `obtainRegistryServiceToken` uses `autheliaIssuer(getConfig().domain)` (`server/daemon/src/lib/registry-credential.js` `oidcDeps`). |
53
+ | Certificates | HTTP-01 per hostname via `lamaste-certbot`; a wildcard is only ever found. `issueTunnelCert` strips the first label to look for `*.<base>`; `getCertPath(fqdn, domain)` is called with `config.domain`. |
54
+ | `lamaste-priv` | `PANEL_SITE_RE` accepts `panel-domain`, `auth`, `tunnel`, `app-<label>`, `agent-panel-<label>`, `site-<uuid>` — **no dots**, so no name containing a hostname. |
55
+ | Agents | Chisel and converge depend only on ports and `https://tunnel.<enrolled domain>:443` (`assertRelayServerUrl`, `enrolledDomain`). `agent-config` returns `{port, subdomain}` per tunnel. `lamaste-agent status` prints `${t.subdomain}.${config.domain}` — wrong for a tunnel on another domain. |
56
+ | Invitations, user access | Invite URL `https://auth.${config.domain}/invite/<token>`; desktop user login opens `auth.<d>` and exchanges at `panel.<d>` for the same `d` (`user_access.rs`). |
57
+
58
+ ## Model
59
+
60
+ ### Domain records
61
+
62
+ `state.db` migration `0016_domains.sql`:
63
+
64
+ ```sql
65
+ CREATE TABLE domains (
66
+ name TEXT PRIMARY KEY, -- 'codelama.com.tr', lower case
67
+ is_primary INTEGER NOT NULL DEFAULT 0, -- exactly one row = 1
68
+ status TEXT NOT NULL CHECK (status IN ('pending','active')),
69
+ created_at TEXT NOT NULL,
70
+ activated_at TEXT
71
+ );
72
+ CREATE UNIQUE INDEX domains_one_primary ON domains(is_primary) WHERE is_primary = 1;
73
+ ```
74
+
75
+ - The migration inserts the primary from `panel.json` `domain` (status
76
+ `active`) when onboarding is completed; onboarding inserts it otherwise.
77
+ `panel.json` `domain` stays as the primary's name, so everything that means
78
+ "the panel's domain" (CORS, `panel.`, `tunnel.`, agent-config, agent panels,
79
+ desktop user access, 2FA) keeps reading it unchanged.
80
+ - **Domains never nest**: a new domain may not equal, contain or be contained
81
+ in another (`a.samplepark.tr` beside `samplepark.tr` is refused). A browser
82
+ sends a parent domain's cookie to every host under it, so nesting would
83
+ mix two domains' sessions.
84
+ - Core `domains.ts` (root export, pure): `assertDomainName` (two or more
85
+ labels, `HOSTNAME_REGEX`, no wildcard, lower case), `domainsOverlap`,
86
+ `domainOfHost(host, domains)` — the one domain the host equals or is under,
87
+ else `null`.
88
+
89
+ **The domain of a hostname is derived from the hostname**, never stored a
90
+ second time. Tunnels and sites already store `fqdn`; because domains never
91
+ nest, `domainOfHost(fqdn)` is unambiguous. A stored second field could only
92
+ disagree with the hostname.
93
+
94
+ ### Hostnames
95
+
96
+ | Object | Hostname | Domain |
97
+ | ------------------------------ | ------------------------------------------------------------- | ------------------------------------------- |
98
+ | App / plugin tunnel | `<subdomain>.<domain>` (`domain` in the API, default primary) | chosen at creation |
99
+ | Agent panel tunnel | `agent-<label>.<primary>` | always primary (mTLS, admin/agent only) |
100
+ | Managed site | `<name>.<domain>` | chosen at creation |
101
+ | Custom site | `customDomain` | `domainOfHost(customDomain)`, may be `null` |
102
+ | Site alias | as given | none of its own — a 301 to the site |
103
+ | User-surface binding (slice 6) | the bound tunnel's `fqdn` | the tunnel's; must be the plugin's domain |
104
+ | Web surface (slice 7) | `<subdomain>.<plugin's domain>` | the plugin's |
105
+ | `panel.`, `tunnel.` | primary only | |
106
+ | `auth.` | one per active domain | |
107
+
108
+ Changes in core and serverd:
109
+
110
+ - `createTunnel` takes `domain` and validates it against the active domains;
111
+ uniqueness becomes **by `fqdn`** (`feria.samplepark.tr` and
112
+ `feria.codelama.com.tr` may coexist). `sites.ts` collision checks and
113
+ `validateAliases` read stored `t.fqdn` instead of rebuilding
114
+ `${t.subdomain}.${domain}`; `createSite` refuses an `agent-` name (closes
115
+ today's gap).
116
+ - The `NginxDeps` writers take `fqdn` instead of `(subdomain, domain)`;
117
+ `nginx.js` derives the Authelia host from it (`auth.${domainOfHost(fqdn)}`).
118
+ `getCertPath` / `hasWildcardCert` are called with the host's domain, not
119
+ `config.domain`.
120
+ - `reconfigureTunnelLocked`, `rewriteServedRoutes`, `bindUserSurface`,
121
+ `web-surface.js` (`hostnameTaken`, `createWebSurface`, `renderOwnedSite`,
122
+ `reconcileOwnedSiteVhost`, `moveWebSurfaceFromTunnel`) and
123
+ `reconcileSiteVhosts` stop taking `config.domain` for hostnames.
124
+ - `agent-config` tunnels gain `fqdn`; `lamaste-agent status` prints it.
125
+ - Moving an existing tunnel to another domain is **not** in this feature
126
+ (decision 6): delete and re-create.
127
+
128
+ ### Reserved names per domain
129
+
130
+ On **every** domain: `RESERVED_SUBDOMAINS` (`panel`, `auth`, `tunnel`, `www`,
131
+ `mail`, `ftp`, `api`) and the `agent-` prefix. `panel.` and `tunnel.` are not
132
+ served on added domains, but reserving them keeps every domain's namespace the
133
+ same and leaves room. A domain's apex is no relay host: it may be a custom
134
+ site (the company page may later move to the relay) or a site's alias, as
135
+ may `www.<domain>`; `isRelayHost` (`panel.`, `auth.`, `tunnel.`, `agent-*`)
136
+ is what custom sites and aliases may never take. Site **names** stay unique
137
+ relay-wide (agents' `allowedSites` and the CLI name sites by them), so a
138
+ second `blog.` on another domain is a custom site there. The UI's copy of the list
139
+ (`server/ui/src/admin/lib/web-surface.js`) is replaced by the core export.
140
+
141
+ ### Vhost files named by full hostname
142
+
143
+ | Today | New |
144
+ | ------------------------------------------------------------------------ | -------------------------------------- |
145
+ | `lamalibre-lamaste-app-<subdomain>` | `lamalibre-lamaste-app-<fqdn>` |
146
+ | `lamalibre-lamaste-agent-panel-<sub>` | `lamalibre-lamaste-agent-panel-<fqdn>` |
147
+ | `lamalibre-lamaste-auth` | `lamalibre-lamaste-auth-<domain>` |
148
+ | `lamalibre-lamaste-site-<uuid>`, `-panel-domain`, `-tunnel`, `-panel-ip` | unchanged |
149
+
150
+ - `lamaste-priv` `PANEL_SITE_RE` gains `app-<host>`, `agent-panel-<host>`,
151
+ `auth-<host>` with `HOST` = the lower-case hostname rule it already applies
152
+ to `server_name`, and keeps accepting the legacy names for `remove`/`disable`
153
+ only (`LEGACY_SITE_RE`). The `lamaste-priv` test checks both.
154
+ - **Migration** at serverd start, in `vhost-reconcile.js`, before the panel
155
+ listens: for each tunnel and the auth vhost, if the legacy file exists and
156
+ the new one does not, write the new file with the same link state, remove
157
+ the legacy file and link, then one `nginx -t` + reload; a failing `nginx -t`
158
+ restores the legacy files. Idempotent; logged as one line per file.
159
+ - Ordering: create-lamaste's redeploy installs `lamaste-priv` before serverd
160
+ starts, so the relay update (Panel Update = redeploy) brings the new
161
+ allow-list first. A serverd that finds a `lamaste-priv` without the new
162
+ names (refused write) keeps the legacy names and logs an error.
163
+ - The auth vhost's http-level `limit_req_zone user_access_auth` stays only in
164
+ the **primary** auth vhost, with `/api/user-access/authorize`: desktop user
165
+ access is primary-only (the desktop exchanges at `panel.<d>` for the same
166
+ `d`). Added domains' auth vhosts carry `/`, `/invite/` and `/api/invite/`.
167
+
168
+ ## Authelia
169
+
170
+ ### Session cookies
171
+
172
+ One `session.cookies` entry per **active** domain, same `name`
173
+ (`authelia_session` — browsers scope cookies by domain, and every Lamaste
174
+ reader strips or parses by name, `authelia-session.ts`):
175
+
176
+ ```yaml
177
+ session:
178
+ cookies:
179
+ - domain: samplepark.tr
180
+ authelia_url: https://auth.samplepark.tr
181
+ default_redirection_url: https://samplepark.tr
182
+ - domain: codelama.com.tr
183
+ authelia_url: https://auth.codelama.com.tr
184
+ default_redirection_url: https://codelama.com.tr
185
+ ```
186
+
187
+ Core `renderSessionCookies(domains)` (pure); `updateAuthelia…` writes it
188
+ through `rewriteAutheliaConfig`. Authelia picks a cookie domain by suffix of
189
+ the target host (`GetCookieDomainFromTargetURI`) and only redirects to a target
190
+ inside a configured cookie domain (`IsSafeRedirectionTargetURI`), so each
191
+ domain's sign-in sets and reads its own cookie. A developer signs in once per
192
+ domain, with the same password and TOTP. (WebAuthn credentials would be per
193
+ domain — Lamaste uses TOTP only.)
194
+
195
+ ### `access_control`
196
+
197
+ Authelia evaluates rules in order and applies the first that matches, so a
198
+ member gate cannot be a rule placed "before" the per-site rules; it has to be
199
+ **inside** them. `renderAccessControl(domains, members, sites)` (core, pure)
200
+ replaces `updateAccessControl`'s rule building:
201
+
202
+ 1. **Per protected site** (as today), every subject ANDed with membership of
203
+ the site's domain: `allowedUsers ∩ members(domain)` as `user:` subjects;
204
+ the admins clause becomes `[['group:admins', 'user:<m>'] for m in members]`
205
+ (AND in Authelia's subject syntax — an admin who is a member). Then `deny`.
206
+ 2. **Per domain**: `domain: [<d>, '*.<d>']`, `policy: two_factor`,
207
+ `subject: ['user:<m>' for m in active members]`; then
208
+ `domain: [<d>, '*.<d>']`, `policy: deny`. A domain with no members
209
+ renders only the deny.
210
+ 3. `default_policy: deny` (today `two_factor`). Every host the relay gates is
211
+ under a domain; a host under none (a custom site outside every domain)
212
+ cannot hold a session cookie anyway, so this only closes what was
213
+ accidentally open.
214
+
215
+ Members are rendered by **username** (Authelia's subject), active users only
216
+ (a disabled or removed user is absent). Rendering is called from
217
+ `changeUsers` (after the directory settles, before the Authelia restart it
218
+ already does), from every membership change, from site changes (core `sites.ts` calls its
219
+ `renderAccess` dependency with the sites it saves) and at start. One `rewriteAutheliaConfig` per
220
+ change; unchanged output = no restart.
221
+
222
+ Membership is **not** rendered as Authelia groups in `users.yml` (decision
223
+ 8): groups would reach every application in `Remote-Groups` and the OIDC
224
+ `groups` claim (Feria sees groups), and `users.yml` is a file Authelia's
225
+ password reset rewrites — the directory would keep a second copy of
226
+ membership.
227
+
228
+ ### `auth.<domain>` vhost and certificate
229
+
230
+ `renderAuthVhost(domain, { primary })`: the same Authelia proxy as today,
231
+ `server_name auth.<domain>`, certificate `auth.<domain>` (HTTP-01). Written
232
+ when a domain is activated, removed when it is removed.
233
+
234
+ ## Membership in the directory
235
+
236
+ `0016_domains.sql` also creates:
237
+
238
+ ```sql
239
+ CREATE TABLE domain_members (
240
+ domain TEXT NOT NULL REFERENCES domains(name),
241
+ user_id TEXT NOT NULL REFERENCES directory_users(user_id),
242
+ added_at TEXT NOT NULL,
243
+ added_by TEXT NOT NULL, -- actor, as audit rows name it
244
+ seq INTEGER NOT NULL DEFAULT 0,
245
+ PRIMARY KEY (domain, user_id)
246
+ );
247
+ ```
248
+
249
+ - A trigger stamps `seq` from `directory_counter` on insert (as
250
+ `plugin_audience_stamp_insert`), and a delete trigger bumps the counter, so
251
+ membership changes are ordered in the directory's one sequence.
252
+ - Audit: `directory.membership.added` / `directory.membership.removed`
253
+ (`{ domain, userId, reason }` — no username), `domain.added`,
254
+ `domain.activated`, `domain.removed`.
255
+ - Keyed by `user_id`: a re-created username is a new user with no membership.
256
+ - **Offboarding** (`offboardUserUnlocked`): `deleted` removes every
257
+ membership (reason `deleted`); erasure finds none left. **Disable keeps
258
+ memberships** (decision 2) — a disabled user is excluded from every
259
+ rendering at once, and enabling restores exactly what they had.
260
+ - Losing membership of the domain the registry credential names also revokes
261
+ that user's registry tokens (`revokeUserRegistryTokens`), as losing a
262
+ `feria-*` group does.
263
+ - **New users** (decision 1): while the relay has one domain, every creation
264
+ path (users API, invitation accept, onboarding, a user adopted from
265
+ `users.yml` by `reconcileDirectory`) adds membership of it — a single-domain
266
+ relay behaves as today. With two or more domains, the users API and
267
+ invitations name the domains (`domains: [...]`, at least one); a user
268
+ adopted from `users.yml` gets none until an admin adds one.
269
+ - Invitations carry `domains`; accepting adds them in the same `changeUsers`;
270
+ the link is `https://auth.<first domain>/invite/<token>`.
271
+ - **Plugin audiences** (slice 6): a plugin belongs to one domain (below); its
272
+ audience is the entitled users who are members of it. A member leaving
273
+ becomes `remove/unentitled` — no new reason, so 4.5 agent halves need no
274
+ change.
275
+
276
+ ## Gatekeeper
277
+
278
+ Gatekeeper reads a projection the panel writes, `domains.json` (0600
279
+ `lamaste`, atomic, in the data dir, watched like `tunnels.json`):
280
+
281
+ ```json
282
+ { "v": 1, "domains": [{ "name": "codelama.com.tr", "members": ["onur", "…"] }] }
283
+ ```
284
+
285
+ Active domains, active members, usernames (Authelia's answer names a
286
+ username). Written in the same places as the Authelia rendering, before the
287
+ Authelia restart.
288
+
289
+ In `authenticatedSession` (every route: tunnel, user surface, shell):
290
+
291
+ 1. Authelia **403** is no longer "unauthenticated": `validateWithAuthelia`
292
+ returns `{ kind: 'forbidden' }` and the route answers 403 with the
293
+ **domain refusal page** (user-surface API: JSON `{error:'not_a_member'}`).
294
+ 401 stays for no/bad session.
295
+ 2. After Authelia's 200, `domainOfHost(host)` and membership of the
296
+ username are checked against `domains.json`; not a member → the same 403.
297
+ A host under no domain, or a missing/unreadable projection → 403 (fail
298
+ closed).
299
+ 3. Then the access mode, as today (`restricted` → grants).
300
+
301
+ The refusal page (`templates.ts` `buildDomainRefusalPage(domain, options)`)
302
+ says the account is not a member of `<domain>`, names the admin contact from
303
+ Gatekeeper's settings, and links to `https://auth.<domain>/logout` to sign in
304
+ as someone else. It names no username (Authelia's 403 carries none).
305
+ nginx already serves Gatekeeper's 403 body inline on tunnel and web-surface
306
+ vhosts (`error_page 403 = /internal/lamaste/authz`, `…/shell-authz`); static
307
+ Authelia-protected sites, which ask Authelia directly, gain an
308
+ `error_page 403` location that asks Gatekeeper for the page
309
+ (`GET /authz/refusal/:fqdn`, no session needed, nothing but the page).
310
+
311
+ The session cache key already includes the host; membership changes bust
312
+ it (`bustCachedSessions`, as group/grant writes do).
313
+
314
+ ## OIDC: one issuer per domain
315
+
316
+ - Authelia derives the issuer from the request host, so
317
+ `https://auth.codelama.com.tr` is an issuer as soon as its auth vhost
318
+ exists; nothing in the OIDC section names an issuer. Every client is known
319
+ to every issuer (Authelia has no per-host client list): a token for a
320
+ client obtained at the wrong issuer carries that issuer's `iss`, which a
321
+ relying party that trusts one issuer (Feria) refuses.
322
+ - **A plugin belongs to one domain** (decision 5): `plugins.domain`
323
+ (migration 0016, default primary). It determines `{domain}` expansion in its
324
+ OIDC clients' `audience`/`redirectUris`, which tunnels may be bound as its
325
+ user surface, its web surface's domain, and its audience. Changing it is
326
+ refused while a surface is bound.
327
+ - **Membership for OIDC**: Authelia's `access_control` does not apply to OIDC
328
+ authorization; a client's `authorization_policy` does. For each domain with
329
+ user-facing clients, render `identity_providers.oidc.authorization_policies`
330
+ `lamaste-<n>-one_factor` / `-two_factor` (`default_policy: deny`, one rule
331
+ with the factor and the members as `user:` subjects), and point each
332
+ user-facing client at its domain's policy of its declared factor. Pure
333
+ `client_credentials` clients (no user) keep `one_factor` (Authelia 4.39
334
+ refuses anything else). `feria login` therefore admits only members of
335
+ Feria's domain.
336
+ - **Registry credential**: `registry-credential.json` gains `issuerDomain`
337
+ (absent = primary, written on the next save); `oidcDeps` uses it for
338
+ `X-Forwarded-Host`, `feriaRulesHint` names it, and `PUT` takes it
339
+ (`--issuer-domain` in the CLI). The owner Feria records becomes
340
+ `ci:auth.<issuerDomain>/lamaste-server`.
341
+ - **Feria move (§14.9)**, once released: install/move Feria's plugin domain to
342
+ `codelama.com.tr`, delete/re-create its tunnel there, update Feria's
343
+ `auth.json` (issuer `https://auth.codelama.com.tr`, audience
344
+ `https://feria.codelama.com.tr`, workloads rule), re-register the
345
+ credential with `issuerDomain: codelama.com.tr`. Lamaste needs nothing more.
346
+
347
+ ## Domain lifecycle (panel API)
348
+
349
+ All admin-only (mTLS), new reserved API prefix `domains`:
350
+
351
+ | Route | Does |
352
+ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
353
+ | `GET /api/domains` | domains with status, member count, what uses each (tunnels, sites, plugins) |
354
+ | `POST /api/domains {name}` | adds `pending` (validation, no nesting) |
355
+ | `POST /api/domains/:name/verify-dns` | `auth.<d>` resolves to the relay IP; reports whether a wildcard probe (`test-lamaste-check.<d>`) does |
356
+ | `POST /api/domains/:name/activate` | issue `auth.<d>`, write its vhost, add the session cookie, render `access_control`/OIDC policies and `domains.json`, restart Authelia — one operation with rollback |
357
+ | `DELETE /api/domains/:name` | refused for the primary and while any tunnel, site or plugin uses it (409 `domain_in_use`); removes memberships (reason `domain-removed`), cookie, vhost |
358
+ | `GET /api/domains/:name/members` | `[{ userId, username, status, addedAt }]` |
359
+ | `PUT` / `DELETE /api/domains/:name/members/:user` (`userId` or username) | add / remove one member |
360
+
361
+ Plus: `domain` on `POST /api/tunnels`, `POST /api/sites` (managed),
362
+ `POST /api/plugins/:name/web-surface`; `PUT /api/plugins/:name/domain`;
363
+ `domains` on `POST /api/users` and invitations; `GET /api/users` adds each
364
+ user's `domains`.
365
+
366
+ ## Panel UI, desktop, CLI
367
+
368
+ - **Panel**: a Domains page (add, DNS check with the records to create, activate,
369
+ remove, members); a domain picker on tunnel/site/web-surface creation
370
+ (hidden while there is one domain); domain chips and an editor on the
371
+ Users page; domains on invitations; the plugin card shows and changes the
372
+ plugin's domain; hostnames everywhere from stored `fqdn`.
373
+ - **AdminClient** (`core/lib/src/types.ts`): `getDomains`, `addDomain`,
374
+ `verifyDomainDns`, `activateDomain`, `removeDomain`, `getDomainMembers`,
375
+ `addDomainMember`, `removeDomainMember`, `setPluginDomain`; the web client
376
+ (`web-admin-client.js`) and the desktop client (`desktop-admin-client.js`,
377
+ `admin_*` commands in `admin_commands.rs`) implement them — enforced at
378
+ build time as today.
379
+ - **CLI** `lamaste-server`: `domains list|add|verify-dns|activate|remove`,
380
+ `domains members <d> [add|remove <userId|username>]`, `--domain` on
381
+ `tunnels create`, `sites create`, `plugins web-surface create`,
382
+ `plugins domain <name> [<d>]`, `users create --domains`; `status` lists the
383
+ domains. `lamaste-agent status` prints `fqdn`.
384
+
385
+ ## Upgrade in place
386
+
387
+ A single-domain relay on 4.6 upgrades with no change in behaviour:
388
+
389
+ 1. Migration 0016: the primary domain row; **every non-removed directory user
390
+ becomes a member** (`added_by: 'migration'`); every plugin's domain = primary.
391
+ 2. Start-up: vhosts renamed (above); Authelia rendering gains the
392
+ one-domain rule (members = everyone) and `default_policy: deny`;
393
+ `domains.json` written. Every host was reachable by every Authelia user and
394
+ still is; nothing else changes.
395
+ 3. The registry credential keeps working (absent `issuerDomain` = primary).
396
+ 4. Agents: nothing (ports and `tunnel.<primary>` unchanged). Old agents
397
+ ignore the new `fqdn` field in `agent-config`.
398
+
399
+ ## Security notes
400
+
401
+ - The member gate is enforced twice, from one source (`state.db`): Authelia
402
+ (rendered allow-lists, deny last, default deny) and Gatekeeper (projection).
403
+ Either alone keeps a non-member out of tunnels and user/web surfaces;
404
+ static sites rely on Authelia; OIDC on the client's authorization policy.
405
+ - Cross-domain cookies: domains never nest and never share a registrable
406
+ domain (see Security review, S7), so a session cookie of one domain is
407
+ never sent to another's hosts, and no application of one can set one for
408
+ another; the backend cookie map strips `authelia_session` by name on every
409
+ domain.
410
+ - Same-site isolation between applications on one domain is unchanged
411
+ (slice 7's deferred note: a customer's shells on its own registrable domain
412
+ is now possible).
413
+ - Logs and audits name domains and `userId`s, never usernames.
414
+
415
+ ## Security review (2026-10-25)
416
+
417
+ Three independent reviews (code, threat model, access-decision correctness)
418
+ of `a7ea95c..037c92c`. Each confirmed finding was fixed with a unit test that
419
+ fails without the fix.
420
+
421
+ | # | Finding | Fix | Test |
422
+ | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
423
+ | S1 | A protected static site under no active domain (possible from 4.6) was rewritten **public** on its next edit or DNS check: `renderStaticSiteVhost` emitted no `auth_request` with a null domain. | Such a site renders closed (`location / { return 403; }`); `updateSite` refuses (`NOT_UNDER_DOMAIN`) whenever the result is protected, not only on false→true; `verifyDns` refuses before resolving. Start-up logs and notifies the admins of such sites (`result.closed`). | multi-domain-core (update/verify refused, unprotect allowed), vhost-reconcile (closed render, notice) |
424
+ | S2 | An OIDC client could mix `client_credentials` with user grants; Authelia gives such a client `one_factor`, so any account of any domain could authorize it. | `OidcClientSchema` refuses `client_credentials` beside any other grant. | plugin-oidc (device code, authorization code, refresh token) |
425
+ | S3 | Registry tokens were revoked only for the credential's issuer domain, while `feria login` admits the Feria plugin's domain. | The registry's domains are defined once (`registryDomainsOf`: the issuer domain and the domain the registry's own host is under). The end of a membership revokes a user's registry tokens when they are a member of none of them; storing a credential whose domains changed (`setRegistryCredential`, under the domains lock) revokes those of every user outside the new ones — config read once, at most 4 revocations in flight. A domain the registry uses cannot be removed, so removal revokes nothing. | domains (stand-in Feria) |
426
+ | S4 | A projection write failure skipped Authelia; an Authelia failure left older, more permissive members; both only warnings. | Both steps always tried; the first failure of a streak is audited (`domain.render.failed`, steps only) and notified (critical), its end audited (`domain.render.recovered`). | domains (injected failures of each step) |
427
+ | S5 | The auth vhost passed the client's `X-Forwarded-Host`/`-Proto` to Authelia (issuer and cookie domain). | `location /` sets `Host`, `X-Forwarded-Host` (the portal's literal name) and `X-Forwarded-Proto https`. | nginx-vhosts |
428
+ | S6 | Races: a tunnel, site, plugin move or registry credential could land on a domain being removed (every later OIDC sync then threw); a custom site could race an add or activation for `auth.<new>`. | One section changes the set of domains (`changeDomains`: tunnel → sites → domains locks) for add, activation's re-check and status flip, and removal's usage check and deletion — certificate issuance, audits and nginx stay outside, the auth vhost of a removed domain goes under the domains lock after re-checking the name is still absent. Tunnels re-check through `assertDomainActive` under the tunnel lock, sites resolve under the hostname locks, `setPluginDomain` under them, the credential under `withActiveDomain` — one error, 400 `unknown_domain`. | multi-domain-core (tunnel refused), domains (add and removal wait for the tunnel lock; activation re-checks after the certificate; `unknown_domain`) |
429
+ | S7 | Sibling domains under one registrable domain (`cust1.example.net` + `cust2.example.net`) and public suffixes were accepted: cookie tossing, login CSRF, one `SameSite` site. | Core server `assertDomainRegistrable` (Public Suffix List, private section included): refuses a public suffix (`public_suffix`) and a domain sharing an existing one's registrable domain (`same_registrable_domain`). The rodeo test domains became `lamaste-second.local` etc. | multi-domain-core, domains |
430
+ | S8 | An interrupted vhost rename left the legacy file linked forever; a same-subdomain tunnel of another domain could take a primary's legacy file. | `renameVhosts` finishes a cut-short migration (the new file takes the old link, the old file goes); renames only for primary-domain tunnels and only when the old file's `server_name` is the tunnel's; file operations injectable. | vhost-reconcile (finished, foreign file left, nginx refusal restores) |
431
+ | S9 | Gatekeeper reloaded `domains.json` only on watch events; the panel never rewrites an unchanged file. | `POST /api/cache/bust` reloads it before busting; a 60 s timer reloads it too. | gatekeeper cache-bust-domains |
432
+ | S10 | The relay hostnames of a pending (or not yet added) domain were not protected; a custom site could take `auth.<d>` and collide at activation. | Sites check relay hosts against every domain record (`relayDomains`); `addDomain` and `activateDomain` refuse (`relay_host_taken`) while a tunnel, site or alias holds one. | multi-domain-core, domains |
433
+ | S11 | Removing a domain left pending invitations naming it (re-added later, for someone else, the invitation would grant it). | Decided at acceptance (`invitationDomainsNow`): an invitation grants only the domains it names that exist now and existed when it was created; nothing chases invitations on removal. Links use the first **active** domain. | domains (removed, re-added, granted nothing) |
434
+ | S12 | The refusal page for a host under no domain linked to `https://auth.<host>/logout` — possibly someone else's host. | No domain → the page names the host and links to no sign-in. | gatekeeper authz-domain-membership |
435
+ | S13 | A plugin move whose OIDC sync failed was only logged; the old domain's members stayed admitted. | The identity tick runs `syncOidcClients` every minute (compare-first); the move answers a warning. | domains (the tick writes the OIDC provider) |
436
+ | S14 | `.`/`..` path segments from the desktop, CLI and web client were resolved by curl/fetch (`/api/domains/../activate`). | Each client refuses a dot segment where every request passes — `buildPanelCurlRequest` (CLI), `apiFetch` (web); the desktop percent-encodes it in `url_encode`. | CLI panel-api, UI domains, cargo |
437
+ | S15 | `userSubject`'s error carried the refused value. | The message names no value. | multi-domain-core |
438
+
439
+ **After review of the fixes** (`/simplify`): the identity tick's OIDC sync
440
+ (S13) no longer runs PBKDF2 every minute — client-secret digests are
441
+ memoized per salt and secret (keyed by a SHA-256, never the secret) and
442
+ computed with async `crypto.pbkdf2`; an unreadable Authelia configuration
443
+ fails the sync (logged once) instead of looking "changed" every tick. Test:
444
+ multi-domain-core (a steady reconcile computes no digest).
445
+
446
+ **S7, agreed by the user 2026-10-25:** stricter than "domains never nest" —
447
+ domains must also have different registrable domains, and a public suffix
448
+ is never a domain. `codelama.com.tr` and `samplepark.tr` are unaffected; two
449
+ customers given `a.example.net` and `b.example.net` by the same owner cannot
450
+ both be on one relay. The check uses `tldts` 7.4.18 (MIT; its one dependency
451
+ `tldts-core` is MIT, no further dependencies), pinned exactly in
452
+ `@lamalibre/lamaste@4.7.0` and kept out of the browser-safe
453
+ `@lamalibre/lamaste@4.7.0/domains` (server path only:
454
+ `server/domain-registrable.ts`).
455
+
456
+ **Residual, documented in `01-concepts/security-model.md`:**
457
+
458
+ - **R1 — resolved, the user's decision of 2026-10-25.** Authelia does not (as
459
+ far as known) re-check `authorization_policy` on `refresh_token` grants, so
460
+ plugin clients get no refresh tokens: `OidcClientSchema` refuses the
461
+ `refresh_token` grant and the `offline` / `offline_access` scopes at install
462
+ and update; an installed plugin's manifest is read with core
463
+ `validateInstalledManifest` (its clients without them; a client left with no
464
+ grant goes) by the OIDC sync and by an update's revert
465
+ (`replacePluginPackage … { restoring: true }`), so a plugin installed before
466
+ keeps working. `renderOidcClient` throws on a refresh grant that got through.
467
+ A removed member keeps at most the access tokens already issued, until they
468
+ expire. Tests: plugin-oidc, agent-hosted-plugins, agent-half-staging.
469
+ - **R2.** Authelia's session store is shared across cookie domains; isolation
470
+ is browser cookie scoping plus membership by username.
471
+ - **R3.** Every membership change restarts Authelia and ends every session on
472
+ every domain (availability).
473
+
474
+ ## Proof (rodeo)
475
+
476
+ `setup-host.sh`'s dnsmasq gains `address=/<domain>/<host ip>` for
477
+ `TEST_DOMAIN_2` and `TEST_DOMAIN_3`; `setup-visitor.sh` adds `auth.` and the
478
+ test hostnames of each to `/etc/hosts`; the certbot shim already signs any
479
+ name. Specs (written after slice 7's campaign, run only with the user's go;
480
+ what wave 4 wrote, with two splits — 48 and single-VM 35 — is under Waves):
481
+
482
+ - **three-VM 45 — developer on two domains**: domain B added and activated;
483
+ user `dev` member of A and B: signs in at `auth.A`, reaches an
484
+ `authenticated` tunnel on A; on B is sent to `auth.B`, signs in with the same
485
+ password and TOTP, reaches B's tunnel; a `restricted` tunnel on B without a
486
+ grant → access-request page; with a grant → through.
487
+ - **three-VM 46 — customer on one domain**: user `cust` member of B only:
488
+ B's tunnel works; A's `authenticated` tunnel, A's static site, A's user
489
+ surface → domain refusal page (403, no login loop); `feria`-style OIDC
490
+ device login for A's client refused, for B's admitted; removing `cust`
491
+ from B → refused within one render; deleting `cust` removes every
492
+ membership (audit).
493
+ - **three-VM 47 — a third domain added**: snapshot of A's and B's
494
+ `access_control` rules, `domains.json` members and reachability matrix;
495
+ add and activate C with a new member; A and B unchanged (rules byte-equal
496
+ apart from C's own, every earlier request answers as before).
497
+ - **single-VM 34 — upgrade and renames**: legacy-named vhosts planted, serverd
498
+ restarted: renamed with link state kept; one domain, all users members,
499
+ every host answers as before; `lamaste-priv` refuses a dotted name it does
500
+ not know and the legacy names for `write`.
501
+
502
+ ## Waves
503
+
504
+ 1. **Core** — done: `domains.ts` (`assertDomainName`, `domainsOverlap`,
505
+ `assertDomainAddable`, `domainOfHost`, `isReservedLabel`, `isRelayHost`),
506
+ `domains` reserved API prefix, the pure renderers `renderSessionCookies`,
507
+ `renderAccessControl`, `renderOidcAuthorizationPolicies`
508
+ (`OIDC_USER_FACTORS` shared with the manifest schema), tunnel and site
509
+ uniqueness by stored `fqdn`, one reserved-label rule for tunnels, sites
510
+ and web surfaces, `issuerDomain` on the registry credential
511
+ (`registryIssuer`); tests in serverd `multi-domain-core.test.js`. The
512
+ renderers are wired, and `updateAccessControl`'s own site loop removed,
513
+ in wave 2 together with membership — wiring them alone would deny every
514
+ user. `NginxDeps` by `fqdn` moves to wave 2 with `nginx.js`.
515
+ 2. **serverd + Gatekeeper + lamaste-priv** — done. state.db `0016_domains.sql`
516
+ (`domains`, `domain_members` with seq triggers, `plugins.domain`);
517
+ `lib/domains.js` (records, `ensurePrimaryDomain`, membership,
518
+ lifecycle with rollback, `syncDomainAccess` rendering Authelia and the
519
+ `domains.json` projection), `lib/plugin-domain.js`, routes
520
+ `routes/management/domains.js`; the directory gives new users their
521
+ domains and ends a removed user's memberships in its transaction;
522
+ `changeUsers` renders before Authelia restarts; audiences are entitled ∩
523
+ members; OIDC clients carry their plugin's domain; vhosts by hostname
524
+ with `renameLegacyVhosts` at start; per-domain auth vhosts; Gatekeeper
525
+ (`authz.ts`: Authelia 403 kept, projection check, `buildDomainRefusalPage`,
526
+ `/authz/refusal/:fqdn`); `lamaste-priv` names. Tests: serverd
527
+ `domains.test.js`, `multi-domain-core.test.js`, Gatekeeper
528
+ `authz-domain-membership.test.js`, and the existing suites updated.
529
+
530
+ **Deviations, for review:**
531
+ - OIDC authorization policy names are `lamaste_<domain with . as _>_<factor>`,
532
+ not `lamaste:<domain>:<factor>`: Authelia's configuration loader
533
+ (koanf) splits key paths at dots. Domain labels hold no `_`, so the
534
+ names stay unambiguous.
535
+ - A web surface is created on its plugin's domain (decision 5); its
536
+ create request takes no `domain` (one would only have to equal it).
537
+ Moving the plugin (`PUT /api/plugins/:name/domain`) is refused while a
538
+ tunnel is bound as its user surface or it owns a web surface
539
+ (409 `surface_bound`).
540
+ - `plugins.domain` NULL means the primary; nothing is rewritten at the
541
+ upgrade. A plugin is installed on the primary and moved afterwards.
542
+ - Sign-in on a hostname under no domain is refused when a site is
543
+ created or protected (`NOT_UNDER_DOMAIN`, 400): no session cookie
544
+ reaches it. Such a site's rules render a deny alone.
545
+ - A static site's refusal page (`/authz/refusal/:fqdn`) cannot tell a
546
+ non-member from a user the site's own list leaves out (Authelia's 403
547
+ says neither): it says both.
548
+ - Members may be added to a pending domain, so an activation goes live
549
+ with them; pending domains are rendered nowhere.
550
+ - `changeUsers` skips its own Authelia restart when the domain rendering
551
+ restarted it already (one restart per change).
552
+ - An agent with `tunnels:write` may create (restricted) tunnels on any
553
+ active domain, as on the primary before.
554
+ - `renameVhosts` (nginx.js) is unit-tested through its caller only —
555
+ nginx.js reads `/etc/nginx`; the rename itself is proven by single-VM
556
+ spec 34.
557
+
558
+ **After review (`/simplify`):** one writer of what members may do —
559
+ `withDomainRendering` in `lib/domains.js` holds the lock under which
560
+ Authelia's domain access, the projection and the OIDC provider are
561
+ written, each reading the members inside it (before, the OIDC provider
562
+ could write back policies from an older read; `domains.test.js` proves
563
+ the order). `authenticated` and `restricted` tunnels share one vhost
564
+ writer (`writeGatekeeperVhost`): their vhosts are identical, Gatekeeper
565
+ decides by the access mode. A removed domain's `auth.` vhost goes with a
566
+ test and reload. Shared helpers: core `labelUnder`, `hostnameHolder`,
567
+ `DomainNameSchema`, `resolveHostA`, `registryIssuerDomain`,
568
+ `atomicWriteJSONIfChanged`, `indexDomainsProjection` (Gatekeeper indexes
569
+ the projection once); daemon `sendFailure` (lib/app-error.js),
570
+ `domainOfHostname`, `invitationSignInDomain`, `domainsOverview`.
571
+ `domainsForNewUser(…, { required })` answers `domains_required`.
572
+ Onboarding writes Authelia's first configuration with the administrator
573
+ as the primary's member, so Authelia starts once. The dead
574
+ `syncAllAccessControl` is gone; sites' `AutheliaDeps.updateAccessControl`
575
+ is `renderAccess`.
576
+
577
+ 3. **UI, desktop, CLI, docs** — done. `AdminClient` gains `getDomains`,
578
+ `addDomain`, `verifyDomainDns`, `activateDomain`, `removeDomain`,
579
+ `getDomainMembers`, `addDomainMember`, `removeDomainMember`,
580
+ `setPluginDomain`, `domain` on tunnel/site creation, `domains` on users
581
+ and invitations (`AdminUser.domains`); web client, desktop client and
582
+ nine `admin_*` Tauri commands (domain names and user ids checked before
583
+ curl; `admin_create_user`/`admin_create_invitation` now pass the panel's
584
+ refusal through so `domains_required` reaches the form). Panel: a Domains
585
+ page (web `/domains`, desktop server section) with DNS records to create,
586
+ activation, members and removal showing `usedBy`; `DomainPicker` on
587
+ tunnel and managed-site creation and `DomainMembershipChoice` on user
588
+ creation, editing and invitations — both hidden on a one-domain relay;
589
+ domain chips on Users; `PluginDomain` on the plugin card; tunnels listed
590
+ by `fqdn`. UI helpers `admin/lib/domains.js` with tests; the UI's
591
+ reserved-label copy is kept equal to core's by a test (the browser bundle
592
+ does not carry the core library; `@lamalibre/lamaste@4.7.0` is a dev dependency
593
+ of the UI for that test). CLI `lamaste-server domains
594
+ list|add|verify-dns|activate|remove|members`, `plugins domain`,
595
+ `--domain` on `tunnels create`/`sites create`, `registry-credential set
596
+ --issuer-domain`, domains in `status`. Docs: `04-api-reference/domains.md`,
597
+ `02-guides/second-domain.md`, and the multi-domain sections of users,
598
+ tunnels, sites, registry-and-oidc, gatekeeper, overview, dns-and-domains,
599
+ authentication, authorization, security-model (the shared blast radius)
600
+ and nginx-configuration; CHANGELOG; CLAUDE.md.
601
+
602
+ **After review (`/simplify`):** the rules are answered by the panel and
603
+ core, never re-derived by a client — the UI imports reserved names and
604
+ ports from the browser-safe `@lamalibre/lamaste@4.7.0/domains` subpath (a real
605
+ dependency; no copy, no sync test); the desktop passes names through
606
+ (`url_encode`) and lets the panel refuse; `GET /api/domains` answers
607
+ `newUsersNeedDomains`; `domainsForNewUser` refuses an explicit empty list
608
+ (new users on a multi-domain relay start with no domain chosen);
609
+ `PUT /api/users/:username` takes `domains` and `setUserDomains` applies
610
+ the difference in one transaction (the UI sends one request); `verify-dns`
611
+ returns the records with their purpose; plugin views carry the resolved
612
+ domain name (`withPluginDomains`; `plugins.domain` NULL stays the stored
613
+ form); members are named by id or username in the members path. UI: one
614
+ `useMembershipChoice` hook, `DomainPicker` owns '' = primary, the shared
615
+ `Dialog.jsx` confirm instead of `window.confirm`, narrow invalidation.
616
+ CLI: one `call`, a table of domain subcommands, a shared `fail`,
617
+ `status` asks the panel in parallel with a short timeout and only while
618
+ it runs. Web surfaces take their plugin's domain (wave 2's deviation), so
619
+ no picker is offered for them.
620
+
621
+ **Deviation:** the CLI has no `users create` (users are created in the
622
+ panel or by invitation), so there is no `users create --domains`; domains
623
+ of a new user are set in the panel or with `domains members … add` after
624
+ it is created.
625
+
626
+ 4. **Rodeo specs** — written in wave 4, run on fresh VMs on 2026-10-25 (see
627
+ _Rodeo: run, results_ below). In
628
+ `packages/tools/lamaste-rodeo`:
629
+ - **Infrastructure.** B and C are `test_domain_n <primary> second|third`
630
+ (`tests/e2e/test-domains.sh`: a registrable domain of its own —
631
+ `test.lamaste.local` → `lamaste-second.local`, `lamaste-third.local`;
632
+ siblings such as `second.lamaste.local` are refused since the security
633
+ review, S7),
634
+ the one rule, sourced by `setup-host.sh` (both suites: dnsmasq answers
635
+ the primary and the second, the three-VM host the third too),
636
+ `setup-visitor.sh` (`auth.` of each in `/etc/hosts` and the cloud-init
637
+ template) and `helpers.sh`, and shipped to the VMs as a tier asset. No
638
+ tier or test variable: the tier runner injects only `HOST_IP` and
639
+ `TEST_DOMAIN`. The specs make sure of the DNS again (`md_dns`,
640
+ `local_domain_dns`) — a VM restored from an older snapshot predates it.
641
+ Shared helpers: `tests/e2e/helpers.sh` (`vhost_name` /
642
+ `vhost_available` / `vhost_linked`, `AUTHELIA_CONFIG_JSON` and
643
+ `AUTHELIA_RULES_UNDER`, `start_stub_registry` — now used by 26, 27, 28 and
644
+ 35 — and the single-VM `local_sign_in`, `local_reset_totp`,
645
+ `local_wait_authelia`, `local_domain_dns`) and
646
+ `tests/e2e-three-vm/multi-domain-harness.sh` (on top of
647
+ `app-plugin-harness.sh`, whose preflight is split into `relay_preflight`
648
+ and the app-plugin checks, whose `host_api_call` takes `API_TIMEOUT`, and
649
+ whose `sign_in` takes the sign-in page's domain). A user signs in again
650
+ within the same 30 s TOTP step only after waiting for the next one
651
+ (tracked per user); a fresh secret needs no wait. Membership is read
652
+ from `GET /api/domains/:name/members`; Gatekeeper's projection only where
653
+ its own check is the point.
654
+ - **three-VM 45 — a developer on two domains**: B added, DNS checked
655
+ (`auth.B` the one required record), activated, two session cookies; the
656
+ developer in both domains' rules and projections; signed in at `auth.A`,
657
+ A's tunnel; B's tunnel sends them to `auth.B` (with A's session too);
658
+ signed in there with the same password and TOTP secret, B's tunnel; a
659
+ restricted tunnel on B: the access-request page, then through with a
660
+ grant.
661
+ - **three-VM 46 — a customer on one domain**: members of B only reach B;
662
+ signed in at `auth.A`, A's authenticated tunnel and A's
663
+ Authelia-protected static site answer the domain refusal page (403, no
664
+ `Location`); Gatekeeper's own check — the projection held without the
665
+ customer (immutable, `chattr +i`, so the panel's once-a-minute render
666
+ cannot undo it) refuses them while Authelia still admits them; no
667
+ projection (a directory in its place: the panel's rename fails,
668
+ Gatekeeper reads none) refuses everyone; restored, they are let in;
669
+ every wait is `AUTHZ_SETTLE_SECONDS` (nginx keeps an authorization's
670
+ 200 for 10 s); removed from B and signed in at `auth.B` again (the
671
+ removal restarted Authelia), the refusal page — audited by id; deleted,
672
+ every membership ends, audited `directory.membership.removed` reason
673
+ `deleted`, by directory id only.
674
+ - **three-VM 47 — a third domain**: snapshot of A's and B's rendered
675
+ rules, their member lists and a reachability matrix (one visitor call);
676
+ C added and activated with a member and a tunnel; A's and B's rules
677
+ byte-equal, their member lists unchanged, every request of the matrix
678
+ answers as before (signed in again — C's activation restarted
679
+ Authelia); C's member reaches C only; A's session is sent to `auth.C`.
680
+ - **three-VM 48 — a plugin's user surface** (split from 46: installing and
681
+ hosting an app plugin alone takes minutes, and rodeo ends a spec at ten):
682
+ a member of A with a role reaches the agent half; a customer of B,
683
+ signed in at `auth.A`, gets 403 JSON `{error: "not_a_member"}`, no
684
+ redirect; the plugin cannot move to B while served on A (409
685
+ `surface_bound`).
686
+ - **single-VM 34 — upgrade and renames**: an enabled and a disabled
687
+ tunnel and the auth portal put back under their 4.6 names; the panel
688
+ restarted renames them with link state kept, no legacy name left,
689
+ `nginx -t` passes, every host answers as before; one domain, the
690
+ primary, every user its member, `newUsersNeedDomains` false;
691
+ `lamaste-priv` refuses `write` for a 4.6 name and for an unknown dotted
692
+ name — for the name (`site name not allowed for write`), as three-VM 01
693
+ now asserts its content refusal names the directive. This is the nginx side of the rename that has no unit test.
694
+ - **single-VM 35 — OIDC sign-in by membership** (moved from 46: the
695
+ stand-in registry and fixture-plugin setup of spec 26 is single-VM; an
696
+ authorization-code client, not device code, because Authelia's
697
+ authorization endpoint answers a refused subject at once with
698
+ `error=access_denied` to the redirect URI, where a device-code flow
699
+ needs a browser to approve): the client belongs to A and points at A's
700
+ two-factor policy; signed in at `auth.A`, the developer's authorization
701
+ request goes on, the customer's is refused; the plugin moved to B, the
702
+ client points at B's policy and the customer's request at `auth.B` goes
703
+ on; `auth.A` and `auth.B` are issuers of their own.
704
+ - **Existing specs updated** for the vhost names of wave 2 (by full
705
+ hostname): single-VM 04 and 19, three-VM 01 (lamaste-priv's content
706
+ check now under a valid name — a 4.6 name is refused before the
707
+ content is read), 03, 15, 22, 32 and 43.
708
+
709
+ ### Rodeo: run, results (2026-10-25)
710
+
711
+ Run on fresh Multipass VMs (development profile), Lamaste installed from
712
+ this branch: create-lamaste vendors serverd, the CLI, the UI and Gatekeeper
713
+ from the worktree; the core library came from the dev Feria (this branch's
714
+ `@lamalibre/lamaste@4.7.0` published there as 4.6.0 for the run, removed
715
+ afterwards).
716
+
717
+ | Suite | Specs | Result |
718
+ | --------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
719
+ | single-VM | 01, 03 (dependencies), 04, 19, 34, 35 | all pass (no skip) |
720
+ | three-VM | 01, 03, 15, 22, 32, 43, 45, 46, 47, 48; regression 40 | all pass after the fixes below (no skip); after the /simplify rework a second campaign on fresh VMs ran 01, 40, 43, 45, 46, 47, 48 (40 re-run after the credential-candidate fix); 40 (121 checks) and 43 (50) re-run on `8109547`, the OIDC sync planned and written under the one writer |
721
+
722
+ Fixed on the way:
723
+
724
+ - **Product — a user surface's refusal was nginx's page, not JSON.** Under
725
+ `auth_request` nginx keeps only the subrequest's status and headers, never
726
+ its body, so Gatekeeper's `{ error: 'not_a_member' }` never reached an
727
+ application (spec 48). Gatekeeper now names the refusal in
728
+ `X-Lamalibre-Refusal` (`not_a_member`, else `forbidden`) on the one
729
+ decision it makes; the user-surface locations capture it
730
+ (`auth_request_set $lamaste_refusal …`) and send a 403 to
731
+ `/internal/lamaste/user-surface-403`, which answers one of two fixed JSON
732
+ bodies (`if ($lamaste_refusal = not_a_member)`, else `forbidden`) —
733
+ Gatekeeper is never asked twice. `lamaste-priv` allows `default_type` (a
734
+ media type) in a location for it. Tests: Gatekeeper authz-user-surface,
735
+ serverd nginx-vhosts, lamaste-priv.
736
+ - **Product — Authelia was talked to before it listened.** `reloadAuthelia`
737
+ and `startAuthelia` slept 2 s and asked systemd whether Authelia was active
738
+ (it is as soon as the process is spawned). Setting the registry credential
739
+ to OIDC restarts Authelia to add the panel's client and then asks its
740
+ token endpoint: "fetch failed" (spec 43, on its second run — a race the
741
+ relay's frequent restarts made common). Both now wait for core
742
+ `autheliaReady` — `GET http://127.0.0.1:9091/api/health` answering 200,
743
+ 30 s at most, ended at once when systemd reports the unit failed; a
744
+ rollback's restart waits 10 s at most, so a configuration that stays broken
745
+ does not hold the domains' rendering lock for a second full wait. The other
746
+ places that started Authelia and paused wait the same way: the Services
747
+ page's start/restart/reload (serverd `services.js`), a redeploy
748
+ (`create-lamaste`), and the rodeo reset between tests (`rodeo.hooks.js`,
749
+ `run-all.sh`, polling the health endpoint). Test: serverd authelia-ready.
750
+ - **Product — the minute's OIDC sync undid a registry credential being
751
+ set.** Setting the credential to OIDC syncs the panel's client into
752
+ Authelia, verifies the chain and only then stores the credential; the
753
+ identity tick's every-minute OIDC sync (security review, S13) read the
754
+ stored credential in between, removed the candidate's client and restarted
755
+ Authelia under the token request ("context canceled"; spec 40 on fresh
756
+ VMs). The PUT now holds its candidate (`withRegistryCandidate` in
757
+ `plugin-oidc.js`, one at a time) across sync, verification and store, and
758
+ every sync serves it. After review: a sync plans, reconciles secrets and
759
+ writes all under the one writer (`withDomainRendering`), as secret
760
+ rotation does — a sync queued behind another could otherwise write a plan
761
+ made before the candidate was set; the route sends its answers directly
762
+ and one trailing sync leaves Authelia as the stored credential says.
763
+ Tests: serverd agent-hosted-plugins, domains (the queued sync).
764
+ - **Spec harness — a TOTP code reused across a step boundary** (spec 46).
765
+ `md_sign_in` recorded the step before signing in; a sign-in begun at a
766
+ step's last second took the next step's code, so a second sign-in in that
767
+ step reused it and Authelia refused it ("already used this code
768
+ recently"). The step is now the one when the sign-in ended, and a user
769
+ waits while the current step is not later than it.
770
+ - **Spec harness — single-VM specs could not find `test-domains.sh`.** The
771
+ VM runner uploaded only the specs and `helpers.sh`. Fixed in rodeo
772
+ (`5b8db11`): a VM-runner suite uploads every `.sh` file beside its specs
773
+ and the suite's helpers file; `helpers.sh` sources `test-domains.sh` from
774
+ beside itself again (the interim `/tmp/e2e` fallback is gone).
775
+ - **Campaign environment — the VMs' MTU.** The orchestrator's uplink was a
776
+ VPN tunnel (MTU 1420) that drops ICMP "fragmentation needed" for the VMs'
777
+ NATed traffic: full-size TLS packets vanished (npm `ECONNRESET`, chisel's
778
+ GitHub download reset). Fixed in rodeo (`dce725c`), below every project:
779
+ when the host's default route has an MTU below Ethernet's, rodeo
780
+ launches each VM with that MTU (a cloud-init netplan drop-in on the
781
+ `default` entry). The interim `node-ready` MTU step is gone from both
782
+ suites.
783
+
784
+ Follow-up (a nit, not done here): one constant for Authelia's loopback base
785
+ URL, shared by `authelia.ts` and `registry-credential.ts`.
786
+
787
+ The three points the specs could not prove without running:
788
+
789
+ - **Authelia completes a sign-in for a host the user is not a member of.**
790
+ It does: a member of B only, signing in at `auth.A` for A's tunnel, passes
791
+ both factors (`OK OK`); the refusal comes afterwards, from Authelia's rules
792
+ and Gatekeeper — the domain refusal page, `403`, no redirect (specs 46,
793
+ 48).
794
+ - **Spec 35's consent redirect and `access_denied`.** A member's
795
+ authorization request at `auth.A` is redirected on to
796
+ `https://auth.<A>/consent/openid/decision` (no error); a non-member's is
797
+ sent back to the client's redirect URI with `error=access_denied`; after the
798
+ plugin moves to B the same non-member's request at `auth.B` goes on, and
799
+ `auth.<A>` / `auth.<B>` each advertise their own issuer.
800
+ - **The nginx side of the vhost rename.** Spec 34 planted the 4.6 names (an
801
+ enabled and a disabled tunnel, the auth portal), restarted the panel: all
802
+ renamed by full hostname with their link state kept, no 4.6 name left as a
803
+ file or link, `nginx -t` passes, every host answers as before; one domain,
804
+ every user its member; `lamaste-priv` refuses `write` for a 4.6 name and for
805
+ an unknown dotted name, each for its name, and leaves no file.
806
+
807
+ ## Decisions (agreed 2026-10-25)
808
+
809
+ The user agreed to every recommendation below; the alternatives are kept for the record.
810
+
811
+ 1. **New users on a one-domain relay** are made members of that domain
812
+ automatically; with two or more domains they must be given domains.
813
+ _Recommended_ — keeps single-domain relays as they are. Alternative:
814
+ always explicit, every user creation names domains.
815
+ 2. **Disable keeps memberships**; delete removes them. _Recommended_ — a
816
+ disabled user is already excluded everywhere; enable restores. Alternative:
817
+ disable removes them (the plan's §14.3 wording), and enable needs them
818
+ re-added.
819
+ 3. **Authelia's `admins` group needs membership too**: admins get no domain
820
+ they are not a member of; inside a domain they keep bypassing `restricted`
821
+ grants. _Recommended_ — explicit everywhere. Alternative: `admins` are
822
+ implicit members of every domain.
823
+ 4. **`default_policy: deny`** in Authelia from the upgrade on.
824
+ _Recommended_ — fail closed. Alternative: keep `two_factor`.
825
+ 5. **A plugin belongs to one domain** (OIDC clients, user and web surfaces,
826
+ audience). _Recommended_. Alternative: per-client domains — no current
827
+ plugin needs it.
828
+ 6. **No tunnel move between domains** in this feature; Feria's move is delete
829
+ - re-create (public tunnel, no grants lost). _Recommended_ — keeps scope;
830
+ a move operation can come later.
831
+ 7. **Domain derived from the hostname** (no stored `domain` field), with
832
+ domains forbidden to nest. _Recommended_.
833
+ 8. **Membership rendered as user subjects in `configuration.yml`**, not as
834
+ Authelia groups in `users.yml`. _Recommended_ (no group leak to
835
+ applications, one copy of membership).
836
+ 9. **Version 4.7.0**, released after 4.6.0. _Recommended_.