@lamalibre/create-lamaste 4.4.0 → 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 (150) 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 +292 -23
  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 +15 -13
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +6 -4
  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 +83 -189
  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/plugin-packages.js +30 -22
  125. package/vendor/serverd/src/lib/plugins.js +94 -15
  126. package/vendor/serverd/src/lib/registry-credential.js +46 -6
  127. package/vendor/serverd/src/lib/request-logging.js +19 -0
  128. package/vendor/serverd/src/lib/retained-data.js +50 -4
  129. package/vendor/serverd/src/lib/state.js +18 -0
  130. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  131. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  132. package/vendor/serverd/src/lib/user-caller.js +67 -0
  133. package/vendor/serverd/src/lib/user-surface.js +733 -0
  134. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  135. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  136. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  137. package/vendor/serverd/src/routes/invite.js +53 -12
  138. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  139. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  140. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  141. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  142. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  143. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  144. package/vendor/serverd/src/routes/management/users.js +290 -208
  145. package/vendor/serverd/src/routes/management.js +4 -0
  146. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  147. package/vendor/serverd/src/routes/user-access.js +17 -4
  148. package/vendor/serverd/src/routes/user-surface.js +69 -0
  149. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  150. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +0 -84
@@ -1,25 +1,34 @@
1
1
  import { z } from 'zod';
2
+ import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
2
3
  import {
3
4
  readUsers,
4
5
  readUsersRaw,
5
6
  writeUsers,
6
- reloadAuthelia,
7
7
  hashPassword,
8
8
  generateTotpSecret,
9
9
  writeTotpToDatabase,
10
10
  } from '../../lib/authelia.js';
11
- import { removeGrantsForUser } from '../../lib/user-access.js';
12
11
  import { revokeUserRegistryTokens } from '../../lib/registry-credential.js';
13
- import { bumpUserEpoch } from '../../lib/config.js';
14
- import { gatekeeperRequest } from '../../lib/gatekeeper-client.js';
12
+ import { httpError } from '../../lib/app-error.js';
13
+ import { actorFromRequest } from '../../lib/actor.js';
14
+ import { recordAuditBestEffort } from '../../lib/audit.js';
15
+ import {
16
+ ADMIN_GROUP,
17
+ changeUsers,
18
+ directoryUsersByUsername,
19
+ eraseDirectoryUser,
20
+ findDirectoryUser,
21
+ getDirectoryUser,
22
+ usersFileError,
23
+ } from '../../lib/directory.js';
15
24
 
