@lamalibre/create-lamaste 4.5.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 (178) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +56 -5
  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/02-guides/static-sites.md +4 -0
  30. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  31. package/vendor/docs/03-architecture/installer.md +4 -4
  32. package/vendor/docs/03-architecture/management-flow.md +1 -1
  33. package/vendor/docs/03-architecture/nginx-configuration.md +80 -12
  34. package/vendor/docs/03-architecture/overview.md +5 -5
  35. package/vendor/docs/03-architecture/panel-client.md +6 -6
  36. package/vendor/docs/03-architecture/panel-server.md +2 -2
  37. package/vendor/docs/03-architecture/state-management.md +3 -3
  38. package/vendor/docs/03-architecture/system-overview.md +1 -1
  39. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +116 -37
  40. package/vendor/docs/04-api-reference/app-host.md +3 -3
  41. package/vendor/docs/04-api-reference/domains.md +192 -0
  42. package/vendor/docs/04-api-reference/gatekeeper.md +54 -8
  43. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  44. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  45. package/vendor/docs/04-api-reference/overview.md +9 -0
  46. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  47. package/vendor/docs/04-api-reference/sites.md +9 -0
  48. package/vendor/docs/04-api-reference/tunnels.md +35 -28
  49. package/vendor/docs/04-api-reference/users.md +41 -29
  50. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  51. package/vendor/docs/05-operations/uninstalling.md +1 -1
  52. package/vendor/docs/05-operations/upgrades.md +15 -6
  53. package/vendor/docs/06-reference/config-files.md +19 -15
  54. package/vendor/docs/06-reference/glossary.md +3 -3
  55. package/vendor/docs/06-reference/installer-flags.md +13 -13
  56. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  57. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  58. package/vendor/docs/_index.json +105 -19
  59. package/vendor/docs/external-members-design.md +816 -0
  60. package/vendor/docs/gatekeeper-design.md +8 -8
  61. package/vendor/docs/identity-directory-design.md +3 -3
  62. package/vendor/docs/multi-domain-relay-design.md +836 -0
  63. package/vendor/docs/package.json +1 -1
  64. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  65. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  66. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/index.js +2 -2
  68. package/vendor/gatekeeper/dist/index.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  70. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  72. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  73. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  74. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  76. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  77. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  79. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  81. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  82. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  83. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  84. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  85. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  86. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  88. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  89. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  90. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  91. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  92. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  93. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  94. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  95. package/vendor/gatekeeper/dist/server/index.d.ts +6 -1
  96. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/index.js +56 -11
  98. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  100. package/vendor/gatekeeper/dist/server/routes/authz.js +325 -55
  101. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  102. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  103. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  104. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  105. package/vendor/gatekeeper/package.json +2 -2
  106. package/vendor/server/bin/lamaste-server.js +7 -0
  107. package/vendor/server/package.json +2 -2
  108. package/vendor/server/src/commands/domains.js +214 -0
  109. package/vendor/server/src/commands/plugin-identity.js +2 -16
  110. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  111. package/vendor/server/src/commands/plugins.js +175 -14
  112. package/vendor/server/src/commands/registry-credential.js +10 -3
  113. package/vendor/server/src/commands/sites.js +18 -1
  114. package/vendor/server/src/commands/status.js +19 -0
  115. package/vendor/server/src/commands/tunnels.js +4 -1
  116. package/vendor/server/src/commands/users.js +8 -19
  117. package/vendor/server/src/domains.js +92 -0
  118. package/vendor/server/src/fail.js +21 -0
  119. package/vendor/server/src/oidc-client-grants.js +63 -0
  120. package/vendor/server/src/panel-api.js +15 -0
  121. package/vendor/server/src/plugin-approval.js +80 -1
  122. package/vendor/server/src/plugin-web-surface.js +307 -0
  123. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  124. package/vendor/server-ui/dist/assets/index-Cb-ADsbB.css +1 -0
  125. package/vendor/server-ui/dist/index.html +2 -2
  126. package/vendor/serverd/package.json +2 -2
  127. package/vendor/serverd/src/index.js +50 -19
  128. package/vendor/serverd/src/lib/app-error.js +24 -0
  129. package/vendor/serverd/src/lib/audience.js +12 -3
  130. package/vendor/serverd/src/lib/authelia.js +341 -35
  131. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  132. package/vendor/serverd/src/lib/chisel.js +10 -2
  133. package/vendor/serverd/src/lib/directory.js +67 -52
  134. package/vendor/serverd/src/lib/domains.js +1792 -0
  135. package/vendor/serverd/src/lib/gatekeeper-client.js +34 -7
  136. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  137. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  138. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  139. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  140. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  141. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  142. package/vendor/serverd/src/lib/nginx.js +576 -131
  143. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  144. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  145. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  146. package/vendor/serverd/src/lib/plugin-lifecycle.js +129 -5
  147. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  148. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  149. package/vendor/serverd/src/lib/plugins.js +82 -9
  150. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  151. package/vendor/serverd/src/lib/services.js +9 -1
  152. package/vendor/serverd/src/lib/state.js +29 -0
  153. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  154. package/vendor/serverd/src/lib/user-surface.js +77 -31
  155. package/vendor/serverd/src/lib/users-lock.js +23 -0
  156. package/vendor/serverd/src/lib/vhost-reconcile.js +151 -41
  157. package/vendor/serverd/src/lib/web-surface.js +783 -0
  158. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  159. package/vendor/serverd/src/routes/invite.js +15 -5
  160. package/vendor/serverd/src/routes/management/domains.js +154 -0
  161. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  162. package/vendor/serverd/src/routes/management/identity.js +21 -7
  163. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  164. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  165. package/vendor/serverd/src/routes/management/plugins.js +6 -2
  166. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  167. package/vendor/serverd/src/routes/management/sites.js +188 -71
  168. package/vendor/serverd/src/routes/management/tunnels.js +37 -22
  169. package/vendor/serverd/src/routes/management/users.js +184 -64
  170. package/vendor/serverd/src/routes/management.js +4 -0
  171. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  172. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  173. package/vendor/serverd/src/routes/user-access.js +13 -1
  174. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  175. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  176. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  177. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  178. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
