@lamalibre/create-lamaste 4.4.1 → 4.5.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/lamaste-priv +200 -16
  3. package/src/index.js +1 -0
  4. package/src/lib/backend-cookie.js +65 -0
  5. package/src/lib/ip-panel-vhost.js +208 -0
  6. package/src/lib/service-config.js +5 -3
  7. package/src/tasks/nginx.js +18 -135
  8. package/src/tasks/redeploy.js +39 -1
  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 +1 -1
  13. package/vendor/docs/01-concepts/authorization.md +66 -48
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/mtls.md +2 -2
  16. package/vendor/docs/01-concepts/security-model.md +70 -33
  17. package/vendor/docs/01-concepts/tickets.md +2 -2
  18. package/vendor/docs/01-concepts/tunneling.md +4 -4
  19. package/vendor/docs/02-guides/agent-setup.md +5 -5
  20. package/vendor/docs/02-guides/certificate-management.md +2 -2
  21. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  22. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  23. package/vendor/docs/02-guides/installation.md +3 -3
  24. package/vendor/docs/02-guides/managing-users.md +113 -25
  25. package/vendor/docs/02-guides/onboarding.md +2 -2
  26. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  27. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  28. package/vendor/docs/03-architecture/installer.md +4 -4
  29. package/vendor/docs/03-architecture/management-flow.md +3 -1
  30. package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
  31. package/vendor/docs/03-architecture/overview.md +13 -17
  32. package/vendor/docs/03-architecture/panel-client.md +6 -6
  33. package/vendor/docs/03-architecture/panel-server.md +3 -2
  34. package/vendor/docs/03-architecture/state-management.md +4 -8
  35. package/vendor/docs/03-architecture/system-overview.md +1 -1
  36. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +291 -22
  37. package/vendor/docs/04-api-reference/app-host.md +3 -3
  38. package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
  39. package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  42. package/vendor/docs/04-api-reference/tunnels.md +67 -29
  43. package/vendor/docs/04-api-reference/users.md +241 -45
  44. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  45. package/vendor/docs/05-operations/monitoring.md +39 -0
  46. package/vendor/docs/05-operations/uninstalling.md +2 -1
  47. package/vendor/docs/05-operations/upgrades.md +15 -6
  48. package/vendor/docs/06-reference/config-files.md +82 -188
  49. package/vendor/docs/06-reference/glossary.md +3 -3
  50. package/vendor/docs/06-reference/installer-flags.md +13 -13
  51. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  52. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  53. package/vendor/docs/gatekeeper-design.md +10 -8
  54. package/vendor/docs/identity-directory-design.md +48 -14
  55. package/vendor/docs/package.json +1 -1
  56. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  57. package/vendor/gatekeeper/dist/index.d.ts +2 -1
  58. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  59. package/vendor/gatekeeper/dist/index.js +2 -0
  60. package/vendor/gatekeeper/dist/index.js.map +1 -1
  61. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  62. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  63. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  64. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  65. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  66. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  68. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  70. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  72. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
  74. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  75. package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
  76. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  77. package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
  78. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  80. package/vendor/gatekeeper/dist/server/index.js +22 -48
  81. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  83. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
  85. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  88. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  89. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  90. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  91. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  92. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  93. package/vendor/gatekeeper/package.json +2 -2
  94. package/vendor/server/bin/lamaste-server.js +15 -1
  95. package/vendor/server/package.json +2 -2
  96. package/vendor/server/src/commands/plugin-identity.js +335 -0
  97. package/vendor/server/src/commands/plugins.js +99 -11
  98. package/vendor/server/src/commands/registry-credential.js +2 -1
  99. package/vendor/server/src/commands/tunnels.js +102 -2
  100. package/vendor/server/src/commands/uninstall.js +3 -1
  101. package/vendor/server/src/commands/users.js +358 -0
  102. package/vendor/server/src/plugin-approval.js +107 -5
  103. package/vendor/server/src/plugin-identity.js +382 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
  106. package/vendor/server-ui/dist/index.html +2 -2
  107. package/vendor/serverd/package.json +2 -2
  108. package/vendor/serverd/src/index.js +25 -1
  109. package/vendor/serverd/src/lib/audience.js +576 -0
  110. package/vendor/serverd/src/lib/authelia.js +78 -7
  111. package/vendor/serverd/src/lib/config.js +68 -43
  112. package/vendor/serverd/src/lib/directory.js +1113 -0
  113. package/vendor/serverd/src/lib/entitlements.js +517 -0
  114. package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
  115. package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
  116. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  117. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  118. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  119. package/vendor/serverd/src/lib/mtls.js +6 -2
  120. package/vendor/serverd/src/lib/nginx.js +279 -48
  121. package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
  122. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  123. package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
  124. package/vendor/serverd/src/lib/plugins.js +59 -4
  125. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  126. package/vendor/serverd/src/lib/request-logging.js +19 -0
  127. package/vendor/serverd/src/lib/retained-data.js +50 -4
  128. package/vendor/serverd/src/lib/state.js +18 -0
  129. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  130. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  131. package/vendor/serverd/src/lib/user-caller.js +67 -0
  132. package/vendor/serverd/src/lib/user-surface.js +733 -0
  133. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  134. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  135. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  136. package/vendor/serverd/src/routes/invite.js +53 -12
  137. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  138. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  139. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  140. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  141. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  142. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  143. package/vendor/serverd/src/routes/management/users.js +290 -208
  144. package/vendor/serverd/src/routes/management.js +4 -0
  145. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  146. package/vendor/serverd/src/routes/user-access.js +17 -4
  147. package/vendor/serverd/src/routes/user-surface.js +69 -0
  148. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  149. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