16
25
  const CreateUserSchema = z.object({
17
26
  username: z
18
27
  .string()
19
- .min(2)
20
- .max(32)
28
+ .min(USERNAME_MIN_LENGTH)
29
+ .max(USERNAME_MAX_LENGTH)
21
30
  .regex(
22
- /^[a-z0-9][a-z0-9_-]*$/,
31
+ USERNAME_REGEX,
23
32
  'Username must start with a lowercase letter or digit and may contain underscores or hyphens',
24
33
  ),
25
34
  displayname: z.string().min(1).max(100),
@@ -31,9 +40,9 @@ const CreateUserSchema = z.object({
31
40
  const UsernameParamSchema = z.object({
32
41
  username: z
33
42
  .string()
34
- .min(2)
35
- .max(32)
36
- .regex(/^[a-z0-9][a-z0-9_-]*$/, 'Invalid username format'),
43
+ .min(USERNAME_MIN_LENGTH)
44
+ .max(USERNAME_MAX_LENGTH)
45
+ .regex(USERNAME_REGEX, 'Invalid username format'),
37
46
  });
38
47
 
39
48
  const UpdateUserSchema = z
@@ -42,13 +51,76 @@ const UpdateUserSchema = z
42
51
  email: z.string().email().optional(),
43
52
  password: z.string().min(8).max(128).optional(),
44
53
  groups: z.array(z.string()).optional(),
54
+ // Authelia's `disabled: true`: the user cannot sign in, every plugin
55
+ // audience shows them disabled, and the user surface refuses them.
56
+ disabled: z.boolean().optional(),
45
57
  })
46
58
  .refine((data) => Object.keys(data).length > 0, {
47
59
  message: 'At least one field must be provided',
48
60
  });
49
61
 
62
+ const DeleteQuerySchema = z
63
+ .object({
64
+ // Erase the tombstone at once: what the directory, invitations and
65
+ // Gatekeeper's access-request log keep of the user goes too.
66
+ erase: z.enum(['true', 'false']).optional(),
67
+ })
68
+ .strict();
69
+
70
+ /** users.yml, or a 500 the route answers with (its cause logged without the file's content). */
71
+ async function readUsersOrFail() {
72
+ try {
73
+ return await readUsersRaw();
74
+ } catch (err) {
75
+ throw httpError('Failed to read user database', 500, { cause: usersFileError(err) });
76
+ }
77
+ }
78
+
79
+ async function writeUsersOrFail(usersData) {
80
+ try {
81
+ await writeUsers(usersData);
82
+ } catch (err) {
83
+ throw httpError('Failed to update user database', 500, { cause: err });
84
+ }
85
+ }
86
+
87
+ /** The user's groups as a sorted list (users.yml may hold none). */
88
+ function groupsOf(entry) {
89
+ return Array.isArray(entry?.groups) ? [...entry.groups].sort() : [];
90
+ }
91
+
92
+ /** Whether two lists hold the same strings. */
93
+ function sameList(a, b) {
94
+ return a.length === b.length && a.every((value, i) => value === b[i]);
95
+ }
96
+
97
+ /** An enabled member of the administrators' group. */
98
+ function isEnabledAdmin(entry) {
99
+ return (
100
+ Array.isArray(entry?.groups) && entry.groups.includes(ADMIN_GROUP) && entry.disabled !== true
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Answer a refusal a users change threw (an error with `statusCode`: its
106
+ * message and `code`), or a 500 for anything else — logged either way when
107
+ * it is the panel's own failure.
108
+ */
109
+ function sendFailure(request, reply, err, fallback) {
110
+ if (Number.isInteger(err.statusCode)) {
111
+ if (err.statusCode >= 500) request.log.error(err.cause ?? err, err.message);
112
+ return reply
113
+ .code(err.statusCode)
114
+ .send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
115
+ }
116
+ request.log.error(err, fallback);
117
+ return reply.code(500).send({ error: fallback });
118
+ }
119
+
50
120
  export default async function usersRoutes(fastify, _opts) {
51
- // GET /api/users — list all users (no sensitive fields)
121
+ // GET /api/users — list all users (no sensitive fields): users.yml's, with
122
+ // each one's directory id (null until the directory has seen them, within
123
+ // a minute) and status (`active` or `disabled`)
52
124
  fastify.get(
53
125
  '/users',
54
126
  {
@@ -57,8 +129,18 @@ export default async function usersRoutes(fastify, _opts) {
57
129
  async (request, reply) => {
58
130
  try {
59
131
  const users = await readUsers();
60
- const sorted = users.sort((a, b) => a.username.localeCompare(b.username));
61
- return { users: sorted };
132
+ const directory = await directoryUsersByUsername();
133
+ const listed = users
134
+ .map((user) => {
135
+ const known = directory.get(user.username);
136
+ return {
137
+ ...user,
138
+ userId: known?.userId ?? null,
139
+ status: known?.status ?? (user.disabled ? 'disabled' : 'active'),
140
+ };
141
+ })
142
+ .sort((a, b) => a.username.localeCompare(b.username));
143
+ return { users: listed };
62
144
  } catch (err) {
63
145
  request.log.error(err, 'Failed to read user database');
64
146
  return reply.code(500).send({ error: 'Failed to read user database' });
@@ -78,16 +160,13 @@ export default async function usersRoutes(fastify, _opts) {
78
160
  async (request, reply) => {
79
161
  const body = CreateUserSchema.parse(request.body);
80
162
 
81
- let usersData;
82
163
  try {
83
- usersData = await readUsersRaw();
164
+ // Refused before a password is hashed; checked again under the lock.
165
+ if ((await readUsersOrFail()).users[body.username]) {
166
+ return reply.code(409).send({ error: 'Username already exists' });
167
+ }
84
168
  } catch (err) {
85
- request.log.error(err, 'Failed to read user database');
86
- return reply.code(500).send({ error: 'Failed to read user database' });
87
- }
88
-
89
- if (usersData.users[body.username]) {
90
- return reply.code(409).send({ error: 'Username already exists' });
169
+ return sendFailure(request, reply, err, 'Failed to read user database');
91
170
  }
92
171
 
93
172
  let hash;
@@ -98,39 +177,54 @@ export default async function usersRoutes(fastify, _opts) {
98
177
  return reply.code(500).send({ error: 'Failed to hash password' });
99
178
  }
100
179
 
101
- usersData.users[body.username] = {
102
- displayname: body.displayname,
103
- email: body.email,
104
- password: hash,
105
- groups: body.groups,
106
- };
107
-
108
- try {
109
- await writeUsers(usersData);
110
- } catch (err) {
111
- request.log.error(err, 'Failed to update user database');
112
- return reply.code(500).send({ error: 'Failed to update user database' });
113
- }
114
-
180
+ let warnings;
181
+ let persisted;
115
182
  try {
116
- await reloadAuthelia();
183
+ ({ warnings, persisted } = await changeUsers(
184
+ async () => {
185
+ const usersData = await readUsersOrFail();
186
+ if (usersData.users[body.username]) throw httpError('Username already exists', 409);
187
+ usersData.users[body.username] = {
188
+ displayname: body.displayname,
189
+ email: body.email,
190
+ password: hash,
191
+ groups: body.groups,
192
+ };
193
+ await writeUsersOrFail(usersData);
194
+ },
195
+ {
196
+ actor: actorFromRequest(request),
197
+ logger: request.log,
198
+ // A removed user's grants, groups and second factors under the
199
+ // username go first; a username with an OIDC identity is refused.
200
+ creates: [body.username],
201
+ expect: (users) => users[body.username]?.password === hash,
202
+ },
203
+ ));
117
204
  } catch (err) {
118
- request.log.warn(err, 'Failed to reload Authelia after user creation');
205
+ return sendFailure(request, reply, err, 'Failed to update user database');
119
206
  }
120
207
 
121
- return reply.code(201).send({
122
- ok: true,
208
+ const created = await findDirectoryUser(body.username);
209
+ return reply.code(persisted ? 201 : 200).send({
210
+ ok: persisted,
211
+ ...(persisted ? {} : { persisted: false }),
123
212
  user: {
124
213
  username: body.username,
125
214
  displayname: body.displayname,
126
215
  email: body.email,
127
216
  groups: body.groups,
217
+ disabled: false,
218
+ userId: created?.userId ?? null,
219
+ status: 'active',
128
220
  },
221
+ ...(warnings.length > 0 ? { warnings } : {}),
129
222
  });
130
223
  },
131
224
  );
132
225
 
133
- // PUT /api/users/:username — update a user
226
+ // PUT /api/users/:username — update a user; `disabled: true` disables
227
+ // them (the last enabled administrator cannot be), `false` enables them
134
228
  fastify.put(
135
229
  '/users/:username',
136
230
  {
@@ -144,83 +238,130 @@ export default async function usersRoutes(fastify, _opts) {
144
238
  const { username } = UsernameParamSchema.parse(request.params);
145
239
  const body = UpdateUserSchema.parse(request.body);
146
240
 
147
- let usersData;
148
- try {
149
- usersData = await readUsersRaw();
150
- } catch (err) {
151
- request.log.error(err, 'Failed to read user database');
152
- return reply.code(500).send({ error: 'Failed to read user database' });
153
- }
154
-
155
- if (!usersData.users[username]) {
156
- return reply.code(404).send({ error: 'User not found' });
157
- }
158
-
159
- const user = usersData.users[username];
160
- const groupsBefore = Array.isArray(user.groups) ? [...user.groups] : [];
161
-
162
- if (body.displayname !== undefined) {
163
- user.displayname = body.displayname;
164
- }
165
- if (body.email !== undefined) {
166
- user.email = body.email;
167
- }
168
- if (body.groups !== undefined) {
169
- user.groups = body.groups;
170
- }
241
+ let passwordHash;
171
242
  if (body.password !== undefined) {
172
243
  try {
173
- user.password = await hashPassword(body.password);
244
+ passwordHash = await hashPassword(body.password);
174
245
  } catch (err) {
175
246
  request.log.error(err, 'Failed to hash password');
176
247
  return reply.code(500).send({ error: 'Failed to hash password' });
177
248
  }
178
249
  }
179
250
 
251
+ /** Whether users.yml's entry holds every field this request set. */
252
+ const intended = (entry) =>
253
+ entry !== undefined &&
254
+ entry !== null &&
255
+ (body.displayname === undefined || entry.displayname === body.displayname) &&
256
+ (body.email === undefined || entry.email === body.email) &&
257
+ (body.groups === undefined || sameList(groupsOf(entry), [...body.groups].sort())) &&
258
+ (passwordHash === undefined || entry.password === passwordHash) &&
259
+ (body.disabled === undefined || (entry.disabled === true) === body.disabled);
260
+
261
+ let outcome;
262
+ let before = null;
180
263
  try {
181
- await writeUsers(usersData);
182
- } catch (err) {
183
- request.log.error(err, 'Failed to update user database');
184
- return reply.code(500).send({ error: 'Failed to update user database' });
185
- }
186
-
187
- try {
188
- await reloadAuthelia();
264
+ outcome = await changeUsers(
265
+ async () => {
266
+ const usersData = await readUsersOrFail();
267
+ const user = usersData.users[username];
268
+ if (!user) throw httpError('User not found', 404);
269
+ // As it was before this request (a second attempt finds it changed already).
270
+ before ??= { groups: groupsOf(user), disabled: user.disabled === true };
271
+ if (body.disabled === true && isEnabledAdmin(user)) {
272
+ const enabledAdmins = Object.values(usersData.users).filter(isEnabledAdmin).length;
273
+ if (enabledAdmins <= 1) {
274
+ throw httpError('Cannot disable the last enabled administrator', 400, {
275
+ code: 'last_admin',
276
+ });
277
+ }
278
+ }
279
+
280
+ if (body.displayname !== undefined) user.displayname = body.displayname;
281
+ if (body.email !== undefined) user.email = body.email;
282
+ if (body.groups !== undefined) user.groups = body.groups;
283
+ if (passwordHash !== undefined) user.password = passwordHash;
284
+ if (body.disabled === true) user.disabled = true;
285
+ if (body.disabled === false) delete user.disabled;
286
+
287
+ await writeUsersOrFail(usersData);
288
+ return user;
289
+ },
290
+ {
291
+ actor: actorFromRequest(request),
292
+ logger: request.log,
293
+ // Authelia's own password reset may rewrite users.yml under the
294
+ // change: it is checked after the restart, and made once more.
295
+ expect: (users) => intended(users[username]),
296
+ },
297
+ );
189
298
  } catch (err) {
190
- request.log.warn(err, 'Failed to reload Authelia after user update');
299
+ return sendFailure(request, reply, err, 'Failed to update user database');
191
300
  }
192
301
 
193
- // Registry scopes are granted at login from feria-* groups; losing a
194
- // group must not leave a valid token behind. Best effort — the user
195
- // update itself is already done.
196
- const warnings = [];
197
- if (body.groups !== undefined) {
302
+ const user = outcome.result;
303
+ const disabled = user.disabled === true;
304
+ const warnings = [...outcome.warnings];
305
+ const known = await findDirectoryUser(username);
306
+ const userId = known?.userId ?? null;
307
+
308
+ // Disabling is offboarding, which the change did as the directory saw
309
+ // the user become disabled (`changeUsers`): sessions ended, registry
310
+ // tokens revoked. Registry scopes are granted at login from feria-*
311
+ // groups too: losing one must not leave a valid token behind either.
312
+ const groupsAfter = groupsOf(user);
313
+ const groupsChanged = body.groups !== undefined && !sameList(before.groups, groupsAfter);
314
+ if (groupsChanged && !(disabled && !before.disabled)) {
198
315
  try {
199
316
  await revokeUserRegistryTokens(
200
317
  username,
201
- { before: groupsBefore, after: user.groups || [] },
318
+ { before: before.groups, after: groupsAfter },
202
319
  request.log,
320
+ { userId },
203
321
  );
204
322
  } catch (err) {
205
- request.log.warn({ username, err: err.message }, 'Failed to revoke registry tokens');
206
- warnings.push(`registry tokens were not revoked: ${err.message}`);
323
+ request.log.warn({ userId, err: err.message }, 'Failed to revoke registry tokens');
324
+ warnings.push({
325
+ step: 'registry',
326
+ message: `Registry tokens were not revoked: ${err.message}`,
327
+ });
207
328
  }
208
329
  }
209
330
 
331
+ // The directory audits status and attributes; these are the users API's own.
332
+ const audit = (action, details = {}) =>
333
+ recordAuditBestEffort(
334
+ { actor: actorFromRequest(request), action, details: { userId, ...details } },
335
+ request.log,
336
+ );
337
+ if (passwordHash !== undefined) await audit('user.password.set');
338
+ if (groupsChanged) {
339
+ await audit('user.groups.changed', {
340
+ added: groupsAfter.filter((g) => !before.groups.includes(g)),
341
+ removed: before.groups.filter((g) => !groupsAfter.includes(g)),
342
+ });
343
+ }
344
+
210
345
  return {
211
- ok: true,
346
+ ok: outcome.persisted,
212
347
  user: {
213
348
  username,
214
349
  displayname: user.displayname,
215
350
  email: user.email,
216
351
  groups: user.groups || [],
352
+ disabled,
353
+ userId,
354
+ status: disabled ? 'disabled' : 'active',
217
355
  },
356
+ ...(outcome.persisted ? {} : { persisted: false }),
218
357
  ...(warnings.length > 0 ? { warnings } : {}),
219
358
  };
220
359
  },
221
360
  );
222
361
 
223
- // DELETE /api/users/:username — delete a user
362
+ // DELETE /api/users/:username?erase= — delete a user. The directory keeps
363
+ // a tombstone (the id and the last known name and address, `removed`);
364
+ // `erase=true` scrubs it at once (see POST /api/identity/directory/:userId/erase)
224
365
  fastify.delete(
225
366
  '/users/:username',
226
367
  {
@@ -232,139 +373,72 @@ export default async function usersRoutes(fastify, _opts) {
232
373
  },
233
374
  async (request, reply) => {
234
375
  const { username } = UsernameParamSchema.parse(request.params);
376
+ const { erase } = DeleteQuerySchema.parse(request.query ?? {});
377
+ const actor = actorFromRequest(request);
235
378
 
236
- let usersData;
379
+ let outcome;
237
380
  try {
238
- usersData = await readUsersRaw();
239
- } catch (err) {
240
- request.log.error(err, 'Failed to read user database');
241
- return reply.code(500).send({ error: 'Failed to read user database' });
242
- }
243
-
244
- if (!usersData.users[username]) {
245
- return reply.code(404).send({ error: 'User not found' });
246
- }
247
-
248
- const userCount = Object.keys(usersData.users).length;
249
- if (userCount <= 1) {
250
- return reply.code(400).send({ error: 'Cannot delete the last user' });
251
- }
252
-
253
- delete usersData.users[username];
254
-
255
- try {
256
- await writeUsers(usersData);
257
- } catch (err) {
258
- request.log.error(err, 'Failed to update user database');
259
- return reply.code(500).send({ error: 'Failed to update user database' });
260
- }
261
-
262
- try {
263
- await reloadAuthelia();
264
- } catch (err) {
265
- request.log.warn(err, 'Failed to reload Authelia after user deletion');
266
- }
267
-
268
- // ---------------------------------------------------------------
269
- // Cascade cleanup. The user is already gone from users.yml; from
270
- // here on we never roll back. Failures collect into `warnings` so
271
- // the admin sees a partial-success response and can investigate.
272
- // ---------------------------------------------------------------
273
- const warnings = [];
274
-
275
- // 0. Registry (Feria) tokens the user holds — their groups are gone.
276
- try {
277
- await revokeUserRegistryTokens(username, { deleted: true }, request.log);
278
- } catch (err) {
279
- request.log.warn(
280
- { username, err: err.message },
281
- 'Cascade: registry token revocation failed',
381
+ outcome = await changeUsers(
382
+ async () => {
383
+ const usersData = await readUsersOrFail();
384
+ if (!usersData.users[username]) throw httpError('User not found', 404);
385
+ if (Object.keys(usersData.users).length <= 1) {
386
+ throw httpError('Cannot delete the last user', 400);
387
+ }
388
+ delete usersData.users[username];
389
+ await writeUsersOrFail(usersData);
390
+ },
391
+ {
392
+ actor,
393
+ logger: request.log,
394
+ deleted: [username],
395
+ expect: (users) => !Object.hasOwn(users, username),
396
+ },
282
397
  );
283
- warnings.push(`registry tokens were not revoked: ${err.message}`);
284
- }
285
-
286
- // 1. Legacy user-plugin-access.json grants (pre-gatekeeper migration)
287
- try {
288
- const removed = await removeGrantsForUser(username, request.log);
289
- if (removed > 0) {
290
- request.log.info(
291
- { username, removed },
292
- 'Cascade: removed legacy user-plugin-access grants',
293
- );
294
- }
295
398
  } catch (err) {
296
- request.log.warn(
297
- { err, username },
298
- 'Cascade: failed to remove legacy user-plugin-access grants',
299
- );
300
- warnings.push({
301
- step: 'user-plugin-access',
302
- message: `Failed to remove legacy grants: ${err.message}`,
303
- });
399
+ return sendFailure(request, reply, err, 'Failed to update user database');
304
400
  }
305
-
306
- // 2. Gatekeeper grants — single bulk-delete keyed by principal
307
- try {
308
- const result = await gatekeeperRequest(
309
- 'DELETE',
310
- `/api/grants?principalType=user&principalId=${encodeURIComponent(username)}`,
311
- );
312
- if (result.ok) {
313
- const removed =
314
- result.data && typeof result.data === 'object' && 'removed' in result.data
315
- ? result.data.removed
316
- : 0;
317
- if (removed > 0) {
318
- request.log.info(
319
- { username, removed },
320
- 'Cascade: removed gatekeeper grants for deleted user',
321
- );
322
- }
323
- } else if (result.reason === 'unreachable' || result.reason === 'no-secret') {
324
- request.log.warn(
325
- { username, reason: result.reason },
326
- 'Cascade: gatekeeper unreachable — grants for deleted user remain in access-grants.json',
327
- );
401
+ const userId =
402
+ outcome.directory?.removed.find((removed) => removed.username === username)?.userId ?? null;
403
+
404
+ // The user was offboarded as the directory saw them go (`changeUsers`):
405
+ // desktop sessions ended, registry tokens revoked, legacy
406
+ // user-plugin-access grants removed, Gatekeeper's grants and group
407
+ // memberships under the username dropped, Authelia's second factors
408
+ // deleted. What failed is among the warnings; the tombstone keeps a
409
+ // Gatekeeper cleanup pending until it succeeds.
410
+ const warnings = [...outcome.warnings];
411
+
412
+ // Erase the tombstone, when asked to. Its failure leaves the tombstone
413
+ // in place, to be erased later by its id.
414
+ let erased = false;
415
+ if (erase === 'true') {
416
+ if (userId === null) {
328
417
  warnings.push({
329
- step: 'gatekeeper',
418
+ step: 'erase',
330
419
  message:
331
- 'Gatekeeper service was unreachable. Re-run grant cleanup once it is back online — grants for this user may still exist in access-grants.json.',
420
+ 'The user directory holds no tombstone for this user — nothing was erased; erase it from the directory once it appears there',
332
421
  });
333
422
  } else {
334
- request.log.warn(
335
- { username, statusCode: result.statusCode, error: result.error },
336
- 'Cascade: gatekeeper returned an error',
337
- );
338
- warnings.push({
339
- step: 'gatekeeper',
340
- message: `Gatekeeper returned HTTP ${result.statusCode}: ${result.error}`,
341
- });
423
+ try {
424
+ await eraseDirectoryUser(userId, { actor, logger: request.log });
425
+ erased = true;
426
+ } catch (err) {
427
+ request.log.warn({ userId, err: err.message }, 'Cascade: erasing the user failed');
428
+ warnings.push({ step: 'erase', message: err.message });
429
+ }
342
430
  }
343
- } catch (err) {
344
- request.log.warn({ err, username }, 'Cascade: unexpected failure calling gatekeeper');
345
- warnings.push({
346
- step: 'gatekeeper',
347
- message: `Unexpected failure: ${err.message}`,
348
- });
349
431
  }
350
432
 
351
- // 3. Live user-access sessions — bump per-user epoch in panel.json so
352
- // middleware rejects every session token with iat < epoch.
353
- try {
354
- const epoch = await bumpUserEpoch(username);
355
- request.log.info({ username, epoch }, 'Cascade: bumped user-access session epoch');
356
- } catch (err) {
357
- request.log.warn(
358
- { err, username },
359
- 'Cascade: failed to bump user-access session epoch — old session tokens may still validate',
360
- );
361
- warnings.push({
362
- step: 'session-epoch',
363
- message: `Failed to invalidate live sessions: ${err.message}. Old session tokens for this username may still validate until their natural expiry.`,
364
- });
365
- }
366
-
367
- const response = { ok: true };
433
+ const tombstone = userId ? await getDirectoryUser(userId) : null;
434
+ const response = {
435
+ ok: outcome.persisted,
436
+ ...(outcome.persisted ? {} : { persisted: false }),
437
+ userId,
438
+ // Gatekeeper dropped the grants and group memberships under the username.
439
+ principalCleared: tombstone?.principalClearedAt != null,
440
+ ...(erase === 'true' ? { erased } : {}),
441
+ };
368
442
  if (warnings.length > 0) {
369
443
  response.warnings = warnings;
370
444
  }
@@ -383,10 +457,9 @@ export default async function usersRoutes(fastify, _opts) {
383
457
 
384
458
  let usersData;
385
459
  try {
386
- usersData = await readUsersRaw();
460
+ usersData = await readUsersOrFail();
387
461
  } catch (err) {
388
- request.log.error(err, 'Failed to read user database');
389
- return reply.code(500).send({ error: 'Failed to read user database' });
462
+ return sendFailure(request, reply, err, 'Failed to read user database');
390
463
  }
391
464
 
392
465
  if (!usersData.users[username]) {
@@ -402,6 +475,15 @@ export default async function usersRoutes(fastify, _opts) {
402
475
  return reply.code(500).send({ error: 'Failed to write TOTP configuration' });
403
476
  }
404
477
 
478
+ const known = await findDirectoryUser(username);
479
+ await recordAuditBestEffort(
480
+ {
481
+ actor: actorFromRequest(request),
482
+ action: 'user.totp.reset',
483
+ details: { userId: known?.userId ?? null },
484
+ },
485
+ request.log,
486
+ );
405
487
  return { ok: true, totpUri: uri };
406
488
  },
407
489
  );
@@ -11,6 +11,8 @@ import pluginRoutes from './management/plugins.js';
11
11
  import settingsRoutes from './management/settings.js';
12
12
  import ticketRoutes from './management/tickets.js';
13
13
  import identityRoutes from './management/identity.js';
14
+ import identityDirectoryRoutes from './management/identity-directory.js';
15
+ import pluginIdentityRoutes from './management/plugin-identity.js';
14
16
  import storageRoutes from './management/storage.js';
15
17
  import gatekeeperProxyRoutes from './management/gatekeeper-proxy.js';
16
18
  import agentsRoutes from './management/agents.js';
@@ -33,6 +35,8 @@ export default async function managementRoutes(fastify, _opts) {
33
35
  await fastify.register(settingsRoutes);
34
36
  await fastify.register(ticketRoutes);
35
37
  await fastify.register(identityRoutes);
38
+ await fastify.register(identityDirectoryRoutes);
39
+ await fastify.register(pluginIdentityRoutes);
36
40
  await fastify.register(storageRoutes);
37
41
  await fastify.register(gatekeeperProxyRoutes);
38
42
  await fastify.register(agentsRoutes);