@@ -0,0 +1,1792 @@
1
+ /**
2
+ * The relay's base domains and who is a member of each — the multi-domain
3
+ * relay (packages/core/docs/multi-domain-relay-design.md).
4
+ *
5
+ * One domain is the primary: the panel's (`panel.json` `domain`, `panel.`,
6
+ * `tunnel.`, agent panels). Others are added (`pending`), then activated —
7
+ * `auth.<domain>` issued and its vhost written, its session cookie rendered
8
+ * into Authelia — and serve tunnels, sites and plugins of their own. Domains
9
+ * never overlap and never share a registrable domain (core
10
+ * `assertDomainRegistrable`).
11
+ *
12
+ * **Membership** (`domain_members`, state.db 0016) is explicit, by user id:
13
+ * a person reaches a domain only as its member. A membership has a kind
14
+ * (state.db 0017, packages/core/docs/external-members-design.md): a
15
+ * `member` reaches what the domain gives its members, an `external` member
16
+ * only what names them — a grant on a restricted host or an OIDC client, a
17
+ * static site's listed users — and holds no Authelia group
18
+ * ({@link assertNoGroupsForExternal}).
19
+ *
20
+ * Who is a member of what is rendered — active users of active domains
21
+ * only — by {@link syncDomainAccess}: as groups Lamaste owns into
22
+ * users.yml (lib/owned-groups.js; Authelia watches the file and applies
23
+ * them at each user's next request, without a restart), into Gatekeeper's
24
+ * projection (`domains.json`, with the kinds), and as the rules naming
25
+ * those groups into Authelia's configuration (session cookies,
26
+ * `access_control` — core `updateDomainAccess`), which change, and restart
27
+ * Authelia, only with the set of domains and protected sites. It renders
28
+ * after every membership, user, domain, site, tunnel and grant change, and
29
+ * every minute (the identity tick). Everything that writes what members may
30
+ * do — that rendering and the OIDC provider (lib/plugin-oidc.js) — runs
31
+ * inside one lock ({@link withDomainRendering}) and reads the members there,
32
+ * so a membership that ended is never written back from an older read. A
33
+ * disabled user keeps their memberships and is rendered nowhere; a deleted
34
+ * user loses them (lib/directory.js). While the relay has one domain every
35
+ * new user becomes its member (decision 1); with two or more, a new user is
36
+ * given the domains they are created with.
37
+ *
38
+ * Audit entries name domains and user ids, never a username.
39
+ */
40
+
41
+ import crypto from 'node:crypto';
42
+ import path from 'node:path';
43
+ import { z } from 'zod';
44
+ import {
45
+ DNS_WILDCARD_PROBE_LABEL,
46
+ DOMAINS_PROJECTION_FILE,
47
+ DomainNameSchema,
48
+ MEMBERSHIP_KINDS,
49
+ PromiseChainMutex,
50
+ atomicWriteJSONIfChanged,
51
+ domainOfHost,
52
+ isAssignableGroupName,
53
+ isLamasteOwnedGroup,
54
+ isRelayHost,
55
+ isIdentityUuid,
56
+ isLamasteUsername,
57
+ reachedOidcClients,
58
+ } from '@lamalibre/lamaste';
59
+ import {
60
+ assertDomainRegistrable,
61
+ registryAudience,
62
+ registryIssuerDomain,
63
+ resolveHostA,
64
+ withTunnelLock,
65
+ } from '@lamalibre/lamaste/server';
66
+ import { httpError } from './app-error.js';
67
+ import { recordAuditBestEffort } from './audit.js';
68
+ import {
69
+ autheliaWatchesUsers,
70
+ heldOwnedGroups,
71
+ narrowOwnedGroupsOnly,
72
+ readUsersRaw,
73
+ reloadAuthelia,
74
+ takeUsersFileDirty,
75
+ updateDomainAccess,
76
+ writeOwnedGroups,
77
+ } from './authelia.js';
78
+ import { getConfig } from './config.js';
79
+ import { deviceCodeClients, desiredOwnedGroups, narrowingSince } from './owned-groups.js';
80
+ import { getStateDb } from './state-db.js';
81
+ import { readSites, readTunnels, withSitesLock } from './state.js';
82
+ import { withUsersLock } from './users-lock.js';
83
+
84
+ const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
85
+
86
+ /** One change of the domains or their members at a time. */
87
+ const domainsMutex = new PromiseChainMutex();
88
+ /**
89
+ * One writer of what members may do at a time — Authelia's domain access,
90
+ * the projection, the OIDC provider — each reading the members under it.
91
+ */
92
+ const renderMutex = new PromiseChainMutex();
93
+
94
+ /**
95
+ * @typedef {object} Domain
96
+ * @property {string} name
97
+ * @property {boolean} primary
98
+ * @property {'pending' | 'active'} status
99
+ * @property {string} createdAt
100
+ * @property {string | null} activatedAt
101
+ */
102
+
103
+ /** @returns {Domain} */
104
+ function rowToDomain(row) {
105
+ return {
106
+ name: row.name,
107
+ primary: row.is_primary === 1,
108
+ status: row.status,
109
+ createdAt: row.created_at,
110
+ activatedAt: row.activated_at,
111
+ };
112
+ }
113
+
114
+ /** `fn(db)` in one `BEGIN IMMEDIATE` transaction, rolled back when it throws. */
115
+ function inTransaction(db, fn) {
116
+ db.exec('BEGIN IMMEDIATE');
117
+ try {
118
+ const result = fn(db);
119
+ db.exec('COMMIT');
120
+ return result;
121
+ } catch (err) {
122
+ db.exec('ROLLBACK');
123
+ throw err;
124
+ }
125
+ }
126
+
127
+ /** `panel.json`'s domain — the primary's name — or null before onboarding set one (or loaded it). */
128
+ function configDomain() {
129
+ try {
130
+ return getConfig().domain ?? null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Reading
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /** One domain's row, or null. */
141
+ function domainRow(db, name) {
142
+ const row = db.prepare('SELECT * FROM domains WHERE name = ?').get(name);
143
+ return row ? rowToDomain(row) : null;
144
+ }
145
+
146
+ /**
147
+ * Every domain: the primary first, then by name.
148
+ *
149
+ * @returns {Promise<Domain[]>}
150
+ */
151
+ export async function listDomains() {
152
+ const db = await getStateDb();
153
+ return db.prepare('SELECT * FROM domains ORDER BY is_primary DESC, name').all().map(rowToDomain);
154
+ }
155
+
156
+ /** One domain by name, or null. */
157
+ export async function getDomain(name) {
158
+ return domainRow(await getStateDb(), name);
159
+ }
160
+
161
+ /** Every domain's name, pending ones too — what the relay's own hostnames are kept free on. */
162
+ export async function domainNames() {
163
+ return (await listDomains()).map((d) => d.name);
164
+ }
165
+
166
+ /**
167
+ * Every domain whose hostnames are Lamaste's groups in users.yml (core
168
+ * `isLamasteOwnedGroup`): every domain record, pending ones too, and every
169
+ * domain removed from the relay — a host group it left on a user is
170
+ * stripped by the next rendering, never taken for an administrator's group.
171
+ */
172
+ export async function ownedGroupDomains() {
173
+ const db = await getStateDb();
174
+ const retired = db.prepare('SELECT name FROM retired_domains').all();
175
+ return [...new Set([...(await domainNames()), ...retired.map((r) => r.name)])];
176
+ }
177
+
178
+ /** The group key of domain `name` (core owned-groups.ts), or null when there is no such domain. */
179
+ export async function domainGroupKey(name) {
180
+ const db = await getStateDb();
181
+ return db.prepare('SELECT group_key FROM domains WHERE name = ?').get(name)?.group_key ?? null;
182
+ }
183
+
184
+ /** A new domain's group key: 8 random bytes, hex (core `DOMAIN_GROUP_KEY_RE`). */
185
+ function newGroupKey() {
186
+ return crypto.randomBytes(8).toString('hex');
187
+ }
188
+
189
+ /**
190
+ * A tunnel, site or alias that holds one of the relay's own hostnames of
191
+ * `name` (`auth.`, `panel.`, `tunnel.`, `agent-*`): what was created there
192
+ * before the domain was added — its hostname, or null. A domain is added
193
+ * and activated only when none does: its sign-in vhost would share a
194
+ * `server_name` with it.
195
+ *
196
+ * @param {string} name
197
+ */
198
+ async function relayHostTaken(name) {
199
+ const [tunnels, sites] = await Promise.all([readTunnels(), readSites()]);
200
+ const hosts = [
201
+ ...tunnels.flatMap((t) => (t.fqdn ? [t.fqdn] : [])),
202
+ ...sites.flatMap((s) => [s.fqdn, ...(s.aliases ?? [])]),
203
+ ];
204
+ return hosts.find((host) => isRelayHost(host, [name])) ?? null;
205
+ }
206
+
207
+ /** Refuse (409 `relay_host_taken`) while something holds one of `name`'s relay hostnames. */
208
+ async function assertRelayHostsFree(name) {
209
+ const taken = await relayHostTaken(name);
210
+ if (taken !== null) {
211
+ throw httpError(
212
+ `'${taken}' is served by a tunnel or a site — the relay's own name on ${name}; remove it first`,
213
+ 409,
214
+ { code: 'relay_host_taken' },
215
+ );
216
+ }
217
+ }
218
+
219
+ /** The active domains (the primary first). */
220
+ export async function activeDomains() {
221
+ return (await listDomains()).filter((d) => d.status === 'active');
222
+ }
223
+
224
+ /** The names of the active domains — what hostnames are checked against. */
225
+ export async function activeDomainNames() {
226
+ return (await activeDomains()).map((d) => d.name);
227
+ }
228
+
229
+ /**
230
+ * The primary domain's name: its record, or — before the first start that
231
+ * made it ({@link ensurePrimaryDomain}) — `panel.json`'s `domain`. Null
232
+ * before onboarding set one.
233
+ */
234
+ export async function primaryDomainName() {
235
+ const db = await getStateDb();
236
+ return db.prepare('SELECT name FROM domains WHERE is_primary = 1').get()?.name ?? configDomain();
237
+ }
238
+
239
+ /**
240
+ * Whether user `userId` is a member — of kind `member`, not external — of
241
+ * the primary domain: who desktop user access (`/api/user-access/*`,
242
+ * primary only) is for. An external member reaches only what names them;
243
+ * the desktop's plugin grants are no such thing.
244
+ *
245
+ * @param {string} userId
246
+ */
247
+ export async function isPrimaryMember(userId) {
248
+ const primary = await primaryDomainName();
249
+ if (!primary) return false;
250
+ const db = await getStateDb();
251
+ const row = db
252
+ .prepare('SELECT kind FROM domain_members WHERE domain = ? AND user_id = ?')
253
+ .get(primary, userId);
254
+ return row?.kind === 'member';
255
+ }
256
+
257
+ /**
258
+ * The active domain hostname `fqdn` is under, or null when it is under none
259
+ * of them.
260
+ *
261
+ * @param {string} fqdn
262
+ */
263
+ export async function domainOfHostname(fqdn) {
264
+ return domainOfHost(fqdn, await activeDomainNames());
265
+ }
266
+
267
+ /** The domain a plugin belongs to: its own (`plugins.domain`), else `primary`. Pure. */
268
+ export function pluginDomainOr(plugin, primary) {
269
+ return plugin?.domain ?? primary;
270
+ }
271
+
272
+ /**
273
+ * Plugins as the API shows them: `domain` the name of the domain each
274
+ * belongs to (stored NULL means the primary — clients never interpret it).
275
+ *
276
+ * @template {{ domain?: string | null }} T
277
+ * @param {T[]} plugins
278
+ * @returns {Promise<T[]>}
279
+ */
280
+ export async function withPluginDomains(plugins) {
281
+ const primary = await primaryDomainName();
282
+ return plugins.map((p) => ({ ...p, domain: pluginDomainOr(p, primary) }));
283
+ }
284
+
285
+ /**
286
+ * The domain a plugin belongs to: its own, else the primary.
287
+ *
288
+ * @param {{ domain?: string | null } | null | undefined} plugin
289
+ */
290
+ export async function pluginDomainName(plugin) {
291
+ return pluginDomainOr(plugin, await primaryDomainName());
292
+ }
293
+
294
+ /**
295
+ * The domain whose sign-in an invitation sends its user to: the first active
296
+ * one it names, else the primary (an invitation from before the multi-domain
297
+ * relay names none).
298
+ */
299
+ export async function invitationSignInDomain(invitation) {
300
+ // The first that serves: a pending domain has no sign-in page yet.
301
+ const active = await activeDomainNames();
302
+ return (
303
+ invitationMemberships(invitation).find((m) => active.includes(m.name))?.name ??
304
+ (await primaryDomainName())
305
+ );
306
+ }
307
+
308
+ /**
309
+ * The memberships an invitation names, `{ name, kind }` (an invitation
310
+ * made before kinds names bare domains: members).
311
+ *
312
+ * @param {{ domains?: unknown } | null | undefined} invitation
313
+ * @returns {Array<{ name: string, kind: 'member' | 'external' }>}
314
+ */
315
+ export function invitationMemberships(invitation) {
316
+ const named = Array.isArray(invitation?.domains) ? invitation.domains : [];
317
+ return named.flatMap((d) => {
318
+ if (typeof d === 'string') return [{ name: d, kind: 'member' }];
319
+ if (d && typeof d.name === 'string' && (d.kind === 'member' || d.kind === 'external')) {
320
+ return [{ name: d.name, kind: d.kind }];
321
+ }
322
+ return [];
323
+ });
324
+ }
325
+
326
+ /**
327
+ * The memberships an invitation still grants when it is accepted: those it
328
+ * names on domains that exist now **and existed when it was created** — a
329
+ * domain removed since and added again (possibly for someone else) is
330
+ * another domain, and the invitation grants it nothing. Decided at
331
+ * acceptance, so no removal has to chase invitations. Each keeps the kind
332
+ * the invitation named.
333
+ *
334
+ * @param {{ domains?: unknown, createdAt?: string }} invitation
335
+ * @returns {Promise<Array<{ name: string, kind: 'member' | 'external' }>>}
336
+ */
337
+ export async function invitationDomainsNow(invitation) {
338
+ const named = invitationMemberships(invitation);
339
+ if (named.length === 0 || typeof invitation.createdAt !== 'string') return [];
340
+ const db = await getStateDb();
341
+ const stmt = db.prepare('SELECT 1 FROM domains WHERE name = ? AND created_at <= ?');
342
+ return named.filter((m) => stmt.get(m.name, invitation.createdAt) !== undefined);
343
+ }
344
+
345
+ /**
346
+ * The registry's domains for `config` (a stored registry credential, or
347
+ * null): the issuer domain its tokens carry (`issuerDomain`, else the
348
+ * primary) and the domain its own host is under — Feria's, whose members
349
+ * `feria login` admits. Pure apart from the domain lists.
350
+ *
351
+ * @param {object | null} config
352
+ * @returns {Promise<string[]>}
353
+ */
354
+ export async function registryDomainsOf(config) {
355
+ const primary = await primaryDomainName();
356
+ if (!config) return primary ? [primary] : [];
357
+ const names = await activeDomainNames();
358
+ let host = null;
359
+ try {
360
+ host = domainOfHost(new URL(config.registryUrl).hostname, names);
361
+ } catch {
362
+ // An unparseable URL names no domain.
363
+ }
364
+ return [...new Set([registryIssuerDomain(config.auth, primary), host].filter(Boolean))];
365
+ }
366
+
367
+ /** The stored registry credential's domains ({@link registryDomainsOf}). */
368
+ export async function registryDomains() {
369
+ const { readRegistryCredentialConfig } = await import('./registry-credential.js');
370
+ return registryDomainsOf(await readRegistryCredentialConfig().catch(() => null));
371
+ }
372
+
373
+ /**
374
+ * The active domain `requested` names — the primary when it names none — or
375
+ * a 400 the route answers with: a tunnel, a site or a plugin is put only on
376
+ * a domain that serves.
377
+ *
378
+ * @param {string | undefined | null} requested
379
+ * @returns {Promise<string>}
380
+ */
381
+ export async function resolveActiveDomain(requested) {
382
+ if (requested === undefined || requested === null) {
383
+ const primary = await primaryDomainName();
384
+ if (!primary) throw httpError('The panel has no domain yet', 400, { code: 'no_domain' });
385
+ return primary;
386
+ }
387
+ const wanted = String(requested).toLowerCase();
388
+ if (!(await activeDomainNames()).includes(wanted)) {
389
+ throw httpError(`'${wanted}' is not an active domain of this relay`, 400, {
390
+ code: 'unknown_domain',
391
+ });
392
+ }
393
+ return wanted;
394
+ }
395
+
396
+ /**
397
+ * Run `fn` while `name` (when given) is an active domain and cannot be
398
+ * removed: under the domains lock, which a domain's removal holds for its
399
+ * usage check. The same 400 `unknown_domain` as {@link resolveActiveDomain}
400
+ * when it is no longer active — for writes that name a domain outside the
401
+ * tunnel and sites locks (the registry credential). Workflows under those
402
+ * locks call {@link resolveActiveDomain} there instead.
403
+ *
404
+ * @template T
405
+ * @param {string | undefined | null} name
406
+ * @param {() => Promise<T>} fn
407
+ * @returns {Promise<T>}
408
+ */
409
+ export function withActiveDomain(name, fn) {
410
+ return domainsMutex.run(async () => {
411
+ if (name) await resolveActiveDomain(name);
412
+ return fn();
413
+ });
414
+ }
415
+
416
+ /**
417
+ * Every member of `name` the directory still holds (removed users have no
418
+ * membership): id, username, status, the membership's kind and when it was
419
+ * added.
420
+ *
421
+ * @param {string} name
422
+ */
423
+ export async function listDomainMembers(name) {
424
+ const db = await getStateDb();
425
+ return db
426
+ .prepare(
427
+ `SELECT dm.user_id, u.username, u.status, dm.kind, dm.added_at, dm.added_by
428
+ FROM domain_members dm JOIN directory_users u ON u.user_id = dm.user_id
429
+ WHERE dm.domain = ? ORDER BY u.username`,
430
+ )
431
+ .all(name)
432
+ .map((row) => ({
433
+ userId: row.user_id,
434
+ username: row.username,
435
+ status: row.status,
436
+ kind: row.kind,
437
+ addedAt: row.added_at,
438
+ addedBy: row.added_by,
439
+ }));
440
+ }
441
+
442
+ /**
443
+ * Each user's memberships, by user id: `{ name, kind }`, by domain name.
444
+ *
445
+ * @returns {Promise<Map<string, Array<{ name: string, kind: 'member' | 'external' }>>>}
446
+ */
447
+ export async function domainsByUser() {
448
+ const db = await getStateDb();
449
+ const map = new Map();
450
+ for (const { user_id: userId, domain, kind } of db
451
+ .prepare('SELECT user_id, domain, kind FROM domain_members ORDER BY domain')
452
+ .all()) {
453
+ map.set(userId, [...(map.get(userId) ?? []), { name: domain, kind }]);
454
+ }
455
+ return map;
456
+ }
457
+
458
+ /**
459
+ * Each domain's members, by domain: their user ids.
460
+ *
461
+ * @returns {Promise<Map<string, Set<string>>>}
462
+ */
463
+ export async function membersByDomain() {
464
+ const db = await getStateDb();
465
+ const map = new Map();
466
+ for (const { domain, user_id: userId } of db
467
+ .prepare('SELECT domain, user_id FROM domain_members')
468
+ .all()) {
469
+ if (!map.has(domain)) map.set(domain, new Set());
470
+ map.get(domain).add(userId);
471
+ }
472
+ return map;
473
+ }
474
+
475
+ /**
476
+ * The memberships of `userId`: `{ name, kind }`, by domain name.
477
+ *
478
+ * @returns {Promise<Array<{ name: string, kind: 'member' | 'external' }>>}
479
+ */
480
+ export async function domainsOfUser(userId) {
481
+ const db = await getStateDb();
482
+ return db
483
+ .prepare('SELECT domain, kind FROM domain_members WHERE user_id = ? ORDER BY domain')
484
+ .all(userId)
485
+ .map((row) => ({ name: row.domain, kind: row.kind }));
486
+ }
487
+
488
+ /**
489
+ * The domains, the primary first, with how many members and external
490
+ * members each has and what uses it (as {@link domainUsage}) — read once
491
+ * for all of them.
492
+ */
493
+ export async function domainsOverview() {
494
+ const db = await getStateDb();
495
+ const domains = await listDomains();
496
+ const counts = new Map(
497
+ db
498
+ .prepare(
499
+ `SELECT domain, SUM(kind = 'member') AS members, SUM(kind = 'external') AS externals
500
+ FROM domain_members GROUP BY domain`,
501
+ )
502
+ .all()
503
+ .map((row) => [row.domain, row]),
504
+ );
505
+ const usage = await usageByDomain(domains.map((d) => d.name));
506
+ return domains.map((d) => ({
507
+ ...d,
508
+ members: counts.get(d.name)?.members ?? 0,
509
+ externalMembers: counts.get(d.name)?.externals ?? 0,
510
+ usedBy: usage.get(d.name),
511
+ }));
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Memberships inside the directory's transactions
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /**
519
+ * Whether new users must be given their domains: the relay has two or more
520
+ * (pending ones count). With one, every new user becomes its member.
521
+ *
522
+ * @param {ReadonlyArray<unknown>} domains
523
+ */
524
+ export function newUsersNeedDomains(domains) {
525
+ return domains.length > 1;
526
+ }
527
+
528
+ /**
529
+ * One membership as the API takes it: a domain name — a `member` — or
530
+ * `{ name, kind }`.
531
+ */
532
+ export const MembershipInputSchema = z.union([
533
+ DomainNameSchema,
534
+ z.object({ name: DomainNameSchema, kind: z.enum(MEMBERSHIP_KINDS) }).strict(),
535
+ ]);
536
+
537
+ /**
538
+ * Memberships as the API took them, each domain once, names in lower case:
539
+ * a bare name is a `member`. Throws a 400 `conflicting_membership` when one
540
+ * domain is named with two kinds.
541
+ *
542
+ * @param {ReadonlyArray<string | { name: string, kind: 'member' | 'external' }>} input
543
+ * @returns {Array<{ name: string, kind: 'member' | 'external' }>}
544
+ */
545
+ export function normalizeMemberships(input) {
546
+ const byName = new Map();
547
+ for (const entry of input) {
548
+ const { name, kind } =
549
+ typeof entry === 'string' ? { name: entry, kind: 'member' } : { kind: 'member', ...entry };
550
+ const lower = String(name).toLowerCase();
551
+ const seen = byName.get(lower);
552
+ if (seen && seen !== kind) {
553
+ throw httpError(`'${lower}' is named both as a member and as an external member`, 400, {
554
+ code: 'conflicting_membership',
555
+ });
556
+ }
557
+ byName.set(lower, kind);
558
+ }
559
+ return [...byName].map(([name, kind]) => ({ name, kind }));
560
+ }
561
+
562
+ /**
563
+ * `groups` without the ones Lamaste owns (`domains`: every domain record) —
564
+ * what anything outside Authelia is shown of a user's groups: the
565
+ * rendering's are nobody's choice, and who is a member of which domain is
566
+ * shown as memberships.
567
+ *
568
+ * @param {unknown} groups
569
+ * @param {readonly string[]} domains
570
+ * @returns {string[]}
571
+ */
572
+ export function adminGroups(groups, domains) {
573
+ return Array.isArray(groups)
574
+ ? groups.filter((g) => typeof g === 'string' && !isLamasteOwnedGroup(g, domains))
575
+ : [];
576
+ }
577
+
578
+ /**
579
+ * The groups an administrator gave `username` in users.yml
580
+ * ({@link adminGroups}).
581
+ *
582
+ * @param {string} username
583
+ * @returns {Promise<string[]>}
584
+ */
585
+ export async function adminGroupsOf(username) {
586
+ const [users, names] = await Promise.all([
587
+ readUsersRaw().then((data) => data.users ?? {}),
588
+ domainNames(),
589
+ ]);
590
+ return adminGroups(users[username]?.groups, names);
591
+ }
592
+
593
+ /**
594
+ * The refusal (`external_member_groups`) of Authelia groups for an external
595
+ * member: groups grant by membership, relay-wide (`admins`, `feria-*`), and
596
+ * an external member reaches only what names them. 409 when the user's
597
+ * state is in the way, 400 when the request itself asks for both.
598
+ *
599
+ * @param {400 | 409} statusCode
600
+ */
601
+ export function externalMemberGroupsError(statusCode) {
602
+ return httpError(
603
+ "An external member holds no Authelia group: remove the user's groups or the external memberships first",
604
+ statusCode,
605
+ { code: 'external_member_groups' },
606
+ );
607
+ }
608
+
609
+ /**
610
+ * Refuse (400 `reserved_group`) a group an administrator may not assign —
611
+ * one that could be a hostname or one of Lamaste's own groups (core
612
+ * `isAssignableGroupName`) — unless the user holds it already (a group from
613
+ * before the rule).
614
+ *
615
+ * @param {readonly string[]} groups
616
+ * @param {readonly string[]} [held]
617
+ */
618
+ export function assertAssignableGroups(groups, held = []) {
619
+ if (groups.every((g) => isAssignableGroupName(g) || held.includes(g))) return;
620
+ throw httpError(
621
+ 'A group name is letters, digits, "-" and "_" (1–64): names with ".", ":" or "@" are reserved',
622
+ 400,
623
+ { code: 'reserved_group' },
624
+ );
625
+ }
626
+
627
+ /**
628
+ * Refuse (409 `external_member_groups`) memberships that make a user who
629
+ * holds Authelia groups an external member anywhere. Call it holding the
630
+ * users lock (the users API changes groups under it).
631
+ *
632
+ * @param {string | null} username
633
+ * @param {ReadonlyArray<{ kind: 'member' | 'external' }>} memberships
634
+ */
635
+ export async function assertNoGroupsForExternal(username, memberships) {
636
+ if (!username || !memberships.some((m) => m.kind === 'external')) return;
637
+ if ((await adminGroupsOf(username)).length > 0) throw externalMemberGroupsError(409);
638
+ }
639
+
640
+ /**
641
+ * The memberships a user created now is given: `requested` (each an
642
+ * existing domain, pending ones too, so an activation goes live with its
643
+ * members; a bare name is a `member`), or — when none are requested and the
644
+ * relay has exactly one domain — a membership of that one. With two or more
645
+ * domains and none requested: none (a user adopted from users.yml waits for
646
+ * an administrator), or with `required` a 400 `domains_required` (the users
647
+ * API and invitations). Throws a 400 `unknown_domain` on a domain the relay
648
+ * does not have.
649
+ *
650
+ * @param {ReadonlyArray<string | { name: string, kind: 'member' | 'external' }> | undefined} requested
651
+ * @param {{ required?: boolean }} [opts]
652
+ * @returns {Promise<Array<{ name: string, kind: 'member' | 'external' }>>}
653
+ */
654
+ export async function domainsForNewUser(requested, { required = false } = {}) {
655
+ const domains = await listDomains();
656
+ if (requested === undefined && !newUsersNeedDomains(domains)) {
657
+ return domains.length === 1 ? [{ name: domains[0].name, kind: 'member' }] : [];
658
+ }
659
+ // A user who is a member of nothing reaches nothing: when domains are
660
+ // asked for, none — or an empty list — is refused.
661
+ if (requested === undefined || requested.length === 0) {
662
+ if (!required) return [];
663
+ throw httpError(
664
+ domains.length > 1
665
+ ? 'Name the domains the user is a member of: this relay has more than one'
666
+ : 'A new user is a member of at least one domain',
667
+ 400,
668
+ { code: 'domains_required' },
669
+ );
670
+ }
671
+ const names = new Set(domains.map((d) => d.name));
672
+ const wanted = normalizeMemberships(requested);
673
+ const unknown = wanted.find((m) => !names.has(m.name));
674
+ if (unknown !== undefined) {
675
+ throw httpError(`'${unknown.name}' is not a domain of this relay`, 400, {
676
+ code: 'unknown_domain',
677
+ });
678
+ }
679
+ return wanted;
680
+ }
681
+
682
+ /**
683
+ * Give `userId` `memberships`, inside the caller's transaction: the rows
684
+ * that are new — a membership the user holds already keeps its kind — and
685
+ * the audit entries for them (to record once the transaction committed).
686
+ *
687
+ * @param {import('node:sqlite').DatabaseSync} db
688
+ * @param {string} userId
689
+ * @param {ReadonlyArray<string | { name: string, kind: 'member' | 'external' }>} memberships
690
+ * a bare domain name is a `member`
691
+ * @param {{ actor: string, now: string }} opts
692
+ * @returns {Array<{ action: string, details: object }>}
693
+ */
694
+ export function insertMembershipsSync(db, userId, memberships, { actor, now }) {
695
+ const insert = db.prepare(
696
+ `INSERT OR IGNORE INTO domain_members (domain, user_id, kind, added_at, added_by)
697
+ SELECT name, ?, ?, ?, ? FROM domains WHERE name = ?`,
698
+ );
699
+ const events = [];
700
+ for (const { name: domain, kind } of normalizeMemberships(memberships)) {
701
+ if (insert.run(userId, kind, now, actor, domain).changes > 0) {
702
+ events.push({ action: 'directory.membership.added', details: { domain, userId, kind } });
703
+ }
704
+ }
705
+ return events;
706
+ }
707
+
708
+ /**
709
+ * End every membership of `userId`, inside the caller's transaction; returns
710
+ * the audit entries (`reason`, e.g. `deleted`).
711
+ *
712
+ * @param {import('node:sqlite').DatabaseSync} db
713
+ * @param {string} userId
714
+ * @param {string} reason
715
+ * @returns {Array<{ action: string, details: object }>}
716
+ */
717
+ export function deleteMembershipsSync(db, userId, reason) {
718
+ return db
719
+ .prepare('DELETE FROM domain_members WHERE user_id = ? RETURNING domain')
720
+ .all(userId)
721
+ .map(({ domain }) => ({
722
+ action: 'directory.membership.removed',
723
+ details: { domain, userId, reason },
724
+ }));
725
+ }
726
+
727
+ // ---------------------------------------------------------------------------
728
+ // The primary domain
729
+ // ---------------------------------------------------------------------------
730
+
731
+ /**
732
+ * Make the primary domain's record from `panel.json`'s `domain` when there
733
+ * is none yet — at the first start after onboarding, or of a version from
734
+ * before the multi-domain relay — with every user the directory holds as its
735
+ * member (`added_by: 'migration'`): a relay with one domain lets in whom it
736
+ * let in before. Idempotent. Returns the primary's name, or null without a
737
+ * domain.
738
+ *
739
+ * @param {{ logger?: import('pino').Logger }} [opts]
740
+ */
741
+ export async function ensurePrimaryDomain({ logger } = {}) {
742
+ return domainsMutex.run(async () => {
743
+ const db = await getStateDb();
744
+ const existing = db.prepare('SELECT name FROM domains WHERE is_primary = 1').get();
745
+ if (existing) return existing.name;
746
+ const domain = configDomain();
747
+ if (!domain) return null;
748
+ const now = new Date().toISOString();
749
+ const members = inTransaction(db, () => {
750
+ db.prepare(
751
+ `INSERT INTO domains (name, is_primary, status, created_at, activated_at, group_key)
752
+ VALUES (?, 1, 'active', ?, ?, ?)`,
753
+ ).run(domain, now, now, newGroupKey());
754
+ return db
755
+ .prepare(
756
+ `INSERT INTO domain_members (domain, user_id, added_at, added_by)
757
+ SELECT ?, user_id, ?, 'migration' FROM directory_users WHERE status <> 'removed'`,
758
+ )
759
+ .run(domain, now).changes;
760
+ });
761
+ await recordAuditBestEffort(
762
+ { actor: 'lamaste', action: 'domain.added', details: { domain, primary: true, members } },
763
+ logger,
764
+ );
765
+ logger?.info({ domain, members }, 'Primary domain recorded');
766
+ return domain;
767
+ });
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Rendering
772
+ // ---------------------------------------------------------------------------
773
+
774
+ /** The protected-site rules Authelia renders, from `sites.json`. */
775
+ function protectedSiteRules(sites) {
776
+ return sites
777
+ .filter((s) => s && typeof s.fqdn === 'string')
778
+ .map((s) => ({
779
+ ...(typeof s.id === 'string' ? { id: s.id } : {}),
780
+ fqdn: s.fqdn,
781
+ autheliaProtected: s.autheliaProtected === true,
782
+ allowedUsers: Array.isArray(s.allowedUsers) ? s.allowedUsers : [],
783
+ ...(s.restrictAccess === true ? { restrictAccess: true } : {}),
784
+ }));
785
+ }
786
+
787
+ /** Logged once per count, so a steady number of unrenderable members is not repeated every minute. */
788
+ let loggedIneligible = 0;
789
+
790
+ /**
791
+ * The active domains with the usernames of their active members and of
792
+ * their active external members — what Authelia and Gatekeeper are told. A
793
+ * username users.yml holds that is not a Lamaste username is never rendered
794
+ * (counted, never named). Read it under {@link withDomainRendering} to write
795
+ * anything from it.
796
+ *
797
+ * @param {import('pino').Logger} [logger]
798
+ * @returns {Promise<Array<{ name: string, groupKey: string, members: string[], externals: string[] }>>}
799
+ */
800
+ async function domainsWithMembers(logger) {
801
+ const db = await getStateDb();
802
+ const domains = db
803
+ .prepare(
804
+ "SELECT name, group_key FROM domains WHERE status = 'active' ORDER BY is_primary DESC, name",
805
+ )
806
+ .all();
807
+ const rows = db
808
+ .prepare(
809
+ `SELECT dm.domain, dm.kind, u.username FROM domain_members dm
810
+ JOIN directory_users u ON u.user_id = dm.user_id
811
+ JOIN domains d ON d.name = dm.domain
812
+ WHERE d.status = 'active' AND u.status = 'active' AND u.username IS NOT NULL
813
+ ORDER BY u.username`,
814
+ )
815
+ .all();
816
+ let ineligible = 0;
817
+ const byDomain = new Map(domains.map((d) => [d.name, { members: [], externals: [] }]));
818
+ for (const row of rows) {
819
+ if (!isLamasteUsername(row.username)) {
820
+ ineligible += 1;
821
+ continue;
822
+ }
823
+ byDomain
824
+ .get(row.domain)
825
+ ?.[row.kind === 'external' ? 'externals' : 'members'].push(row.username);
826
+ }
827
+ if (ineligible !== loggedIneligible) {
828
+ loggedIneligible = ineligible;
829
+ if (ineligible > 0) {
830
+ logger?.warn({ ineligible }, 'Members whose username Authelia cannot be told are left out');
831
+ }
832
+ }
833
+ return domains.map((d) => ({ name: d.name, groupKey: d.group_key, ...byDomain.get(d.name) }));
834
+ }
835
+
836
+ /**
837
+ * Run `fn(domains)` — `domains` the active domains with their active
838
+ * members, read now — as the one writer of what members may do: two
839
+ * writers would each write their own reading, and the later one could put
840
+ * back a member the earlier had removed. Every write of Authelia's domain
841
+ * access, the projection and the OIDC provider goes through here.
842
+ *
843
+ * @template T
844
+ * @param {(domains: Array<{ name: string, groupKey: string, members: string[], externals: string[] }>) => Promise<T>} fn
845
+ * @param {{ logger?: import('pino').Logger }} [opts]
846
+ * @returns {Promise<T>}
847
+ */
848
+ export function withDomainRendering(fn, { logger } = {}) {
849
+ return renderMutex.run(async () => fn(await domainsWithMembers(logger)));
850
+ }
851
+
852
+ /**
853
+ * Whether the last rendering failed — so a failure streak is audited and
854
+ * notified once, and its end once.
855
+ */
856
+ let renderFailing = false;
857
+
858
+ /**
859
+ * Render the domains and their people, in this order:
860
+ *
861
+ * 1. the groups Lamaste owns into users.yml (lib/owned-groups.js) — who is
862
+ * a member or an external member of which domain, the static sites'
863
+ * listed users, the hosts and OIDC clients that name each external
864
+ * member. Authelia applies them at each user's next request. When the
865
+ * grants cannot be read, external members hold no granted host or
866
+ * client (fail closed) and the step counts as failed;
867
+ * 2. Gatekeeper's projection (`domains.json`, v2: members and external
868
+ * members of each active domain);
869
+ * 3. Authelia's session cookies and `access_control` (`sites`: the
870
+ * protected sites, `sites.json` unless given — a site change passes the
871
+ * sites it is saving), whose rules name the groups of step 1 and change
872
+ * only with the domains and protected sites: Authelia restarts only then;
873
+ * 4. Authelia restarted when it must read users.yml anew: when its
874
+ * configuration does not have it watch the file (one that could not be
875
+ * brought in line) and the file changed; and when a narrowing reached a
876
+ * device-code OIDC client — Authelia's device flow checks the groups a
877
+ * session last refreshed, never re-reading them, so a session that lost
878
+ * a member's or a granted client's group must end.
879
+ *
880
+ * The groups come first: a rule never names a group that is not yet filled,
881
+ * and a host group never outlives the rule that narrows its host. Each step
882
+ * is always attempted — one failing never keeps another at older, more
883
+ * permissive members. Nothing before onboarding made a domain.
884
+ *
885
+ * Then, outside the rendering's lock, each enabled user who lost an owned
886
+ * group is followed: audited (`directory.access.narrowed`, by id), and their
887
+ * registry tokens revoked when they no longer reach the registry's sign-in
888
+ * — whatever narrowed them (a membership, a kind, a grant revoked through
889
+ * the panel or with Gatekeeper's own CLI).
890
+ *
891
+ * Throws when any step failed (callers report a warning; the identity tick
892
+ * renders again within a minute); the first failure of a streak is audited
893
+ * (`domain.render.failed`) and notified to the administrators, its end
894
+ * audited (`domain.render.recovered`).
895
+ *
896
+ * @param {{ logger?: import('pino').Logger, sites?: readonly object[] }} [opts]
897
+ * @returns {Promise<{ changed: boolean, groupsChanged: boolean, restarted: boolean, domains: number, warnings?: object[] }>}
898
+ */
899
+ export async function syncDomainAccess({ logger, sites } = {}) {
900
+ const outcome = await withDomainRendering(
901
+ async (domains) => {
902
+ if (domains.length === 0) {
903
+ return { changed: false, groupsChanged: false, restarted: false, domains: 0, failures: [] };
904
+ }
905
+ const siteList = sites ?? (await readSites());
906
+ const failures = [];
907
+ let groupsChanged = false;
908
+ let narrowed = [];
909
+ try {
910
+ const allDomains = await ownedGroupDomains();
911
+ const owned = await desiredOwnedGroups({ domains, sites: siteList });
912
+ if (!owned.complete) {
913
+ logger?.warn(owned.warning);
914
+ failures.push({ step: 'grants', message: owned.warning });
915
+ }
916
+ const held = await heldOwnedGroups(allDomains);
917
+ ({ changed: groupsChanged } = await writeOwnedGroups(owned.desired, allDomains));
918
+ narrowed = narrowingSince(owned.desired, owned.complete, held);
919
+ if (groupsChanged) logger?.info('Authelia users file groups updated');
920
+ } catch (err) {
921
+ narrowOwnedGroupsOnly();
922
+ failures.push({ step: 'groups', message: err.message });
923
+ }
924
+ try {
925
+ await atomicWriteJSONIfChanged(
926
+ path.join(STATE_DIR, DOMAINS_PROJECTION_FILE),
927
+ {
928
+ v: 2,
929
+ domains: domains.map(({ name, members, externals }) => ({ name, members, externals })),
930
+ },
931
+ { mode: 0o600 },
932
+ );
933
+ } catch (err) {
934
+ failures.push({ step: 'gatekeeper', message: err.message });
935
+ }
936
+ let changed = false;
937
+ try {
938
+ ({ changed } = await updateDomainAccess({
939
+ domains: domains.map(({ name, groupKey }) => ({ name, groupKey })),
940
+ sites: protectedSiteRules(siteList),
941
+ }));
942
+ if (changed) logger?.info({ domains: domains.length }, 'Authelia domain access updated');
943
+ } catch (err) {
944
+ failures.push({ step: 'authelia', message: err.message });
945
+ }
946
+ let restarted = changed;
947
+ if (changed) takeUsersFileDirty();
948
+ try {
949
+ const reason = changed ? null : await restartReason(domains, narrowed);
950
+ if (reason) {
951
+ await reloadAuthelia();
952
+ restarted = true;
953
+ logger?.info({ reason }, 'Authelia restarted to read users.yml anew');
954
+ await recordAuditBestEffort(
955
+ { actor: 'lamaste', action: 'authelia.restarted', details: { reason } },
956
+ logger,
957
+ );
958
+ }
959
+ } catch (err) {
960
+ failures.push({ step: 'restart', message: err.message });
961
+ }
962
+ await recordRenderOutcome(failures, logger);
963
+ return { changed, groupsChanged, restarted, domains: domains.length, failures, narrowed };
964
+ },
965
+ { logger },
966
+ );
967
+
968
+ const { failures, narrowed = [], ...result } = outcome;
969
+ const warnings = [];
970
+ if (result.restarted) {
971
+ const { bustGatekeeperSessions } = await import('./gatekeeper-client.js');
972
+ const busted = await bustGatekeeperSessions();
973
+ if (!busted.ok) {
974
+ warnings.push({
975
+ step: 'gatekeeper',
976
+ message: `Gatekeeper did not drop its cached sessions (${busted.error}); they expire within 30 seconds`,
977
+ });
978
+ }
979
+ }
980
+ if (narrowed.length > 0) warnings.push(...(await followNarrowing(narrowed, logger)));
981
+ if (failures.length > 0) {
982
+ const err = new Error(failures.map((f) => `${f.step}: ${f.message}`).join('; '));
983
+ err.warnings = warnings;
984
+ throw err;
985
+ }
986
+ return { ...result, ...(warnings.length > 0 ? { warnings } : {}) };
987
+ }
988
+
989
+ /**
990
+ * Why Authelia must be restarted after this rendering wrote users.yml, or
991
+ * null: `unwatched` — its configuration does not have it watch the file and
992
+ * the file changed (a configuration that could not be brought in line);
993
+ * `device-code` — an enabled user lost a group that admitted them to a
994
+ * device-code OIDC client ({@link syncDomainAccess} step 4). When the
995
+ * device-code clients cannot be read, any narrowing restarts (fail closed).
996
+ */
997
+ async function restartReason(domains, narrowed) {
998
+ if (!(await autheliaWatchesUsers()) && takeUsersFileDirty()) return 'unwatched';
999
+ if (narrowed.length === 0) return null;
1000
+ let clients;
1001
+ try {
1002
+ clients = await deviceCodeClients(domains);
1003
+ } catch {
1004
+ return 'device-code';
1005
+ }
1006
+ if (clients.length === 0) return null;
1007
+ const lostReach = narrowed.some(({ before, after }) => {
1008
+ const still = new Set(reachedOidcClients(after, clients));
1009
+ return reachedOidcClients(before, clients).some((id) => !still.has(id));
1010
+ });
1011
+ return lostReach ? 'device-code' : null;
1012
+ }
1013
+
1014
+ /**
1015
+ * Follow the users a rendering narrowed: audit each (`directory.access.narrowed`,
1016
+ * by directory id and the number of groups lost — never a username or a
1017
+ * group), and revoke the registry tokens of those who no longer reach the
1018
+ * registry's sign-in. Best effort; returns warnings.
1019
+ */
1020
+ async function followNarrowing(narrowed, logger) {
1021
+ const db = await getStateDb();
1022
+ const ids = new Map(
1023
+ db
1024
+ .prepare("SELECT user_id, username FROM directory_users WHERE status = 'active'")
1025
+ .all()
1026
+ .map((r) => [r.username, r.user_id]),
1027
+ );
1028
+ const warnings = [];
1029
+ for (const { username, lost } of narrowed) {
1030
+ const userId = ids.get(username);
1031
+ if (!userId) continue;
1032
+ await recordAuditBestEffort(
1033
+ {
1034
+ actor: 'lamaste',
1035
+ action: 'directory.access.narrowed',
1036
+ details: { userId, groups: lost.length },
1037
+ },
1038
+ logger,
1039
+ );
1040
+ warnings.push(...(await revokeRegistryTokensFor(username, userId, logger)));
1041
+ }
1042
+ return warnings;
1043
+ }
1044
+
1045
+ /** How soon a rendering a grant change could not finish is tried again. */
1046
+ const RENDER_RETRY_DELAY_MS = 5_000;
1047
+ let renderRetryTimer = null;
1048
+
1049
+ /**
1050
+ * Render once more shortly — after a grant or group change whose rendering
1051
+ * failed, so an external member a revocation narrowed is not left holding
1052
+ * the group for the identity tick's minute. Coalesced, best effort.
1053
+ */
1054
+ export function scheduleRenderRetry(logger) {
1055
+ if (renderRetryTimer) return;
1056
+ renderRetryTimer = setTimeout(() => {
1057
+ renderRetryTimer = null;
1058
+ void syncDomainAccess({ logger }).catch(() => {
1059
+ // Audited by the rendering itself; the identity tick renders again.
1060
+ });
1061
+ }, RENDER_RETRY_DELAY_MS);
1062
+ renderRetryTimer.unref();
1063
+ }
1064
+
1065
+ /** How long a change of tunnels or sites waits before the hosts it names are rendered. */
1066
+ const HOST_RENDER_DELAY_MS = 100;
1067
+ let hostRenderTimer = null;
1068
+
1069
+ /**
1070
+ * After tunnels.json or sites.json changed (lib/state.js): render the
1071
+ * domains once more, shortly — a restricted host's group names the external
1072
+ * members granted it, and a tunnel or web surface that became restricted,
1073
+ * stopped being one or went changes who holds which. Only while some
1074
+ * domain has external members (the host groups name no one else);
1075
+ * coalesced, best effort (the identity tick renders every minute anyway).
1076
+ */
1077
+ export function scheduleHostRender() {
1078
+ if (hostRenderTimer) return;
1079
+ hostRenderTimer = setTimeout(() => {
1080
+ hostRenderTimer = null;
1081
+ void (async () => {
1082
+ const db = await getStateDb();
1083
+ if (!db.prepare("SELECT 1 FROM domain_members WHERE kind = 'external' LIMIT 1").get()) return;
1084
+ await syncDomainAccess();
1085
+ })().catch(() => {
1086
+ // Audited by the rendering itself; the identity tick renders again.
1087
+ });
1088
+ }, HOST_RENDER_DELAY_MS);
1089
+ hostRenderTimer.unref();
1090
+ }
1091
+
1092
+ /** Audit and notify the start and the end of a failure streak (steps only, never members). */
1093
+ async function recordRenderOutcome(failures, logger) {
1094
+ const failing = failures.length > 0;
1095
+ if (failing === renderFailing) return;
1096
+ renderFailing = failing;
1097
+ const steps = failures.map((f) => f.step);
1098
+ await recordAuditBestEffort(
1099
+ failing
1100
+ ? { actor: 'lamaste', action: 'domain.render.failed', details: { steps } }
1101
+ : { actor: 'lamaste', action: 'domain.render.recovered', details: {} },
1102
+ logger,
1103
+ );
1104
+ if (failing) {
1105
+ const { notifyAdminsBestEffort } = await import('./notifications.js');
1106
+ await notifyAdminsBestEffort(
1107
+ {
1108
+ source: 'lamaste',
1109
+ level: 'critical',
1110
+ message: `Who may reach the relay's domains could not be rendered (${steps.join(', ')}): a membership that ended may still let its user in. The panel retries every minute.`,
1111
+ },
1112
+ logger,
1113
+ );
1114
+ }
1115
+ }
1116
+
1117
+ /**
1118
+ * After a change of who may reach a domain: render it and — when the change
1119
+ * reaches a rendering (`effective`: an active user on an active domain) —
1120
+ * drop Gatekeeper's cached session verdicts and recompute the plugins'
1121
+ * audiences (a plugin's audience is its domain's members). Returns the
1122
+ * warnings.
1123
+ */
1124
+ async function settleMembership(logger, { effective = true } = {}) {
1125
+ const warnings = [];
1126
+ try {
1127
+ warnings.push(...((await syncDomainAccess({ logger })).warnings ?? []));
1128
+ } catch (err) {
1129
+ logger?.error({ err: err.message }, 'Rendering domain access failed');
1130
+ warnings.push(...(err.warnings ?? []), {
1131
+ step: 'render',
1132
+ message: `Domain access was not fully rendered (${err.message}); the panel renders it again within a minute`,
1133
+ });
1134
+ }
1135
+ if (!effective) return warnings;
1136
+ const { bustGatekeeperSessions } = await import('./gatekeeper-client.js');
1137
+ const busted = await bustGatekeeperSessions();
1138
+ if (!busted.ok) {
1139
+ warnings.push({
1140
+ step: 'gatekeeper',
1141
+ message: `Gatekeeper did not drop its cached sessions (${busted.error}); they expire within 30 seconds`,
1142
+ });
1143
+ }
1144
+ const { recomputeAudiences } = await import('./audience.js');
1145
+ await recomputeAudiences({ logger });
1146
+ return warnings;
1147
+ }
1148
+
1149
+ /** `{ warnings }` when there are any, else nothing — what a route's answer carries. */
1150
+ const withWarnings = (warnings) => (warnings.length > 0 ? { warnings } : {});
1151
+
1152
+ // ---------------------------------------------------------------------------
1153
+ // Membership
1154
+ // ---------------------------------------------------------------------------
1155
+
1156
+ /**
1157
+ * The directory id `ref` names — an id itself, or the username of a user
1158
+ * the directory holds (not a deleted one) — or a 404.
1159
+ *
1160
+ * @param {import('node:sqlite').DatabaseSync} db
1161
+ * @param {string} ref
1162
+ */
1163
+ function memberUserId(db, ref) {
1164
+ if (isIdentityUuid(ref)) return ref;
1165
+ const row = isLamasteUsername(ref)
1166
+ ? db
1167
+ .prepare("SELECT user_id FROM directory_users WHERE username = ? AND status <> 'removed'")
1168
+ .get(ref)
1169
+ : undefined;
1170
+ if (!row) throw httpError('User not found', 404, { code: 'user_not_found' });
1171
+ return row.user_id;
1172
+ }
1173
+
1174
+ /** The directory row of `userId`, or a 404 / 409 the route answers with. */
1175
+ function memberCandidate(db, userId) {
1176
+ const row = db
1177
+ .prepare('SELECT user_id, status, username FROM directory_users WHERE user_id = ?')
1178
+ .get(userId);
1179
+ if (!row) throw httpError('User not found', 404, { code: 'user_not_found' });
1180
+ if (row.status === 'removed') {
1181
+ throw httpError('The user is deleted', 409, { code: 'user_removed' });
1182
+ }
1183
+ return row;
1184
+ }
1185
+
1186
+ function domainOr404(db, name) {
1187
+ const domain = domainRow(db, name);
1188
+ if (!domain) throw httpError('Domain not found', 404, { code: 'domain_not_found' });
1189
+ return domain;
1190
+ }
1191
+
1192
+ /**
1193
+ * Make user `user` (a directory id or a username) a member of domain
1194
+ * `name` of `kind` (`member` unless named): added, or — when they are one
1195
+ * of the other kind — changed (audited `directory.membership.changed`).
1196
+ * Idempotent (`added: false, changed: false` when they were one of that
1197
+ * kind). Refused (409 `external_member_groups`) when an external
1198
+ * membership would go to a user who holds Authelia groups. Then renders,
1199
+ * busts Gatekeeper's sessions and recomputes the audiences; a member made
1200
+ * external whose access to the registry ends loses their registry tokens.
1201
+ *
1202
+ * @param {string} name
1203
+ * @param {string} user
1204
+ * @param {{ kind?: 'member' | 'external', actor: string, logger?: import('pino').Logger }} opts
1205
+ */
1206
+ export async function addDomainMember(name, user, { kind = 'member', actor, logger }) {
1207
+ const change = await withUsersLock(() =>
1208
+ domainsMutex.run(async () => {
1209
+ const db = await getStateDb();
1210
+ const domain = domainOr404(db, name);
1211
+ const userId = memberUserId(db, user);
1212
+ const candidate = memberCandidate(db, userId);
1213
+ const current = db
1214
+ .prepare('SELECT kind FROM domain_members WHERE domain = ? AND user_id = ?')
1215
+ .get(name, userId)?.kind;
1216
+ if (current === kind) return { userId, added: false, changed: false };
1217
+ await assertNoGroupsForExternal(candidate.username, [{ kind }]);
1218
+ const now = new Date().toISOString();
1219
+ const events =
1220
+ current === undefined
1221
+ ? insertMembershipsSync(db, userId, [{ name, kind }], { actor, now })
1222
+ : changeKindSync(db, userId, name, current, kind);
1223
+ for (const event of events) await recordAuditBestEffort({ actor, ...event }, logger);
1224
+ return {
1225
+ userId,
1226
+ username: candidate.username,
1227
+ added: current === undefined,
1228
+ changed: current !== undefined,
1229
+ external: kind === 'external',
1230
+ effective: domain.status === 'active' && candidate.status === 'active',
1231
+ };
1232
+ }),
1233
+ );
1234
+ if (!change.added && !change.changed) return { added: false, changed: false };
1235
+ const warnings = await settleMembership(logger, { effective: change.effective });
1236
+ return { added: change.added, changed: change.changed, ...withWarnings(warnings) };
1237
+ }
1238
+
1239
+ /**
1240
+ * Change the kind of `userId`'s membership of `domain` from `from` to `to`,
1241
+ * inside the caller's transaction (or alone); returns the audit entry.
1242
+ *
1243
+ * @param {import('node:sqlite').DatabaseSync} db
1244
+ */
1245
+ function changeKindSync(db, userId, domain, from, to) {
1246
+ db.prepare('UPDATE domain_members SET kind = ? WHERE domain = ? AND user_id = ?').run(
1247
+ to,
1248
+ domain,
1249
+ userId,
1250
+ );
1251
+ return [{ action: 'directory.membership.changed', details: { domain, userId, from, to } }];
1252
+ }
1253
+
1254
+ /**
1255
+ * End the membership of user `user` (a directory id or a username) of
1256
+ * domain `name` (`removed: false` when there was none). Then renders, busts
1257
+ * Gatekeeper's sessions, recomputes the audiences, and — when `name` is the
1258
+ * registry's domain — revokes the user's registry tokens.
1259
+ *
1260
+ * @param {string} name
1261
+ * @param {string} user
1262
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
1263
+ */
1264
+ export async function removeDomainMember(name, user, { actor, logger }) {
1265
+ let userId;
1266
+ const removed = await domainsMutex.run(async () => {
1267
+ const db = await getStateDb();
1268
+ const domain = domainOr404(db, name);
1269
+ userId = memberUserId(db, user);
1270
+ const row = db
1271
+ .prepare(
1272
+ `SELECT u.username, u.status FROM domain_members dm JOIN directory_users u USING (user_id)
1273
+ WHERE dm.domain = ? AND dm.user_id = ?`,
1274
+ )
1275
+ .get(name, userId);
1276
+ if (!row) return null;
1277
+ db.prepare('DELETE FROM domain_members WHERE domain = ? AND user_id = ?').run(name, userId);
1278
+ await recordAuditBestEffort(
1279
+ {
1280
+ actor,
1281
+ action: 'directory.membership.removed',
1282
+ details: { domain: name, userId, reason: 'removed' },
1283
+ },
1284
+ logger,
1285
+ );
1286
+ return {
1287
+ username: row.username,
1288
+ effective: domain.status === 'active' && row.status === 'active',
1289
+ };
1290
+ });
1291
+ if (!removed) return { removed: false };
1292
+ const warnings = await settleMembership(logger, { effective: removed.effective });
1293
+ return { removed: true, ...withWarnings(warnings) };
1294
+ }
1295
+
1296
+ /**
1297
+ * Make user `userId`'s memberships exactly `memberships` (a bare domain
1298
+ * name is a `member`) — the users API's edit: the difference applied in one
1299
+ * transaction — memberships added, ended, and changed in kind — each
1300
+ * audited as {@link addDomainMember} / {@link removeDomainMember} audit
1301
+ * them. Refused (409 `external_member_groups`) when it would make a user
1302
+ * who holds Authelia groups an external member. Then one rendering, and the
1303
+ * user's registry tokens revoked when a membership ended or became
1304
+ * external and their access to the registry with it. Throws a 400
1305
+ * `unknown_domain` for a domain the relay does not have, a 404 / 409 for a
1306
+ * user it cannot be.
1307
+ *
1308
+ * @param {string} userId
1309
+ * @param {ReadonlyArray<string | { name: string, kind: 'member' | 'external' }>} memberships
1310
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
1311
+ * @returns {Promise<{ domains: Array<{ name: string, kind: 'member' | 'external' }>, changed: boolean, warnings?: Array<object> }>}
1312
+ */
1313
+ export async function setUserDomains(userId, memberships, { actor, logger }) {
1314
+ const wanted = normalizeMemberships(memberships).sort((a, b) => a.name.localeCompare(b.name));
1315
+ const change = await withUsersLock(() =>
1316
+ domainsMutex.run(async () => {
1317
+ const db = await getStateDb();
1318
+ const known = new Map((await listDomains()).map((d) => [d.name, d]));
1319
+ const unknown = wanted.find((m) => !known.has(m.name));
1320
+ if (unknown !== undefined) {
1321
+ throw httpError(`'${unknown.name}' is not a domain of this relay`, 400, {
1322
+ code: 'unknown_domain',
1323
+ });
1324
+ }
1325
+ const user = memberCandidate(db, userId);
1326
+ const current = new Map(
1327
+ db
1328
+ .prepare('SELECT domain, kind FROM domain_members WHERE user_id = ?')
1329
+ .all(userId)
1330
+ .map((r) => [r.domain, r.kind]),
1331
+ );
1332
+ const wantedKinds = new Map(wanted.map((m) => [m.name, m.kind]));
1333
+ const ended = [...current.keys()].filter((d) => !wantedKinds.has(d));
1334
+ const rekinded = wanted.filter((m) => current.has(m.name) && current.get(m.name) !== m.kind);
1335
+ const added = wanted.filter((m) => !current.has(m.name));
1336
+ if (rekinded.length > 0 || added.length > 0) {
1337
+ await assertNoGroupsForExternal(user.username, [...rekinded, ...added]);
1338
+ }
1339
+ const events = inTransaction(db, () => {
1340
+ const remove = db.prepare('DELETE FROM domain_members WHERE domain = ? AND user_id = ?');
1341
+ for (const domain of ended) remove.run(domain, userId);
1342
+ return [
1343
+ ...insertMembershipsSync(db, userId, added, { actor, now: new Date().toISOString() }),
1344
+ ...rekinded.flatMap((m) =>
1345
+ changeKindSync(db, userId, m.name, current.get(m.name), m.kind),
1346
+ ),
1347
+ ...ended.map((domain) => ({
1348
+ action: 'directory.membership.removed',
1349
+ details: { domain, userId, reason: 'removed' },
1350
+ })),
1351
+ ];
1352
+ });
1353
+ for (const event of events) await recordAuditBestEffort({ actor, ...event }, logger);
1354
+ const touched = events.map((e) => known.get(e.details.domain));
1355
+ return {
1356
+ username: user.username,
1357
+ changed: events.length > 0,
1358
+ effective: user.status === 'active' && touched.some((d) => d?.status === 'active'),
1359
+ };
1360
+ }),
1361
+ );
1362
+ if (!change.changed) return { domains: wanted, changed: false };
1363
+ const warnings = await settleMembership(logger, { effective: change.effective });
1364
+ return { domains: wanted, changed: true, ...withWarnings(warnings) };
1365
+ }
1366
+
1367
+ /**
1368
+ * Whether user `userId` (`username`) still reaches the registry's sign-in:
1369
+ * one of the registry's own sign-in clients — the user-facing OIDC clients
1370
+ * whose audience is the registry (`registryAudience` of its URL) — admits
1371
+ * them: as a member of the client's domain, or as an external member of it
1372
+ * granted the client (a grant directly or through a Lamaste group). No
1373
+ * such client, or one that cannot be read — the plugin manifests,
1374
+ * Gatekeeper — is "no": fail closed.
1375
+ *
1376
+ * @param {string} username
1377
+ * @param {string} userId
1378
+ * @param {{ registryUrl: string }} config the stored registry credential
1379
+ */
1380
+ async function reachesRegistry(username, userId, config) {
1381
+ let audience;
1382
+ try {
1383
+ audience = registryAudience(config.registryUrl);
1384
+ } catch {
1385
+ return false;
1386
+ }
1387
+ const { planOidcClients } = await import('./plugin-oidc.js');
1388
+ const { isUserFacingOidcClient, readGrantedResources, OIDC_CLIENT_RESOURCE } =
1389
+ await import('./owned-groups.js');
1390
+ let signIn;
1391
+ try {
1392
+ signIn = (await planOidcClients()).served.filter(
1393
+ (c) =>
1394
+ isUserFacingOidcClient(c) &&
1395
+ c.audience.some((a) => a.replaceAll('{domain}', c.domain) === audience),
1396
+ );
1397
+ } catch {
1398
+ return false;
1399
+ }
1400
+ if (signIn.length === 0) return false;
1401
+ const db = await getStateDb();
1402
+ const kinds = new Map(
1403
+ db
1404
+ .prepare('SELECT domain, kind FROM domain_members WHERE user_id = ?')
1405
+ .all(userId)
1406
+ .map((r) => [r.domain, r.kind]),
1407
+ );
1408
+ if (signIn.some((c) => kinds.get(c.domain) === 'member')) return true;
1409
+ const external = signIn.filter((c) => kinds.get(c.domain) === 'external');
1410
+ if (external.length === 0) return false;
1411
+ const read = await readGrantedResources();
1412
+ if (!read.ok) return false;
1413
+ return external.some((c) => read.granted[OIDC_CLIENT_RESOURCE].get(c.id)?.has(username));
1414
+ }
1415
+
1416
+ /**
1417
+ * After a user's access narrowed — a membership ended or became external, a
1418
+ * grant on an OIDC client went: revoke their registry tokens when they no
1419
+ * longer reach the registry's sign-in ({@link reachesRegistry}) — the
1420
+ * sign-in that minted the tokens would refuse them now. Best effort;
1421
+ * returns warnings.
1422
+ *
1423
+ * @param {string} username
1424
+ * @param {string} userId
1425
+ * @param {import('pino').Logger} [logger]
1426
+ */
1427
+ export async function revokeRegistryTokensFor(username, userId, logger) {
1428
+ try {
1429
+ const { readRegistryCredentialConfig } = await import('./registry-credential.js');
1430
+ const config = await readRegistryCredentialConfig();
1431
+ if (!config) return [];
1432
+ if (await reachesRegistry(username, userId, config)) return [];
1433
+ return await revokeTokensOf([{ username, userId }], config, logger);
1434
+ } catch (err) {
1435
+ logger?.warn({ userId, err: err.message }, 'Registry tokens were not revoked');
1436
+ return [{ step: 'registry', message: `Registry tokens were not revoked: ${err.message}` }];
1437
+ }
1438
+ }
1439
+
1440
+ /** At most this many registry revocations in flight. */
1441
+ const REVOCATION_CONCURRENCY = 4;
1442
+
1443
+ /** Revoke the registry tokens of `users` against `config`, a few at a time; returns warnings. */
1444
+ async function revokeTokensOf(users, config, logger) {
1445
+ const { revokeUserRegistryTokens } = await import('./registry-credential.js');
1446
+ const warnings = [];
1447
+ const queue = [...users];
1448
+ const worker = async () => {
1449
+ for (let user = queue.shift(); user; user = queue.shift()) {
1450
+ try {
1451
+ await revokeUserRegistryTokens(user.username, { deleted: true }, logger, {
1452
+ userId: user.userId,
1453
+ config,
1454
+ });
1455
+ } catch (err) {
1456
+ logger?.warn({ userId: user.userId, err: err.message }, 'Registry tokens were not revoked');
1457
+ warnings.push({
1458
+ step: 'registry',
1459
+ message: `Registry tokens were not revoked: ${err.message}`,
1460
+ });
1461
+ }
1462
+ }
1463
+ };
1464
+ await Promise.all(Array.from({ length: Math.min(REVOCATION_CONCURRENCY, users.length) }, worker));
1465
+ return warnings;
1466
+ }
1467
+
1468
+ /**
1469
+ * Revoke the registry tokens of every user who is a member or an external
1470
+ * member of none of `domains` — after the registry credential changed its
1471
+ * domains: who may hold a token follows them (an external member of one
1472
+ * whose client grant does not reach the registry is revoked when the grant
1473
+ * goes, {@link revokeRegistryTokensFor}). Best effort; returns warnings.
1474
+ *
1475
+ * @param {readonly string[]} domains
1476
+ * @param {object} config the stored credential
1477
+ * @param {import('pino').Logger} [logger]
1478
+ */
1479
+ export async function revokeRegistryTokensOutside(domains, config, logger) {
1480
+ const db = await getStateDb();
1481
+ const placeholders = domains.map(() => '?').join(', ') || "''";
1482
+ const outside = db
1483
+ .prepare(
1484
+ `SELECT u.user_id AS userId, u.username FROM directory_users u
1485
+ WHERE u.status != 'removed' AND u.username IS NOT NULL
1486
+ AND NOT EXISTS (SELECT 1 FROM domain_members m
1487
+ WHERE m.user_id = u.user_id AND m.domain IN (${placeholders}))`,
1488
+ )
1489
+ .all(...domains);
1490
+ return revokeTokensOf(outside, config, logger);
1491
+ }
1492
+
1493
+ // ---------------------------------------------------------------------------
1494
+ // Domain lifecycle
1495
+ // ---------------------------------------------------------------------------
1496
+
1497
+ /**
1498
+ * Change the set of domains — add one, activate one, remove one — holding
1499
+ * the tunnel lock, then the sites lock, then the domains lock: the locks
1500
+ * under which tunnels, sites and plugin moves resolve their domain, and
1501
+ * under which a site or an alias takes a hostname. So no hostname is taken
1502
+ * on a domain between the check of its relay hostnames and its insertion,
1503
+ * and nothing lands on a domain between its usage check and its removal.
1504
+ * Keep slow work (certificates, nginx reloads) outside.
1505
+ *
1506
+ * @template T
1507
+ * @param {() => Promise<T>} fn
1508
+ * @returns {Promise<T>}
1509
+ */
1510
+ function changeDomains(fn) {
1511
+ return withTunnelLock(() => withSitesLock(() => domainsMutex.run(fn)));
1512
+ }
1513
+
1514
+ /**
1515
+ * Add domain `name` (`pending`): validated, refused when it overlaps
1516
+ * another, shares a registrable domain with one or is a public suffix
1517
+ * (400 `invalid_domain` / `public_suffix`, 409 `domain_overlap` /
1518
+ * `same_registrable_domain`), or while a tunnel, site or alias holds one of
1519
+ * its relay hostnames (409 `relay_host_taken`).
1520
+ *
1521
+ * @param {string} name
1522
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
1523
+ * @returns {Promise<Domain>}
1524
+ */
1525
+ export async function addDomain(name, { actor, logger }) {
1526
+ return changeDomains(async () => {
1527
+ const db = await getStateDb();
1528
+ const existing = db
1529
+ .prepare('SELECT name FROM domains')
1530
+ .all()
1531
+ .map((r) => r.name);
1532
+ try {
1533
+ assertDomainRegistrable(name, existing);
1534
+ } catch (err) {
1535
+ if (err.code === 'DOMAIN_OVERLAP' || err.code === 'SAME_REGISTRABLE_DOMAIN') {
1536
+ throw httpError(err.message, 409, { code: err.code.toLowerCase() });
1537
+ }
1538
+ throw httpError(err.message, 400, {
1539
+ code: err.code === 'PUBLIC_SUFFIX' ? 'public_suffix' : 'invalid_domain',
1540
+ });
1541
+ }
1542
+ await assertRelayHostsFree(name);
1543
+ db.prepare(
1544
+ `INSERT INTO domains (name, is_primary, status, created_at, group_key)
1545
+ VALUES (?, 0, 'pending', ?, ?)`,
1546
+ ).run(name, new Date().toISOString(), newGroupKey());
1547
+ await recordAuditBestEffort(
1548
+ { actor, action: 'domain.added', details: { domain: name, primary: false } },
1549
+ logger,
1550
+ );
1551
+ return domainRow(db, name);
1552
+ });
1553
+ }
1554
+
1555
+ /**
1556
+ * Check the DNS a domain needs: `auth.<domain>` must resolve to the relay.
1557
+ * Reports whether a wildcard record is in place too (a probe name), which
1558
+ * every tunnel and site hostname then resolves through.
1559
+ *
1560
+ * @param {string} name
1561
+ */
1562
+ export async function verifyDomainDns(name) {
1563
+ domainOr404(await getStateDb(), name);
1564
+ const expectedIp = getConfig().ip;
1565
+ const authName = `auth.${name}`;
1566
+ const [auth, wildcard] = await Promise.all([
1567
+ resolveHostA(authName),
1568
+ resolveHostA(`${DNS_WILDCARD_PROBE_LABEL}.${name}`),
1569
+ ]);
1570
+ const record = (name, resolvedIps, required, purpose) => ({
1571
+ type: 'A',
1572
+ name,
1573
+ value: expectedIp,
1574
+ required,
1575
+ purpose,
1576
+ resolvedIps,
1577
+ ok: resolvedIps.includes(expectedIp),
1578
+ });
1579
+ const records = [
1580
+ record(authName, auth, true, 'the domain’s sign-in page'),
1581
+ // Probed through a name nothing else uses.
1582
+ record(
1583
+ `*.${name}`,
1584
+ wildcard,
1585
+ false,
1586
+ 'every tunnel and site hostname — or one A record per hostname instead',
1587
+ ),
1588
+ ];
1589
+ return { ok: records.every((r) => !r.required || r.ok), expectedIp, records };
1590
+ }
1591
+
1592
+ /** What activation needs from nginx and certbot (the real ones unless a test gives others). */
1593
+ async function activationDeps() {
1594
+ if (activationOverride) return activationOverride;
1595
+ const nginx = await import('./nginx.js');
1596
+ const certbot = await import('./certbot.js');
1597
+ return {
1598
+ issueCert: certbot.issueCert,
1599
+ writeAuthVhost: nginx.writeAuthVhost,
1600
+ removeAuthVhost: nginx.removeAuthVhost,
1601
+ };
1602
+ }
1603
+
1604
+ let activationOverride = null;
1605
+
1606
+ /** Replace nginx and certbot for activation and removal (tests). */
1607
+ export function setDomainActivationForTests(deps) {
1608
+ activationOverride = deps;
1609
+ }
1610
+
1611
+ /**
1612
+ * Activate domain `name`: issue `auth.<domain>`'s certificate, write and
1613
+ * link its auth vhost, mark it active and render — its session cookie, its
1614
+ * members' rules and Gatekeeper's projection. One operation: when a step
1615
+ * fails the vhost goes and the domain stays pending. Idempotent on an
1616
+ * active domain.
1617
+ *
1618
+ * @param {string} name
1619
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
1620
+ * @returns {Promise<Domain>}
1621
+ */
1622
+ export async function activateDomain(name, { actor, logger }) {
1623
+ const pending = await domainsMutex.run(async () => {
1624
+ const domain = domainOr404(await getStateDb(), name);
1625
+ return domain.status === 'active' ? null : domain;
1626
+ });
1627
+ if (pending === null) return getDomain(name);
1628
+ const { email } = getConfig();
1629
+ if (!email) throw httpError('The panel has no email for certificates', 400);
1630
+ const deps = await activationDeps();
1631
+ const fqdn = `auth.${name}`;
1632
+ // Outside the locks: certbot takes its time, and an extra certificate of
1633
+ // a domain removed meanwhile is harmless.
1634
+ try {
1635
+ await deps.issueCert(fqdn, email);
1636
+ } catch (err) {
1637
+ throw httpError(`The certificate of ${fqdn} could not be issued: ${err.message}`, 502, {
1638
+ code: 'cert_failed',
1639
+ });
1640
+ }
1641
+ const activated = await changeDomains(async () => {
1642
+ const db = await getStateDb();
1643
+ // Checked again under the locks: removed, activated or its relay
1644
+ // hostnames taken while the certificate was issued.
1645
+ const domain = domainOr404(db, name);
1646
+ if (domain.status === 'active') return null;
1647
+ await assertRelayHostsFree(name);
1648
+ try {
1649
+ await deps.writeAuthVhost(name, { primary: false });
1650
+ } catch (err) {
1651
+ throw httpError(`nginx refused the vhost of ${fqdn}: ${err.message}`, 502, {
1652
+ code: 'nginx_failed',
1653
+ });
1654
+ }
1655
+ db.prepare("UPDATE domains SET status = 'active', activated_at = ? WHERE name = ?").run(
1656
+ new Date().toISOString(),
1657
+ name,
1658
+ );
1659
+ try {
1660
+ await syncDomainAccess({ logger });
1661
+ } catch (err) {
1662
+ db.prepare("UPDATE domains SET status = 'pending', activated_at = NULL WHERE name = ?").run(
1663
+ name,
1664
+ );
1665
+ await deps.removeAuthVhost(name).catch(() => {});
1666
+ // Back to what it was before: the other domains as they were.
1667
+ await syncDomainAccess({ logger }).catch(() => {});
1668
+ throw httpError(`Authelia was not updated: ${err.message}`, 502, { code: 'authelia_failed' });
1669
+ }
1670
+ return domainRow(db, name);
1671
+ });
1672
+ if (activated === null) return getDomain(name);
1673
+ await recordAuditBestEffort(
1674
+ { actor, action: 'domain.activated', details: { domain: name } },
1675
+ logger,
1676
+ );
1677
+ const { bustGatekeeperSessions } = await import('./gatekeeper-client.js');
1678
+ await bustGatekeeperSessions();
1679
+ return activated;
1680
+ }
1681
+
1682
+ /**
1683
+ * What uses each of `names`: tunnels and sites (a hostname or an alias
1684
+ * under it), plugins that belong to it, the registry credential's issuer —
1685
+ * the state read once.
1686
+ *
1687
+ * @param {readonly string[]} names
1688
+ * @returns {Promise<Map<string, { tunnels: string[], sites: string[], plugins: string[], registry: boolean }>>}
1689
+ */
1690
+ async function usageByDomain(names) {
1691
+ const usage = new Map(
1692
+ names.map((n) => [n, { tunnels: [], sites: [], plugins: [], registry: false }]),
1693
+ );
1694
+ const of = (host) => (typeof host === 'string' ? usage.get(domainOfHost(host, names)) : null);
1695
+ for (const t of await readTunnels()) of(t.fqdn)?.tunnels.push(t.fqdn);
1696
+ for (const s of await readSites()) {
1697
+ const hosts = [s.fqdn, ...(s.aliases ?? [])];
1698
+ for (const name of new Set(hosts.map((h) => domainOfHost(h, names)).filter(Boolean))) {
1699
+ usage.get(name).sites.push(s.fqdn);
1700
+ }
1701
+ }
1702
+ const db = await getStateDb();
1703
+ for (const row of db.prepare('SELECT name, domain FROM plugins ORDER BY name').all()) {
1704
+ if (row.domain) usage.get(row.domain)?.plugins.push(row.name);
1705
+ }
1706
+ for (const domain of await registryDomains().catch(() => [])) {
1707
+ if (usage.has(domain)) usage.get(domain).registry = true;
1708
+ }
1709
+ return usage;
1710
+ }
1711
+
1712
+ /**
1713
+ * What uses domain `name`: tunnels and sites (a hostname or an alias under
1714
+ * it), plugins that belong to it, the registry credential's issuer.
1715
+ *
1716
+ * @param {string} name
1717
+ */
1718
+ export async function domainUsage(name) {
1719
+ return (await usageByDomain([name])).get(name);
1720
+ }
1721
+
1722
+ /**
1723
+ * Remove domain `name`: refused for the primary (400 `primary_domain`) and
1724
+ * while anything uses it (409 `domain_in_use` with `usedBy`). Its
1725
+ * memberships end (audited, reason `domain-removed`), its auth vhost goes,
1726
+ * and the rest is rendered without it.
1727
+ *
1728
+ * @param {string} name
1729
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
1730
+ */
1731
+ export async function removeDomain(name, { actor, logger }) {
1732
+ // The usage check and the deletion alone hold the locks (see
1733
+ // changeDomains); audits and nginx come after.
1734
+ const removed = await changeDomains(async () => {
1735
+ const db = await getStateDb();
1736
+ const domain = domainOr404(db, name);
1737
+ if (domain.primary) {
1738
+ throw httpError('The primary domain cannot be removed', 400, { code: 'primary_domain' });
1739
+ }
1740
+ const usedBy = await domainUsage(name);
1741
+ const { tunnels, sites, plugins, registry } = usedBy;
1742
+ if (tunnels.length + sites.length + plugins.length > 0 || registry) {
1743
+ throw httpError(`Domain '${name}' is in use`, 409, { code: 'domain_in_use', usedBy });
1744
+ }
1745
+ const members = inTransaction(db, () => {
1746
+ const ended = db
1747
+ .prepare('DELETE FROM domain_members WHERE domain = ? RETURNING user_id')
1748
+ .all(name)
1749
+ .map((r) => r.user_id);
1750
+ db.prepare('DELETE FROM domains WHERE name = ?').run(name);
1751
+ db.prepare(
1752
+ `INSERT INTO retired_domains (name, removed_at) VALUES (?, ?)
1753
+ ON CONFLICT (name) DO UPDATE SET removed_at = excluded.removed_at`,
1754
+ ).run(name, new Date().toISOString());
1755
+ return ended;
1756
+ });
1757
+ return { active: domain.status === 'active', members };
1758
+ });
1759
+ for (const userId of removed.members) {
1760
+ await recordAuditBestEffort(
1761
+ {
1762
+ actor,
1763
+ action: 'directory.membership.removed',
1764
+ details: { domain: name, userId, reason: 'domain-removed' },
1765
+ },
1766
+ logger,
1767
+ );
1768
+ }
1769
+ await recordAuditBestEffort(
1770
+ { actor, action: 'domain.removed', details: { domain: name, members: removed.members.length } },
1771
+ logger,
1772
+ );
1773
+ if (removed.active) {
1774
+ // Unless the name was added again meanwhile — its new vhost is its own.
1775
+ await domainsMutex.run(async () => {
1776
+ if (domainRow(await getStateDb(), name)) return;
1777
+ const deps = await activationDeps();
1778
+ await deps.removeAuthVhost(name).catch((err) => {
1779
+ logger?.warn(
1780
+ { domain: name, err: err.message },
1781
+ 'The auth vhost of a removed domain stayed',
1782
+ );
1783
+ });
1784
+ });
1785
+ }
1786
+ // No registry token can depend on a removed domain: the registry's
1787
+ // domains cannot be removed (`domain_in_use`).
1788
+ return {
1789
+ removed: true,
1790
+ ...withWarnings(await settleMembership(logger, { effective: removed.active })),
1791
+ };
1792
+ }