@@ -0,0 +1,1113 @@
1
+ /**
2
+ * The user directory — every user the panel knows, by a stable id.
3
+ *
4
+ * Authelia's users.yml stays its credential file and keys users by a
5
+ * username anyone can reuse. The directory (state.db, migration 0013) gives
6
+ * each user a UUID v4 the moment their username appears and keeps the row
7
+ * for good: plugins key their data on the id, and a username that comes
8
+ * back after a deletion is a new user with a new id. A row is `active`,
9
+ * `disabled` (Authelia's `disabled: true` — the one offboarding action) or
10
+ * `removed` — deleted through Lamaste (`deleted`) or gone from users.yml
11
+ * without it (`deleted-outside`) — and a removed row keeps the username,
12
+ * display name and email it last had, so history stays readable, until an
13
+ * administrator erases it ({@link eraseDirectoryUser}).
14
+ *
15
+ * users.yml is never written from here. Every write of it goes through
16
+ * {@link changeUsers}: one in-process lock around the read-modify-write,
17
+ * then the directory is reconciled against the file, then Authelia
18
+ * restarts, then the plugins' audiences are recomputed. The reconcile
19
+ * ({@link reconcileDirectory}) also runs at start, every minute and after
20
+ * onboarding — read only: writing users.yml restarts Authelia and ends
21
+ * every session. It adopts what changed outside Lamaste (Authelia's own
22
+ * password reset rewrites the file; an operator may edit it), and only
23
+ * after reading the file twice does it take a user for deleted outside —
24
+ * Authelia rewrites the file in place, and a read that met a half-written
25
+ * file must not tombstone anyone.
26
+ *
27
+ * Gatekeeper keys users by username too: the grants naming a user and
28
+ * their memberships of Lamaste groups would pass to anyone who later holds
29
+ * the username. Every removed user's principal is therefore cleared in
30
+ * Gatekeeper (`principal_cleared_at`): when the user is removed (through
31
+ * Lamaste or outside it), then every minute while it is pending
32
+ * (Gatekeeper down), before an erasure scrubs the username it needs, and
33
+ * whenever a user is created under a username a removed user held — always
34
+ * then, pending or not: a restored gatekeeper.db or a grant that raced the
35
+ * deletion can bring grants back (refused while it cannot complete). A
36
+ * username a removed user held and nobody holds now takes no new grant or
37
+ * group membership (409 `username_deleted`), and grants are created under
38
+ * the users lock, so none races a deletion. While a username's cleanup is
39
+ * pending it holds no role in any plugin.
40
+ *
41
+ * Authelia keys a user's second factors and the OpenID Connect identity it
42
+ * gives relying parties by username as well (`authelia-user-storage.ts` in
43
+ * core): a removed user's TOTP configuration and WebAuthn credentials are
44
+ * deleted when they are removed and again before a user is created under
45
+ * the username, and a username Authelia holds an opaque identifier for is
46
+ * never given to a new user (409 `username_has_oidc_identity`) — Authelia
47
+ * cannot delete one, and every relying party would take the new user for
48
+ * the old.
49
+ *
50
+ * **Offboarding** ({@link offboardUserUnlocked}, keyed by user id) is one
51
+ * function for every way a user stops being one — disabled or removed,
52
+ * through the users API or adopted from users.yml by a reconcile: the
53
+ * user's desktop sessions end (their session epoch, panel.json keyed by
54
+ * user id), their registry tokens are revoked, and for a removal their
55
+ * legacy user-plugin-access grants, their Gatekeeper principal and their
56
+ * Authelia second factors go; Gatekeeper's cached session verdicts are
57
+ * dropped. Every reconcile offboards exactly the users it saw change.
58
+ *
59
+ * Audit entries name users by their id alone: never a username, a name or
60
+ * an address.
61
+ */
62
+
63
+ import crypto from 'node:crypto';
64
+ import { PromiseChainMutex } from '@lamalibre/lamaste';
65
+ import { httpError } from './app-error.js';
66
+ import { recordAuditBestEffort } from './audit.js';
67
+ import {
68
+ autheliaHoldsOidcIdentity,
69
+ purgeAutheliaUserSecondFactors,
70
+ readUsersRaw,
71
+ reloadAuthelia,
72
+ } from './authelia.js';
73
+ import { bumpUserEpoch } from './config.js';
74
+ import {
75
+ bustGatekeeperSessions,
76
+ purgeAccessLogForUser,
77
+ removeUserPrincipal,
78
+ } from './gatekeeper-client.js';
79
+ import { revokeUserRegistryTokens } from './registry-credential.js';
80
+ import { getStateDb } from './state-db.js';
81
+ import { readInvitations, withInvitationsLock, writeInvitations } from './state.js';
82
+ import { removeGrantsForUser } from './user-access.js';
83
+
84
+ /** How long the reconcile waits before reading users.yml again to confirm a user is gone. */
85
+ const DIRECTORY_CONFIRM_DELAY_MS = 500;
86
+
87
+ /** A logger for callees that need one when the caller passed none. */
88
+ const SILENT_LOGGER = Object.freeze({ info() {}, warn() {}, error() {} });
89
+
90
+ /** The Authelia group whose enabled members administer Lamaste. */
91
+ export const ADMIN_GROUP = 'admins';
92
+
93
+ /**
94
+ * A user as the directory keeps them.
95
+ *
96
+ * @typedef {object} DirectoryUser
97
+ * @property {string} userId UUID v4
98
+ * @property {string | null} username null once erased
99
+ * @property {string | null} displayName
100
+ * @property {string | null} email
101
+ * @property {'active' | 'disabled' | 'removed'} status
102
+ * @property {'deleted' | 'deleted-outside' | null} removedReason
103
+ * @property {string | null} removedAt
104
+ * @property {string | null} erasedAt
105
+ * @property {string | null} principalClearedAt when Gatekeeper dropped what it held under the
106
+ * username (removed users only; null on a removed user: pending)
107
+ * @property {number} version
108
+ * @property {string} createdAt
109
+ * @property {string} updatedAt
110
+ */
111
+
112
+ /**
113
+ * @typedef {object} DirectoryReconcileResult
114
+ * @property {number} created
115
+ * @property {number} updated
116
+ * @property {Array<{ userId: string, username: string, reason: 'deleted' | 'deleted-outside' }>} removed
117
+ * in memory only — never logged or audited with the username
118
+ * @property {string[]} disabled the ids of users this reconcile saw become disabled
119
+ */
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // The users lock
123
+ // ---------------------------------------------------------------------------
124
+
125
+ const usersMutex = new PromiseChainMutex();
126
+
127
+ /**
128
+ * Run `fn` holding the users lock — the one lock around every
129
+ * read-modify-write of users.yml and every change of the directory in this
130
+ * process (core's users functions take none).
131
+ *
132
+ * @template T
133
+ * @param {() => Promise<T>} fn
134
+ * @returns {Promise<T>}
135
+ */
136
+ function withUsersLock(fn) {
137
+ return usersMutex.run(fn);
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Reading
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /** @returns {DirectoryUser} */
145
+ function rowToUser(row) {
146
+ return {
147
+ userId: row.user_id,
148
+ username: row.username,
149
+ displayName: row.display_name,
150
+ email: row.email,
151
+ status: row.status,
152
+ removedReason: row.removed_reason,
153
+ removedAt: row.removed_at,
154
+ erasedAt: row.erased_at,
155
+ principalClearedAt: row.principal_cleared_at,
156
+ version: row.version,
157
+ createdAt: row.created_at,
158
+ updatedAt: row.updated_at,
159
+ };
160
+ }
161
+
162
+ /** The directory's id (UUID v4), made with the store. */
163
+ export async function getDirectoryId() {
164
+ const db = await getStateDb();
165
+ const row = db.prepare('SELECT directory_id FROM directory_meta WHERE id = 1').get();
166
+ if (!row) throw new Error('The user directory has no id (state.db migration 0013 missing)');
167
+ return row.directory_id;
168
+ }
169
+
170
+ /**
171
+ * Every user the directory knows — removed and erased ones too: members
172
+ * first by username, then the removed, then the erased.
173
+ *
174
+ * @returns {Promise<DirectoryUser[]>}
175
+ */
176
+ export async function listDirectoryUsers() {
177
+ const db = await getStateDb();
178
+ return db
179
+ .prepare(
180
+ `SELECT * FROM directory_users
181
+ ORDER BY status = 'removed', erased_at IS NOT NULL, username, created_at, user_id`,
182
+ )
183
+ .all()
184
+ .map(rowToUser);
185
+ }
186
+
187
+ /**
188
+ * One user by id, or null.
189
+ *
190
+ * @param {string} userId
191
+ * @returns {Promise<DirectoryUser | null>}
192
+ */
193
+ export async function getDirectoryUser(userId) {
194
+ const db = await getStateDb();
195
+ const row = db.prepare('SELECT * FROM directory_users WHERE user_id = ?').get(userId);
196
+ return row ? rowToUser(row) : null;
197
+ }
198
+
199
+ /**
200
+ * The user who holds `username` now (active or disabled), or null — a
201
+ * removed user keeps their username but no longer holds it.
202
+ *
203
+ * @param {string} username
204
+ * @returns {Promise<DirectoryUser | null>}
205
+ */
206
+ export async function findDirectoryUser(username) {
207
+ const db = await getStateDb();
208
+ const row = db
209
+ .prepare("SELECT * FROM directory_users WHERE username = ? AND status <> 'removed'")
210
+ .get(username);
211
+ return row ? rowToUser(row) : null;
212
+ }
213
+
214
+ /**
215
+ * The users who hold a username now, by username.
216
+ *
217
+ * @returns {Promise<Map<string, DirectoryUser>>}
218
+ */
219
+ export async function directoryUsersByUsername() {
220
+ const db = await getStateDb();
221
+ const rows = db.prepare("SELECT * FROM directory_users WHERE status <> 'removed'").all();
222
+ return new Map(rows.map((row) => [row.username, rowToUser(row)]));
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Gatekeeper principals of removed users
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * The usernames whose Gatekeeper principal is still to be cleared — of
231
+ * removed users whose cleanup has not succeeded yet. No role is counted for
232
+ * them, whoever holds the username now.
233
+ *
234
+ * @returns {Promise<Set<string>>}
235
+ */
236
+ export async function pendingPrincipalCleanups() {
237
+ const db = await getStateDb();
238
+ return new Set(
239
+ db
240
+ .prepare(
241
+ `SELECT DISTINCT username FROM directory_users
242
+ WHERE status = 'removed' AND principal_cleared_at IS NULL AND username IS NOT NULL`,
243
+ )
244
+ .all()
245
+ .map((row) => row.username),
246
+ );
247
+ }
248
+
249
+ /**
250
+ * Whether a removed user (not erased: an erased one no longer has a
251
+ * username) held `username`.
252
+ *
253
+ * @param {string} username
254
+ */
255
+ async function usernameHasTombstone(username) {
256
+ const db = await getStateDb();
257
+ return (
258
+ db
259
+ .prepare("SELECT 1 FROM directory_users WHERE username = ? AND status = 'removed'")
260
+ .get(username) !== undefined
261
+ );
262
+ }
263
+
264
+ /**
265
+ * Whether a removed user held `username` and Gatekeeper may still hold
266
+ * their grants or group memberships under it.
267
+ *
268
+ * @param {string} username
269
+ */
270
+ export async function principalCleanupPending(username) {
271
+ const db = await getStateDb();
272
+ return (
273
+ db
274
+ .prepare(
275
+ `SELECT 1 FROM directory_users
276
+ WHERE username = ? AND status = 'removed' AND principal_cleared_at IS NULL`,
277
+ )
278
+ .get(username) !== undefined
279
+ );
280
+ }
281
+
282
+ /** The longest username a grant or a group member can name: Gatekeeper holds nothing longer. */
283
+ const GATEKEEPER_PRINCIPAL_MAX = 100;
284
+
285
+ /**
286
+ * Have Gatekeeper drop everything it holds under `username` and record it
287
+ * on every removed user whose cleanup was pending under it — users lock
288
+ * held. A username Gatekeeper cannot hold needs no call. Returns
289
+ * `{ ok: true }` or the failure (`error`, never thrown); audited by user id.
290
+ *
291
+ * @param {string} username
292
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
293
+ * @returns {Promise<{ ok: true, grantsRemoved: number, groupsLeft: number } | { ok: false, error: string }>}
294
+ */
295
+ async function clearPrincipalUnlocked(username, { actor, logger }) {
296
+ let removed = { grantsRemoved: 0, groupsLeft: 0 };
297
+ if (username.length > 0 && username.length <= GATEKEEPER_PRINCIPAL_MAX) {
298
+ const result = await removeUserPrincipal(username);
299
+ if (!result.ok) return { ok: false, error: result.error };
300
+ removed = { grantsRemoved: result.grantsRemoved, groupsLeft: result.groupsLeft };
301
+ }
302
+ const db = await getStateDb();
303
+ const cleared = db
304
+ .prepare(
305
+ `SELECT user_id FROM directory_users
306
+ WHERE username = ? AND status = 'removed' AND principal_cleared_at IS NULL`,
307
+ )
308
+ .all(username)
309
+ .map((row) => row.user_id);
310
+ if (cleared.length > 0) {
311
+ db.prepare(
312
+ `UPDATE directory_users SET principal_cleared_at = ?
313
+ WHERE username = ? AND status = 'removed' AND principal_cleared_at IS NULL`,
314
+ ).run(new Date().toISOString(), username);
315
+ await recordAuditBestEffort(
316
+ {
317
+ actor,
318
+ action: 'directory.user.principal-cleared',
319
+ details: { userIds: cleared, ...removed },
320
+ },
321
+ logger,
322
+ );
323
+ }
324
+ if (cleared.length > 0 || removed.grantsRemoved > 0 || removed.groupsLeft > 0) {
325
+ logger?.info(
326
+ { userIds: cleared, ...removed },
327
+ 'Cleared what Gatekeeper held under a removed user’s username',
328
+ );
329
+ }
330
+ return { ok: true, ...removed };
331
+ }
332
+
333
+ /** 503 `gatekeeper_unavailable`: what Gatekeeper holds under a username could not be cleared. */
334
+ function gatekeeperCleanupError(error) {
335
+ return httpError(
336
+ `A removed user's grants and group memberships under this username could not be cleared — Gatekeeper cannot clear them now (${error}); try again once Gatekeeper answers`,
337
+ 503,
338
+ { code: 'gatekeeper_unavailable' },
339
+ );
340
+ }
341
+
342
+ /** 503 `authelia_storage_unavailable`: Authelia's per-user storage could not be read or changed. */
343
+ function autheliaStorageError(what, err) {
344
+ return httpError(
345
+ `Authelia's storage could not ${what} (${err.message}) — nothing was changed; try again`,
346
+ 503,
347
+ { code: 'authelia_storage_unavailable' },
348
+ );
349
+ }
350
+
351
+ /**
352
+ * Before a user is created under `username` — users lock held: refuse a
353
+ * username Authelia holds an OpenID Connect opaque identifier for (409
354
+ * `username_has_oidc_identity`), and when a removed user held it, clear
355
+ * everything Gatekeeper holds under it and delete Authelia's second factors
356
+ * for it — always, whether or not a cleanup was pending: a restored
357
+ * gatekeeper.db or Authelia database, or a grant that raced the deletion,
358
+ * may have brought something back. Throws 503 (`gatekeeper_unavailable`,
359
+ * `authelia_storage_unavailable`) when that cannot be done now.
360
+ * `autheliaStorage: false` skips Authelia's storage (onboarding: it does not
361
+ * exist yet).
362
+ *
363
+ * @param {string} username
364
+ * @param {{ actor: string, logger?: import('pino').Logger, autheliaStorage: boolean }} opts
365
+ */
366
+ async function prepareUsernameUnlocked(username, { actor, logger, autheliaStorage }) {
367
+ if (autheliaStorage) {
368
+ let held;
369
+ try {
370
+ held = await autheliaHoldsOidcIdentity(username);
371
+ } catch (err) {
372
+ throw autheliaStorageError('be checked for an OpenID Connect identity of this username', err);
373
+ }
374
+ if (held) {
375
+ throw httpError(
376
+ 'Authelia holds an OpenID Connect identity for this username (it was signed in to an OpenID Connect relying party before): every relying party — plugins signing users in through Lamaste — would take a new user for the earlier one, and Authelia cannot forget it. Choose another username.',
377
+ 409,
378
+ { code: 'username_has_oidc_identity' },
379
+ );
380
+ }
381
+ }
382
+ if (!(await usernameHasTombstone(username))) return;
383
+ const cleared = await clearPrincipalUnlocked(username, { actor, logger });
384
+ if (!cleared.ok) throw gatekeeperCleanupError(cleared.error);
385
+ if (autheliaStorage) {
386
+ try {
387
+ await purgeAutheliaUserSecondFactors(username);
388
+ } catch (err) {
389
+ throw autheliaStorageError(
390
+ 'delete an earlier user’s second factors under this username',
391
+ err,
392
+ );
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Hold the users lock while something is given to `usernames` in
399
+ * Gatekeeper — a grant or a group membership — so no deletion runs
400
+ * between the checks and `give`: a username a removed user held and nobody
401
+ * holds now is refused (409 `username_deleted` — whatever was given would
402
+ * pass to whoever is given the username next), and a pending Gatekeeper
403
+ * cleanup under one is completed first (503 `gatekeeper_unavailable` when
404
+ * it cannot be). With `requireUser`, a username nobody holds is refused
405
+ * too (404 `user_not_found`). A username the directory has never seen
406
+ * goes through: Gatekeeper may hold grants for a user still to come.
407
+ *
408
+ * @template T
409
+ * @param {string[]} usernames
410
+ * @param {{ actor: string, logger?: import('pino').Logger, requireUser?: boolean }} opts
411
+ * @param {() => Promise<T>} give
412
+ * @returns {Promise<T>}
413
+ */
414
+ export function withUsernamesForGrants(usernames, { actor, logger, requireUser = false }, give) {
415
+ return withUsersLock(async () => {
416
+ for (const username of usernames) {
417
+ const holder = await findDirectoryUser(username);
418
+ if (!holder) {
419
+ if (await usernameHasTombstone(username)) {
420
+ throw httpError(
421
+ 'This username belonged to a user who was deleted, and nobody holds it now — nothing is given to it until a user is created under it',
422
+ 409,
423
+ { code: 'username_deleted' },
424
+ );
425
+ }
426
+ if (requireUser) {
427
+ throw httpError('No user with this username in the directory', 404, {
428
+ code: 'user_not_found',
429
+ });
430
+ }
431
+ }
432
+ if (await principalCleanupPending(username)) {
433
+ const cleared = await clearPrincipalUnlocked(username, { actor, logger });
434
+ if (!cleared.ok) throw gatekeeperCleanupError(cleared.error);
435
+ }
436
+ }
437
+ return give();
438
+ });
439
+ }
440
+
441
+ /**
442
+ * Retry every pending cleanup (the minute's tick): one Gatekeeper call per
443
+ * username, failures logged and left pending. Returns how many usernames
444
+ * were cleared and how many are still pending.
445
+ *
446
+ * @param {{ logger?: import('pino').Logger }} [opts]
447
+ * @returns {Promise<{ cleared: number, pending: number }>}
448
+ */
449
+ export function clearPendingPrincipals({ logger } = {}) {
450
+ return withUsersLock(async () => {
451
+ let cleared = 0;
452
+ let pending = 0;
453
+ for (const username of await pendingPrincipalCleanups()) {
454
+ const result = await clearPrincipalUnlocked(username, { actor: 'lamaste', logger });
455
+ if (result.ok) cleared += 1;
456
+ else pending += 1;
457
+ }
458
+ if (pending > 0) {
459
+ logger?.warn(
460
+ { pending },
461
+ 'Gatekeeper principals of removed users are still to be cleared — retried every minute',
462
+ );
463
+ }
464
+ return { cleared, pending };
465
+ });
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Reconcile (read only on users.yml)
470
+ // ---------------------------------------------------------------------------
471
+
472
+ function isRecord(value) {
473
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
474
+ }
475
+
476
+ function textOrNull(value) {
477
+ return typeof value === 'string' && value.length > 0 ? value : null;
478
+ }
479
+
480
+ /**
481
+ * Why users.yml could not be read, in words that carry none of its content:
482
+ * a YAML parser's message quotes the lines around the fault — usernames,
483
+ * addresses, password hashes — and must never reach a log.
484
+ *
485
+ * @param {unknown} err
486
+ * @returns {Error}
487
+ */
488
+ export function usersFileError(err) {
489
+ const cause =
490
+ err?.name === 'YAMLException'
491
+ ? `not valid YAML${Number.isInteger(err.mark?.line) ? ` at line ${err.mark.line + 1}` : ''}`
492
+ : typeof err?.code === 'string'
493
+ ? err.code
494
+ : (err?.name ?? 'unknown error');
495
+ return new Error(`users.yml could not be read (${cause})`);
496
+ }
497
+
498
+ /**
499
+ * users.yml as the directory sees it: username → display name, email and
500
+ * status. Throws when the file cannot be read or parsed (without its
501
+ * content: {@link usersFileError}), or is not a users file — a reconcile
502
+ * then changes nothing.
503
+ *
504
+ * @returns {Promise<Map<string, { displayName: string | null, email: string | null, status: 'active' | 'disabled' }>>}
505
+ */
506
+ async function readUsersSnapshot() {
507
+ let raw;
508
+ try {
509
+ raw = await readUsersRaw();
510
+ } catch (err) {
511
+ throw usersFileError(err);
512
+ }
513
+ if (!isRecord(raw) || !isRecord(raw.users)) {
514
+ throw new Error('users.yml is not a users file (no "users" map)');
515
+ }
516
+ const users = new Map();
517
+ for (const [username, value] of Object.entries(raw.users)) {
518
+ const entry = isRecord(value) ? value : {};
519
+ users.set(username, {
520
+ displayName: textOrNull(entry.displayname),
521
+ email: textOrNull(entry.email),
522
+ status: entry.disabled === true ? 'disabled' : 'active',
523
+ });
524
+ }
525
+ return users;
526
+ }
527
+
528
+ /** Refuse a users.yml that lists nobody while the directory has users: never a real state. */
529
+ function assertNotEmpty(snapshot, live) {
530
+ if (snapshot.size === 0 && live.length > 0) {
531
+ throw new Error('users.yml lists no users — the directory is left as it is');
532
+ }
533
+ }
534
+
535
+ /**
536
+ * {@link reconcileDirectory} with the users lock already held.
537
+ *
538
+ * @param {{ actor: string, logger?: import('pino').Logger, deleted?: string[], confirmDelayMs?: number }} opts
539
+ * @returns {Promise<DirectoryReconcileResult>}
540
+ */
541
+ async function reconcileUnlocked({
542
+ actor,
543
+ logger,
544
+ deleted = [],
545
+ confirmDelayMs = DIRECTORY_CONFIRM_DELAY_MS,
546
+ }) {
547
+ const db = await getStateDb();
548
+ const live = db
549
+ .prepare("SELECT * FROM directory_users WHERE status <> 'removed'")
550
+ .all()
551
+ .map(rowToUser);
552
+ const deletedByLamaste = new Set(deleted);
553
+
554
+ let snapshot = await readUsersSnapshot();
555
+ assertNotEmpty(snapshot, live);
556
+ const absentFirst = new Set(live.filter((u) => !snapshot.has(u.username)).map((u) => u.username));
557
+ if ([...absentFirst].some((username) => !deletedByLamaste.has(username))) {
558
+ // Someone left users.yml without Lamaste — or the read met a file
559
+ // Authelia was rewriting in place. Read it again before believing it.
560
+ if (confirmDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, confirmDelayMs));
561
+ snapshot = await readUsersSnapshot();
562
+ assertNotEmpty(snapshot, live);
563
+ }
564
+ // Gone in every read made: Lamaste's own deletions after one, anyone
565
+ // else's after two.
566
+ const missing = live.filter((u) => absentFirst.has(u.username) && !snapshot.has(u.username));
567
+
568
+ const now = new Date().toISOString();
569
+ const events = [];
570
+ const result = { created: 0, updated: 0, removed: [], disabled: [] };
571
+ const selectLive = db.prepare(
572
+ "SELECT * FROM directory_users WHERE username = ? AND status <> 'removed'",
573
+ );
574
+ const remove = db.prepare(
575
+ `UPDATE directory_users
576
+ SET status = 'removed', removed_reason = ?, removed_at = ?, version = version + 1,
577
+ updated_at = ?
578
+ WHERE user_id = ? AND status <> 'removed'`,
579
+ );
580
+ const insert = db.prepare(
581
+ `INSERT INTO directory_users
582
+ (user_id, username, display_name, email, status, version, created_at, updated_at)
583
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?)`,
584
+ );
585
+ const update = db.prepare(
586
+ `UPDATE directory_users
587
+ SET display_name = ?, email = ?, status = ?, version = version + 1, updated_at = ?
588
+ WHERE user_id = ?`,
589
+ );
590
+ db.exec('BEGIN IMMEDIATE');
591
+ try {
592
+ for (const user of missing) {
593
+ const reason = deletedByLamaste.has(user.username) ? 'deleted' : 'deleted-outside';
594
+ remove.run(reason, now, now, user.userId);
595
+ result.removed.push({ userId: user.userId, username: user.username, reason });
596
+ events.push({ action: 'directory.user.removed', details: { userId: user.userId, reason } });
597
+ }
598
+ for (const [username, attrs] of snapshot) {
599
+ const row = selectLive.get(username);
600
+ if (!row) {
601
+ const userId = crypto.randomUUID();
602
+ insert.run(userId, username, attrs.displayName, attrs.email, attrs.status, now, now);
603
+ result.created += 1;
604
+ events.push({
605
+ action: 'directory.user.created',
606
+ details: { userId, status: attrs.status },
607
+ });
608
+ continue;
609
+ }
610
+ const changed = [];
611
+ if (row.display_name !== attrs.displayName) changed.push('displayName');
612
+ if (row.email !== attrs.email) changed.push('email');
613
+ if (row.status !== attrs.status) changed.push('status');
614
+ if (changed.length === 0) continue;
615
+ update.run(attrs.displayName, attrs.email, attrs.status, now, row.user_id);
616
+ result.updated += 1;
617
+ if (row.status !== 'disabled' && attrs.status === 'disabled') {
618
+ result.disabled.push(row.user_id);
619
+ }
620
+ events.push({
621
+ action: 'directory.user.updated',
622
+ details: { userId: row.user_id, changed, status: attrs.status },
623
+ });
624
+ }
625
+ db.exec('COMMIT');
626
+ } catch (err) {
627
+ db.exec('ROLLBACK');
628
+ throw err;
629
+ }
630
+
631
+ for (const event of events) {
632
+ await recordAuditBestEffort({ actor, action: event.action, details: event.details }, logger);
633
+ }
634
+ if (events.length > 0) {
635
+ logger?.info(
636
+ { created: result.created, updated: result.updated, removed: result.removed.length },
637
+ 'User directory reconciled',
638
+ );
639
+ }
640
+ return result;
641
+ }
642
+
643
+ // ---------------------------------------------------------------------------
644
+ // Offboarding
645
+ // ---------------------------------------------------------------------------
646
+
647
+ /**
648
+ * Offboard user `userId` — users lock held — who was just disabled
649
+ * (`kind: 'disabled'`) or removed (`'deleted'`), whoever did it: end their
650
+ * desktop sessions (bump their session epoch), revoke their registry
651
+ * tokens, and for a removal remove their legacy user-plugin-access grants,
652
+ * clear their Gatekeeper principal (pending and retried every minute when
653
+ * Gatekeeper fails) and delete their Authelia second factors (done again
654
+ * before the username is given to anyone). Each step is best effort; what
655
+ * failed comes back as `warnings` (`{ step, message }`, never naming the
656
+ * user). Gatekeeper's cached session verdicts are not dropped here: the
657
+ * caller does that once, after Authelia restarted where it restarts it.
658
+ *
659
+ * @param {string} userId
660
+ * @param {{ kind: 'disabled' | 'deleted', actor: string, logger?: import('pino').Logger }} opts
661
+ * @returns {Promise<Array<{ step: string, message: string }>>}
662
+ */
663
+ async function offboardUserUnlocked(userId, { kind, actor, logger }) {
664
+ const warnings = [];
665
+ const db = await getStateDb();
666
+ const row = db.prepare('SELECT username FROM directory_users WHERE user_id = ?').get(userId);
667
+ const username = row?.username ?? null;
668
+ const failed = (step, message, err) => {
669
+ logger?.warn({ userId, step, err: err.message }, message);
670
+ warnings.push({ step, message: `${message}: ${err.message}` });
671
+ };
672
+
673
+ try {
674
+ await bumpUserEpoch(userId);
675
+ } catch (err) {
676
+ failed('session-epoch', 'Live desktop sessions were not ended', err);
677
+ }
678
+ if (username !== null) {
679
+ try {
680
+ await revokeUserRegistryTokens(username, { deleted: true }, logger, { userId });
681
+ } catch (err) {
682
+ failed('registry', 'Registry tokens were not revoked', err);
683
+ }
684
+ }
685
+ if (kind === 'deleted' && username !== null) {
686
+ try {
687
+ const removed = await removeGrantsForUser(username, logger ?? SILENT_LOGGER);
688
+ if (removed > 0)
689
+ logger?.info({ userId, removed }, 'Removed legacy user-plugin-access grants');
690
+ } catch (err) {
691
+ failed('user-plugin-access', 'Legacy user-plugin-access grants were not removed', err);
692
+ }
693
+ const cleared = await clearPrincipalUnlocked(username, { actor, logger });
694
+ if (!cleared.ok) {
695
+ logger?.warn(
696
+ { userId, err: cleared.error },
697
+ 'Gatekeeper did not clear a removed user’s principal',
698
+ );
699
+ warnings.push({
700
+ step: 'gatekeeper',
701
+ message: `Gatekeeper did not remove the user's grants and group memberships (${cleared.error}) — retried every minute; until it succeeds the username holds no role and cannot be given to a new user`,
702
+ });
703
+ }
704
+ try {
705
+ await purgeAutheliaUserSecondFactors(username);
706
+ } catch (err) {
707
+ failed(
708
+ 'authelia-storage',
709
+ 'Authelia kept the user’s second factors (TOTP, WebAuthn) — they are deleted before the username is given to anyone',
710
+ err,
711
+ );
712
+ }
713
+ }
714
+ logger?.info({ userId, kind }, 'User offboarded');
715
+ return warnings;
716
+ }
717
+
718
+ /**
719
+ * Offboard every user a reconcile saw stop being one — removed, or newly
720
+ * disabled — users lock held; with `bustSessions`, drop Gatekeeper's cached
721
+ * session verdicts once after them. Returns the warnings.
722
+ *
723
+ * @param {DirectoryReconcileResult} reconciled
724
+ * @param {{ actor: string, logger?: import('pino').Logger, bustSessions: boolean }} opts
725
+ */
726
+ async function offboardTransitionsUnlocked(reconciled, { actor, logger, bustSessions }) {
727
+ const warnings = [];
728
+ for (const { userId } of reconciled.removed) {
729
+ warnings.push(...(await offboardUserUnlocked(userId, { kind: 'deleted', actor, logger })));
730
+ }
731
+ for (const userId of reconciled.disabled) {
732
+ warnings.push(...(await offboardUserUnlocked(userId, { kind: 'disabled', actor, logger })));
733
+ }
734
+ if (bustSessions && reconciled.removed.length + reconciled.disabled.length > 0) {
735
+ warnings.push(...(await bustCachedSessions(logger)));
736
+ }
737
+ return warnings;
738
+ }
739
+
740
+ /**
741
+ * Drop Gatekeeper's cached session verdicts. Returns the warning when it
742
+ * did not (the cache expires within 30 seconds anyway).
743
+ *
744
+ * @param {import('pino').Logger} [logger]
745
+ * @returns {Promise<Array<{ step: string, message: string }>>}
746
+ */
747
+ async function bustCachedSessions(logger) {
748
+ const busted = await bustGatekeeperSessions();
749
+ if (busted.ok) return [];
750
+ logger?.warn({ err: busted.error }, 'Gatekeeper did not drop its cached sessions');
751
+ return [
752
+ {
753
+ step: 'gatekeeper',
754
+ message: `Gatekeeper did not drop its cached sessions (${busted.error}); they expire within 30 seconds`,
755
+ },
756
+ ];
757
+ }
758
+
759
+ /**
760
+ * Bring the directory in line with users.yml — read only on the file: a
761
+ * username it does not know gets a new user (active, or disabled per
762
+ * `disabled: true`), a user whose username left the file (seen in two reads
763
+ * {@link DIRECTORY_CONFIRM_DELAY_MS} apart) is removed as deleted outside
764
+ * Lamaste, and a changed display name, email or `disabled` is adopted
765
+ * (version + 1). Each change is audited by user id, and a user who was
766
+ * removed or became disabled is offboarded ({@link offboardUserUnlocked}).
767
+ * Throws, changing nothing, when users.yml cannot be read or lists nobody
768
+ * while the directory has users.
769
+ *
770
+ * @param {{ actor?: string, logger?: import('pino').Logger, confirmDelayMs?: number }} [opts]
771
+ * @returns {Promise<DirectoryReconcileResult>}
772
+ */
773
+ export function reconcileDirectory({ actor = 'lamaste', logger, confirmDelayMs } = {}) {
774
+ return withUsersLock(async () => {
775
+ const reconciled = await reconcileUnlocked({ actor, logger, confirmDelayMs });
776
+ await offboardTransitionsUnlocked(reconciled, { actor, logger, bustSessions: true });
777
+ return reconciled;
778
+ });
779
+ }
780
+
781
+ // ---------------------------------------------------------------------------
782
+ // Writing users.yml
783
+ // ---------------------------------------------------------------------------
784
+
785
+ /**
786
+ * Change users.yml — a read-modify-write `mutate` through `lib/authelia.js`
787
+ * — holding the users lock, all of it:
788
+ *
789
+ * 1. when it creates or deletes users, reconcile first (a user deleted
790
+ * outside Lamaste a moment ago is tombstoned before a namesake is
791
+ * created; a user about to be deleted must be in the directory to leave
792
+ * a tombstone), offboarding whatever that reconcile saw change;
793
+ * 2. for every username `creates` names, {@link prepareUsernameUnlocked}:
794
+ * refused when Authelia holds an OpenID Connect identity for it (409
795
+ * `username_has_oidc_identity`); when a removed user held it, Gatekeeper
796
+ * drops everything under it and Authelia its second factors — always
797
+ * (503 `gatekeeper_unavailable` / `authelia_storage_unavailable` when
798
+ * that cannot be done: a new user never inherits an earlier one's grants,
799
+ * groups or second factors);
800
+ * 3. `mutate`, then reconcile the directory (`deleted` names the usernames
801
+ * `mutate` deletes: removed as `deleted`), offboarding every user that
802
+ * reconcile saw removed or newly disabled ({@link offboardUserUnlocked});
803
+ * 4. restart Authelia (unless `restartAuthelia` is false: onboarding starts
804
+ * it next) — which ends every session — and drop Gatekeeper's cached
805
+ * session verdicts with them;
806
+ * 5. with `expect` (`(users) => boolean` over users.yml's `users` map: does
807
+ * the file hold the intended change?), read users.yml again: Authelia's
808
+ * own password reset rewrites the file from what it read at start, so a
809
+ * write made just before its restart can be lost. When the change is not
810
+ * there it is made once more (steps 3 and 4); when it is still not
811
+ * there, `persisted` is false and a `persist` warning says so.
812
+ *
813
+ * Then the plugins' audiences are recomputed. A refusal `mutate` (or step
814
+ * 2) throws comes back as it was, nothing written; what goes wrong after
815
+ * the write is returned in `warnings` (`{ step, message }`) — the write
816
+ * stays, and a cleanup that failed stays pending (Gatekeeper: retried every
817
+ * minute; Authelia: done again before the username is given to anyone).
818
+ *
819
+ * @template T
820
+ * @param {() => Promise<T>} mutate
821
+ * @param {{ actor: string, logger?: import('pino').Logger, creates?: string[], deleted?: string[], restartAuthelia?: boolean, autheliaStorage?: boolean, confirmDelayMs?: number, expect?: (users: Record<string, unknown>) => boolean }} opts
822
+ * @returns {Promise<{ result: T, directory: DirectoryReconcileResult | null, warnings: Array<{ step: string, message: string }>, persisted: boolean }>}
823
+ */
824
+ export async function changeUsers(
825
+ mutate,
826
+ {
827
+ actor,
828
+ logger,
829
+ creates = [],
830
+ deleted = [],
831
+ restartAuthelia = true,
832
+ autheliaStorage = true,
833
+ confirmDelayMs,
834
+ expect,
835
+ },
836
+ ) {
837
+ const warnings = [];
838
+
839
+ /** Reconcile after the write and offboard what it saw; null when users.yml cannot be read. */
840
+ const settle = async () => {
841
+ let reconciled = null;
842
+ try {
843
+ reconciled = await reconcileUnlocked({ actor, logger, deleted, confirmDelayMs });
844
+ } catch (err) {
845
+ logger?.error({ err: err.message }, 'Could not bring the user directory in line');
846
+ warnings.push({
847
+ step: 'directory',
848
+ message: `The user directory was not updated (${err.message}); it catches up at the next reconcile, within a minute`,
849
+ });
850
+ }
851
+ if (reconciled) {
852
+ warnings.push(
853
+ ...(await offboardTransitionsUnlocked(reconciled, { actor, logger, bustSessions: false })),
854
+ );
855
+ } else {
856
+ // No tombstone yet (the next reconcile makes it, pending): Gatekeeper
857
+ // drops what it holds under the deleted usernames now all the same.
858
+ for (const username of deleted) {
859
+ const cleared = await clearPrincipalUnlocked(username, { actor, logger });
860
+ if (!cleared.ok) {
861
+ warnings.push({
862
+ step: 'gatekeeper',
863
+ message: `Gatekeeper did not remove the user's grants and group memberships (${cleared.error}) — retried every minute`,
864
+ });
865
+ }
866
+ }
867
+ }
868
+ return reconciled;
869
+ };
870
+
871
+ const restart = async () => {
872
+ if (!restartAuthelia) return;
873
+ try {
874
+ await reloadAuthelia();
875
+ } catch (err) {
876
+ logger?.warn({ err: err.message }, 'Failed to restart Authelia after a users change');
877
+ warnings.push({ step: 'authelia', message: `Authelia was not restarted: ${err.message}` });
878
+ }
879
+ warnings.push(...(await bustCachedSessions(logger)));
880
+ };
881
+
882
+ const holds = async () => {
883
+ try {
884
+ const raw = await readUsersRaw();
885
+ return expect(isRecord(raw?.users) ? raw.users : {});
886
+ } catch {
887
+ return false;
888
+ }
889
+ };
890
+
891
+ const outcome = await withUsersLock(async () => {
892
+ if (creates.length > 0 || deleted.length > 0) {
893
+ try {
894
+ const before = await reconcileUnlocked({ actor, logger, confirmDelayMs });
895
+ warnings.push(
896
+ ...(await offboardTransitionsUnlocked(before, { actor, logger, bustSessions: false })),
897
+ );
898
+ } catch (err) {
899
+ logger?.warn(
900
+ { err: err.message },
901
+ 'Could not reconcile the directory before a users change',
902
+ );
903
+ }
904
+ }
905
+ for (const username of creates) {
906
+ await prepareUsernameUnlocked(username, { actor, logger, autheliaStorage });
907
+ }
908
+ let value = await mutate();
909
+ let reconciled = await settle();
910
+ await restart();
911
+
912
+ let persisted = true;
913
+ if (expect && !(await holds())) {
914
+ // Authelia rewrote users.yml over the change (its own password reset
915
+ // writes the file from what it read at start): make it once more.
916
+ logger?.warn('users.yml lost a change while Authelia restarted — making it again');
917
+ try {
918
+ value = await mutate();
919
+ const again = await settle();
920
+ if (again) {
921
+ reconciled = reconciled
922
+ ? {
923
+ created: reconciled.created + again.created,
924
+ updated: reconciled.updated + again.updated,
925
+ removed: [...reconciled.removed, ...again.removed],
926
+ disabled: [...reconciled.disabled, ...again.disabled],
927
+ }
928
+ : again;
929
+ }
930
+ await restart();
931
+ persisted = await holds();
932
+ } catch (err) {
933
+ logger?.warn({ err: err.message }, 'Making a lost users change again failed');
934
+ persisted = false;
935
+ }
936
+ if (!persisted) {
937
+ logger?.error('users.yml does not hold a users change after it was made twice');
938
+ warnings.push({
939
+ step: 'persist',
940
+ message:
941
+ 'users.yml does not hold the change: Authelia rewrote the file while it was being made (a password reset, say), and making it again did not stick either — check the user and make the change again',
942
+ });
943
+ }
944
+ }
945
+ return { result: value, directory: reconciled, persisted };
946
+ });
947
+
948
+ const { recomputeAudiences } = await import('./audience.js');
949
+ await recomputeAudiences({ logger });
950
+ return { ...outcome, warnings };
951
+ }
952
+
953
+ // ---------------------------------------------------------------------------
954
+ // Erasure
955
+ // ---------------------------------------------------------------------------
956
+
957
+ /** Whether an invitation was made at or before `time` (or says nothing usable about when). */
958
+ function madeBy(invitation, time) {
959
+ const made = Date.parse(invitation?.createdAt);
960
+ return !Number.isFinite(made) || made <= Date.parse(time);
961
+ }
962
+
963
+ /**
964
+ * Delete the invitations of `username` made at or before `removedAt` — the
965
+ * removed user's, accepted or not; a later invitation for the same
966
+ * username is someone else's.
967
+ *
968
+ * @returns {Promise<number>} how many went
969
+ */
970
+ function purgeInvitations(username, removedAt) {
971
+ return withInvitationsLock(async () => {
972
+ const invitations = await readInvitations();
973
+ const kept = invitations.filter(
974
+ (inv) => !(inv?.username === username && madeBy(inv, removedAt)),
975
+ );
976
+ if (kept.length === invitations.length) return 0;
977
+ await writeInvitations(kept);
978
+ return invitations.length - kept.length;
979
+ });
980
+ }
981
+
982
+ /**
983
+ * Erase removed user `userId`: with the username the tombstone still holds,
984
+ * clear their Gatekeeper principal if that is still pending, delete
985
+ * Gatekeeper's access-request log rows, their Authelia second factors and
986
+ * their invitations (log and invitations as of when they were removed),
987
+ * then scrub the row to the bare id
988
+ * (`erased_at` set, version + 1), record who asked, audit it, and recompute
989
+ * the audiences — every plugin that had them as a member is sent
990
+ * `remove/erased`, and acknowledges it when its half's cursor passes it.
991
+ *
992
+ * Refuses (409 `user_not_removed`) a user who is active or disabled —
993
+ * delete them first, or delete and erase in one step — and (409
994
+ * `already_erased`) a second erasure. When Gatekeeper cannot purge its log
995
+ * (503 `gatekeeper_unavailable`) or Authelia's storage cannot be changed
996
+ * (503 `authelia_storage_unavailable`) nothing is scrubbed: the tombstone
997
+ * still names the username a later attempt needs. What Lamaste cannot reach
998
+ * stays: Authelia's OpenID Connect opaque identifier for the username and
999
+ * its authentication log.
1000
+ *
1001
+ * @param {string} userId
1002
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
1003
+ * @returns {Promise<{ userId: string, erasedAt: string, invitationsPurged: number, accessLogPurged: number }>}
1004
+ */
1005
+ export async function eraseDirectoryUser(userId, { actor, logger }) {
1006
+ const erased = await withUsersLock(async () => {
1007
+ const db = await getStateDb();
1008
+ const row = db.prepare('SELECT * FROM directory_users WHERE user_id = ?').get(userId);
1009
+ if (!row)
1010
+ throw httpError('No user with this id in the directory', 404, { code: 'user_not_found' });
1011
+ if (row.status !== 'removed') {
1012
+ throw httpError(
1013
+ 'Only a removed user is erased — delete the user first, or delete and erase in one step (DELETE /api/users/:username?erase=true)',
1014
+ 409,
1015
+ { code: 'user_not_removed' },
1016
+ );
1017
+ }
1018
+ if (row.erased_at !== null) {
1019
+ throw httpError('This user was erased already', 409, { code: 'already_erased' });
1020
+ }
1021
+
1022
+ // Gatekeeper first: the one service that may be unavailable, so a
1023
+ // refusal leaves everything here as it was. The principal is cleared
1024
+ // while the username that names it is still known; both Gatekeeper
1025
+ // steps and the purge of invitations can be repeated.
1026
+ let invitationsPurged = 0;
1027
+ let accessLogPurged = 0;
1028
+ if (row.username !== null) {
1029
+ if (row.principal_cleared_at === null) {
1030
+ const cleared = await clearPrincipalUnlocked(row.username, { actor, logger });
1031
+ if (!cleared.ok) {
1032
+ throw httpError(
1033
+ `Gatekeeper could not remove the user's grants and group memberships (${cleared.error}) — nothing was erased; try again once Gatekeeper answers`,
1034
+ 503,
1035
+ { code: 'gatekeeper_unavailable' },
1036
+ );
1037
+ }
1038
+ }
1039
+ const purge = await purgeAccessLogForUser(row.username, { before: row.removed_at });
1040
+ if (!purge.ok) {
1041
+ throw httpError(
1042
+ `Gatekeeper's access-request log could not be purged (${purge.error}) — nothing was erased; try again once Gatekeeper answers`,
1043
+ 503,
1044
+ { code: 'gatekeeper_unavailable' },
1045
+ );
1046
+ }
1047
+ accessLogPurged = purge.removed;
1048
+ // Authelia's second factors under the username go before it is
1049
+ // forgotten (they went at removal unless that failed — then never
1050
+ // again, once nothing here names it).
1051
+ try {
1052
+ await purgeAutheliaUserSecondFactors(row.username);
1053
+ } catch (err) {
1054
+ throw httpError(
1055
+ `Authelia's storage could not delete the user's second factors (${err.message}) — nothing was erased; try again`,
1056
+ 503,
1057
+ { code: 'authelia_storage_unavailable' },
1058
+ );
1059
+ }
1060
+ invitationsPurged = await purgeInvitations(row.username, row.removed_at);
1061
+ }
1062
+
1063
+ const now = new Date().toISOString();
1064
+ db.exec('BEGIN IMMEDIATE');
1065
+ try {
1066
+ db.prepare(
1067
+ `UPDATE directory_users
1068
+ SET username = NULL, display_name = NULL, email = NULL, erased_at = ?,
1069
+ version = version + 1, updated_at = ?
1070
+ WHERE user_id = ? AND status = 'removed' AND erased_at IS NULL`,
1071
+ ).run(now, now, userId);
1072
+ db.prepare(
1073
+ 'INSERT INTO directory_erasures (user_id, requested_at, requested_by) VALUES (?, ?, ?)',
1074
+ ).run(userId, now, actor);
1075
+ db.exec('COMMIT');
1076
+ } catch (err) {
1077
+ db.exec('ROLLBACK');
1078
+ throw err;
1079
+ }
1080
+ await recordAuditBestEffort(
1081
+ {
1082
+ actor,
1083
+ action: 'directory.user.erased',
1084
+ details: { userId, invitationsPurged, accessLogPurged },
1085
+ },
1086
+ logger,
1087
+ );
1088
+ logger?.info({ userId, invitationsPurged, accessLogPurged }, 'Erased a removed user');
1089
+ return { userId, erasedAt: now, invitationsPurged, accessLogPurged };
1090
+ });
1091
+
1092
+ const { recomputeAudiences } = await import('./audience.js');
1093
+ await recomputeAudiences({ logger });
1094
+ return erased;
1095
+ }
1096
+
1097
+ /**
1098
+ * Who erased each erased user, and when.
1099
+ *
1100
+ * @returns {Promise<Map<string, { requestedAt: string, requestedBy: string }>>}
1101
+ */
1102
+ export async function listErasures() {
1103
+ const db = await getStateDb();
1104
+ return new Map(
1105
+ db
1106
+ .prepare('SELECT * FROM directory_erasures')
1107
+ .all()
1108
+ .map((row) => [
1109
+ row.user_id,
1110
+ { requestedAt: row.requested_at, requestedBy: row.requested_by },
1111
+ ]),
1112
+ );
1113
+ }