@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
@@ -0,0 +1,358 @@
1
+ /**
2
+ * lamaste-server users — the panel's users and its user directory.
3
+ *
4
+ * Subcommands:
5
+ * list Users (Authelia's users file) with their status and
6
+ * directory id
7
+ * disable <username> The one offboarding action: they cannot sign in, every
8
+ * plugin audience shows them disabled, plugins' user
9
+ * surfaces refuse them, their desktop sessions and
10
+ * registry tokens end (the last enabled administrator
11
+ * cannot be disabled)
12
+ * enable <username> Undo it
13
+ * delete <username> [--erase]
14
+ * Delete: the directory keeps a tombstone (the id and
15
+ * the name and address they last had) so plugins'
16
+ * history stays readable; --erase scrubs it at once
17
+ * erase <userId> Erase a deleted user: the tombstone is scrubbed to the
18
+ * id, their invitations and Gatekeeper's access-request
19
+ * log entries go, plugins are sent the erasure
20
+ * directory Every user the directory knows — deleted and erased
21
+ * ones too — with Gatekeeper's cleanup of deleted
22
+ * usernames and the erasures plugins have not
23
+ * acknowledged
24
+ *
25
+ * Every subcommand goes through the running panel (`/api/users`,
26
+ * `/api/identity/directory`, admin certificate): users.yml is Authelia's
27
+ * credential file and the panel's one lock serializes every write of it.
28
+ * What the panel says is printed only as `terminalText`; `--json` emits it
29
+ * as it came.
30
+ */
31
+
32
+ import chalk from 'chalk';
33
+ import { panelRequest } from '../panel-api.js';
34
+ import { emit, emitComplete, emitError } from '../ndjson.js';
35
+ import { parseUserArgs, warningLine } from '../plugin-identity.js';
36
+ import { formatMoment, terminalText } from '../plugin-settings.js';
37
+
38
+ /**
39
+ * @param {string[]} args Subcommand args: ['list'] | ['disable', '<u>'] | ...
40
+ * @param {{ json: boolean }} options
41
+ */
42
+ export async function runUsers(args, { json }) {
43
+ const sub = args[0];
44
+ switch (sub) {
45
+ case 'list':
46
+ return listUsers({ json });
47
+ case 'disable':
48
+ case 'enable':
49
+ return setDisabledCmd(sub, args.slice(1), { json });
50
+ case 'delete':
51
+ return deleteUserCmd(args.slice(1), { json });
52
+ case 'erase':
53
+ return eraseUserCmd(args.slice(1), { json });
54
+ case 'directory':
55
+ return directoryCmd({ json });
56
+ default:
57
+ printUsersUsage();
58
+ process.exit(sub ? 1 : 0);
59
+ }
60
+ }
61
+
62
+ function printUsersUsage() {
63
+ const b = chalk.bold;
64
+ const c = chalk.cyan;
65
+ console.log(`
66
+ ${b('Usage:')} lamaste-server users <subcommand>
67
+
68
+ ${b('Subcommands:')}
69
+ ${c('list')} Users with their status (active, disabled) and directory id
70
+ ${c('disable <username>')} They cannot sign in; plugins see them disabled and refuse them;
71
+ desktop sessions and registry tokens end
72
+ ${c('enable <username>')} Enable them again
73
+ ${c('delete <username> [--erase]')} Delete; the directory keeps a tombstone (id, last name and
74
+ address) — --erase scrubs it at once (cannot be undone)
75
+ ${c('erase <userId>')} Erase a deleted user (an id from ${c('users directory')})
76
+ ${c('directory')} Every user the directory knows: deleted and erased ones, the
77
+ Gatekeeper cleanup of their usernames, pending erasures
78
+ `);
79
+ }
80
+
81
+ /**
82
+ * Exit with a usage error or a refused request, as text or NDJSON (with
83
+ * the refusal's `code`, e.g. `last_admin`, `user_not_removed`).
84
+ *
85
+ * @param {string} message
86
+ * @param {boolean} json
87
+ * @param {{ code?: string }} [err]
88
+ * @returns {never}
89
+ */
90
+ function fail(message, json, err = {}) {
91
+ if (json) emitError(message, false, typeof err.code === 'string' ? { code: err.code } : {});
92
+ else console.error(`\n ${chalk.red(terminalText(message))}\n`);
93
+ process.exit(1);
94
+ }
95
+
96
+ /** Print a change's warnings: what the panel could not complete beside it. */
97
+ function printWarnings(warnings) {
98
+ for (const warning of warnings ?? []) {
99
+ console.log(` ${chalk.yellow('!')} ${warningLine(warning)}`);
100
+ }
101
+ }
102
+
103
+ /** A user's status in its column: disabled in yellow, anything else in green. */
104
+ function statusCell(status) {
105
+ return (status === 'disabled' ? chalk.yellow : chalk.green)(status.padEnd(8));
106
+ }
107
+
108
+ /**
109
+ * The rows `users list` prints: each user's username, status, directory id
110
+ * (a dash until the directory has seen them) and groups, as terminal text.
111
+ * Pure, exported for tests.
112
+ *
113
+ * @param {Array<Record<string, any>>} users as `GET /api/users` lists them
114
+ */
115
+ export function userRows(users) {
116
+ return users.map((u) => ({
117
+ username: terminalText(u.username),
118
+ status: u.status === 'disabled' ? 'disabled' : 'active',
119
+ userId: u.userId ? terminalText(u.userId) : '—',
120
+ groups: terminalText((u.groups ?? []).join(', ')),
121
+ }));
122
+ }
123
+
124
+ /** @param {{ json: boolean }} options */
125
+ async function listUsers({ json }) {
126
+ let result;
127
+ try {
128
+ result = await panelRequest('GET', '/api/users');
129
+ } catch (err) {
130
+ fail(err.message, json, err);
131
+ }
132
+ if (json) {
133
+ emit(result);
134
+ return;
135
+ }
136
+ const rows = userRows(result.users ?? []);
137
+ if (rows.length === 0) {
138
+ console.log('\n No users.\n');
139
+ return;
140
+ }
141
+ const width = Math.max('USERNAME'.length, ...rows.map((r) => r.username.length));
142
+ console.log('');
143
+ console.log(chalk.bold(' Users'));
144
+ console.log(
145
+ chalk.dim(` ${'USERNAME'.padEnd(width)} STATUS ${'USER ID'.padEnd(36)} GROUPS`),
146
+ );
147
+ for (const r of rows) {
148
+ console.log(
149
+ ` ${chalk.cyan(r.username.padEnd(width))} ${statusCell(r.status)} ${r.userId.padEnd(36)} ${chalk.dim(r.groups)}`,
150
+ );
151
+ }
152
+ if (rows.some((r) => r.userId === '—')) {
153
+ console.log(
154
+ chalk.dim('\n — : not in the user directory yet; it adopts new users within a minute.'),
155
+ );
156
+ }
157
+ console.log('');
158
+ }
159
+
160
+ /**
161
+ * `users disable|enable <username>`.
162
+ *
163
+ * @param {'disable' | 'enable'} sub
164
+ * @param {string[]} args
165
+ * @param {{ json: boolean }} options
166
+ */
167
+ async function setDisabledCmd(sub, args, { json }) {
168
+ const parsed = parseUserArgs(sub, args);
169
+ if (!parsed.ok) fail(parsed.error, json);
170
+ const { username } = parsed;
171
+ const disabled = sub === 'disable';
172
+ let result;
173
+ try {
174
+ result = await panelRequest('PUT', `/api/users/${encodeURIComponent(username)}`, {
175
+ disabled,
176
+ });
177
+ } catch (err) {
178
+ fail(err.message, json, err);
179
+ }
180
+ if (json) {
181
+ emitComplete({ username, status: result.user.status, warnings: result.warnings ?? [] });
182
+ return;
183
+ }
184
+ console.log(
185
+ disabled
186
+ ? `\n ${chalk.green('✓')} ${chalk.cyan(username)} ${chalk.yellow('disabled')} — cannot sign in; plugins see them disabled and refuse them`
187
+ : `\n ${chalk.green('✓')} ${chalk.cyan(username)} ${chalk.green('enabled')}`,
188
+ );
189
+ printWarnings(result.warnings);
190
+ console.log('');
191
+ }
192
+
193
+ /**
194
+ * `users delete <username> [--erase]`.
195
+ *
196
+ * @param {string[]} args
197
+ * @param {{ json: boolean }} options
198
+ */
199
+ async function deleteUserCmd(args, { json }) {
200
+ const parsed = parseUserArgs('delete', args);
201
+ if (!parsed.ok) fail(parsed.error, json);
202
+ const { username, erase } = parsed;
203
+ let result;
204
+ try {
205
+ result = await panelRequest(
206
+ 'DELETE',
207
+ `/api/users/${encodeURIComponent(username)}${erase ? '?erase=true' : ''}`,
208
+ );
209
+ } catch (err) {
210
+ fail(err.message, json, err);
211
+ }
212
+ if (json) {
213
+ emitComplete({ username, ...result });
214
+ return;
215
+ }
216
+ console.log(`\n ${chalk.green('✓')} ${chalk.cyan(username)} deleted`);
217
+ if (result.userId) console.log(chalk.dim(` user id ${terminalText(result.userId)}`));
218
+ if (erase) {
219
+ console.log(
220
+ result.erased
221
+ ? ' and erased — plugins that had them are being sent the erasure'
222
+ : chalk.yellow(
223
+ ' not erased (see below) — erase it later with lamaste-server users erase <userId>',
224
+ ),
225
+ );
226
+ } else {
227
+ console.log(
228
+ chalk.dim(
229
+ ' The directory keeps a tombstone (the id and the name they last had) until it is erased.',
230
+ ),
231
+ );
232
+ }
233
+ if (!result.principalCleared) {
234
+ console.log(
235
+ chalk.yellow(
236
+ ' Gatekeeper has not dropped their grants and group memberships yet — retried every minute;\n' +
237
+ ' nothing is given to the username again until it has.',
238
+ ),
239
+ );
240
+ }
241
+ printWarnings(result.warnings);
242
+ console.log('');
243
+ }
244
+
245
+ /**
246
+ * `users erase <userId>`.
247
+ *
248
+ * @param {string[]} args
249
+ * @param {{ json: boolean }} options
250
+ */
251
+ async function eraseUserCmd(args, { json }) {
252
+ const parsed = parseUserArgs('erase', args);
253
+ if (!parsed.ok) fail(parsed.error, json);
254
+ const { userId } = parsed;
255
+ let result;
256
+ try {
257
+ result = await panelRequest(
258
+ 'POST',
259
+ `/api/identity/directory/${encodeURIComponent(userId)}/erase`,
260
+ );
261
+ } catch (err) {
262
+ fail(err.message, json, err);
263
+ }
264
+ if (json) {
265
+ emitComplete(result);
266
+ return;
267
+ }
268
+ console.log(`\n ${chalk.green('✓')} ${terminalText(userId)} erased`);
269
+ console.log(
270
+ chalk.dim(
271
+ ` ${result.invitationsPurged} invitation(s) and ${result.accessLogPurged} access-request log entr${result.accessLogPurged === 1 ? 'y' : 'ies'} deleted;` +
272
+ '\n every plugin that had them is sent the erasure (lamaste-server users directory shows who has not acknowledged it).' +
273
+ "\n Authelia's own authentication log is outside Lamaste's reach.",
274
+ ),
275
+ );
276
+ console.log('');
277
+ }
278
+
279
+ /**
280
+ * What `users directory` says of a removed user: who they were (or that
281
+ * they were erased), how they were removed, and what is still pending —
282
+ * Gatekeeper's cleanup of their username, or the plugins that have not
283
+ * acknowledged their erasure. Pure, exported for tests.
284
+ *
285
+ * @param {Record<string, any>} user as `GET /api/identity/directory` lists them
286
+ * @param {number} now epoch ms
287
+ * @returns {{ userId: string, who: string, removed: string, pending: string | null }}
288
+ */
289
+ export function removedUserText(user, now) {
290
+ const erased = user.erasedAt != null;
291
+ const who = erased
292
+ ? 'erased'
293
+ : terminalText(
294
+ user.displayName && user.username
295
+ ? `${user.username} (${user.displayName})`
296
+ : (user.username ?? '—'),
297
+ );
298
+ const how =
299
+ user.removedReason === 'deleted-outside'
300
+ ? 'gone from users.yml (not through Lamaste)'
301
+ : 'deleted';
302
+ const removed = user.removedAt ? `${how} ${formatMoment(user.removedAt, now)}` : how;
303
+ let pending = null;
304
+ if (erased) {
305
+ const plugins = Array.isArray(user.pendingErasures) ? user.pendingErasures : [];
306
+ const inactive = Array.isArray(user.pendingErasuresInactive)
307
+ ? user.pendingErasuresInactive
308
+ : [];
309
+ const named = plugins.map((name) =>
310
+ inactive.includes(name) ? `${name} (not taking the feed; an agent keeps its data)` : name,
311
+ );
312
+ pending =
313
+ named.length > 0 ? `erasure not yet acknowledged by ${terminalText(named.join(', '))}` : null;
314
+ } else if (user.principalClearedAt === null) {
315
+ pending = 'Gatekeeper cleanup of the username pending (retried every minute)';
316
+ }
317
+ return { userId: terminalText(user.userId), who, removed, pending };
318
+ }
319
+
320
+ /** @param {{ json: boolean }} options */
321
+ async function directoryCmd({ json }) {
322
+ let result;
323
+ try {
324
+ result = await panelRequest('GET', '/api/identity/directory');
325
+ } catch (err) {
326
+ fail(err.message, json, err);
327
+ }
328
+ if (json) {
329
+ emit(result);
330
+ return;
331
+ }
332
+ const now = Date.now();
333
+ const users = result.users ?? [];
334
+ const holding = users.filter((u) => u.status !== 'removed');
335
+ const removed = users.filter((u) => u.status === 'removed');
336
+ console.log('');
337
+ console.log(chalk.bold(' User directory') + chalk.dim(` ${terminalText(result.directoryId)}`));
338
+ console.log(chalk.dim(' ' + '─'.repeat(40)));
339
+ for (const u of holding) {
340
+ console.log(
341
+ ` ${terminalText(u.userId)} ${statusCell(u.status)} ${chalk.cyan(terminalText(u.username))}`,
342
+ );
343
+ }
344
+ if (holding.length === 0) console.log(chalk.dim(' No users.'));
345
+ if (removed.length > 0) {
346
+ console.log('');
347
+ console.log(chalk.bold(' Deleted users') + chalk.dim(' — tombstones until they are erased'));
348
+ for (const u of removed) {
349
+ const text = removedUserText(u, now);
350
+ console.log(` ${text.userId} ${text.who} ${chalk.dim(text.removed)}`);
351
+ if (text.pending) console.log(` ${chalk.yellow(text.pending)}`);
352
+ }
353
+ if (removed.some((u) => u.erasedAt === null)) {
354
+ console.log(chalk.dim('\n Erase a tombstone: lamaste-server users erase <userId>'));
355
+ }
356
+ }
357
+ console.log('');
358
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `lamaste-server plugins hooks|update|approve-hooks`: why an update waits
3
- * for approval, the command that approves it, and the arguments that
3
+ * for approval, what an identity block lets a plugin learn and how an
4
+ * update changes it, the command that approves it, and the arguments that
4
5
  * command takes. Pure — no output — so they are tested as they are.
5
6
  */
6
7
 
@@ -10,6 +11,8 @@ const DIGEST_RE = /^[a-f0-9]{64}$/;
10
11
  const VERSION_RE = /^[0-9A-Za-z.+-]{1,64}$/;
11
12
  /** The reason an update turns secret settings into plain ones. */
12
13
  const SETTINGS_SECRET_TO_PLAIN = 'settings.secret-to-plain';
14
+ /** The reason an update adds or removes roles, or widens the attributes its users' records carry. */
15
+ const IDENTITY_CHANGED = 'identity.changed';
13
16
 
14
17
  /** How `approve-hooks` is called. */
15
18
  export const APPROVE_HOOKS_USAGE =
@@ -18,12 +21,13 @@ export const APPROVE_HOOKS_USAGE =
18
21
  /**
19
22
  * Why a pending update waits, as the terminal shows it: each reason of
20
23
  * `approvalRequired` (`[{ code, reason }]`; a secret turned plain is a
21
- * warning), and the sentences of `autoApproveWithheld` that do not repeat
22
- * one of them — what auto-approve, when on, could not cover beyond that.
23
- * Every text as {@link terminalText}.
24
+ * warning; an identity change carries a note that auto-approve never
25
+ * covers it), and the sentences of `autoApproveWithheld` that do not
26
+ * repeat one of them — what auto-approve, when on, could not cover beyond
27
+ * that. Every text as {@link terminalText}.
24
28
  *
25
29
  * @param {{ approvalRequired?: unknown, autoApproveWithheld?: unknown }} update
26
- * @returns {{ required: Array<{ text: string, warning: boolean }>, notCovered: string[] }}
30
+ * @returns {{ required: Array<{ text: string, warning: boolean, note: string | null }>, notCovered: string[] }}
27
31
  */
28
32
  export function pendingUpdateReasons(update) {
29
33
  const required = (Array.isArray(update?.approvalRequired) ? update.approvalRequired : [])
@@ -31,6 +35,10 @@ export function pendingUpdateReasons(update) {
31
35
  .map((entry) => ({
32
36
  text: terminalText(entry.reason),
33
37
  warning: entry.code === SETTINGS_SECRET_TO_PLAIN,
38
+ note:
39
+ entry.code === IDENTITY_CHANGED
40
+ ? 'who its users are and what it learns about them — auto-approve never covers it'
41
+ : null,
34
42
  }));
35
43
  const said = new Set(required.map((line) => line.text));
36
44
  const notCovered = (Array.isArray(update?.autoApproveWithheld) ? update.autoApproveWithheld : [])
@@ -39,6 +47,100 @@ export function pendingUpdateReasons(update) {
39
47
  return { required, notCovered };
40
48
  }
41
49
 
50
+ /** What a plugin may declare to receive about its users, in words. */
51
+ const ATTRIBUTE_WORDS = { displayName: 'display name', email: 'email address' };
52
+
53
+ /** An identity attribute in words (`displayName` → display name). */
54
+ function attributeWords(attribute) {
55
+ return ATTRIBUTE_WORDS[attribute] ?? terminalText(attribute);
56
+ }
57
+
58
+ /**
59
+ * An identity block (normalized: `{ roles: [{ id, label, description? }],
60
+ * attributes }`) as two lines — its roles, and what it receives about each
61
+ * member. Empty for anything that is not a block. Every line as
62
+ * {@link terminalText}.
63
+ *
64
+ * @param {unknown} block
65
+ * @returns {string[]}
66
+ */
67
+ export function identityBlockLines(block) {
68
+ if (block === null || typeof block !== 'object' || !Array.isArray(block.roles)) return [];
69
+ const roles = block.roles
70
+ .filter((role) => role !== null && typeof role === 'object')
71
+ .map((role) => `${role.id} (${role.label}${role.description ? ` — ${role.description}` : ''})`);
72
+ const attributes = Array.isArray(block.attributes) ? block.attributes.map(attributeWords) : [];
73
+ return [
74
+ terminalText(`roles: ${roles.length > 0 ? roles.join(', ') : 'none'}`),
75
+ terminalText(
76
+ `receives: ${['user id', 'username', 'roles', 'status', ...attributes].join(', ')} — of the users entitled to it`,
77
+ ),
78
+ ];
79
+ }
80
+
81
+ /**
82
+ * How an update changes a plugin's identity block (`pendingUpdate.
83
+ * identity`: `{ before, after, rolesAdded, rolesRemoved, attributesAdded,
84
+ * attributesRemoved, labelsChanged, grantsAffected? }` — `grantsAffected`:
85
+ * how many role grants approving it rewrites or revokes, null when unknown):
86
+ * a headline and a line per kind of
87
+ * change — roles added or removed and attributes widened wait for an
88
+ * administrator (`approval`), narrowing and label edits only change the
89
+ * digest. Null when neither version declares a block, or it is unchanged.
90
+ * Every text as {@link terminalText}.
91
+ *
92
+ * @param {unknown} diff
93
+ * @returns {{ headline: string, lines: Array<{ text: string, approval: boolean }> } | null}
94
+ */
95
+ export function identityDiffLines(diff) {
96
+ if (diff === null || typeof diff !== 'object') return null;
97
+ const list = (value) => (Array.isArray(value) ? value.map(String) : []);
98
+ const labelOf = (block, id) =>
99
+ Array.isArray(block?.roles) ? (block.roles.find((r) => r?.id === id)?.label ?? null) : null;
100
+ const role = (block) => (id) => {
101
+ const label = labelOf(block, id);
102
+ return label ? `${id} (${label})` : id;
103
+ };
104
+ const lines = [];
105
+ const add = (label, entries, approval) => {
106
+ if (entries.length === 0) return;
107
+ lines.push({
108
+ text: terminalText(
109
+ `${label}: ${entries.join(', ')}${approval ? ' — waits for approval' : ''}`,
110
+ ),
111
+ approval,
112
+ });
113
+ };
114
+ add('roles added', list(diff.rolesAdded).map(role(diff.after)), true);
115
+ add('roles removed', list(diff.rolesRemoved).map(role(diff.before)), true);
116
+ add('receives more about its users', list(diff.attributesAdded).map(attributeWords), true);
117
+ add('receives less about its users', list(diff.attributesRemoved).map(attributeWords), false);
118
+ add('role labels changed', list(diff.labelsChanged).map(role(diff.after)), false);
119
+ // A removed role stays removed: approving revokes it from the grants that hold it.
120
+ if (list(diff.rolesRemoved).length > 0 && 'grantsAffected' in diff) {
121
+ const affected = diff.grantsAffected;
122
+ const parts =
123
+ affected === null || typeof affected !== 'object'
124
+ ? ['not known (Gatekeeper could not be read)']
125
+ : [
126
+ ...(affected.revoked > 0 ? [`${affected.revoked} revoked`] : []),
127
+ ...(affected.rewritten > 0
128
+ ? [`${affected.rewritten} narrowed to the roles that stay`]
129
+ : []),
130
+ ];
131
+ add('role grants affected', parts.length > 0 ? parts : ['none'], true);
132
+ }
133
+ const before = diff.before != null;
134
+ const after = diff.after != null;
135
+ if (lines.length === 0 && before === after) return null;
136
+ const headline = !before
137
+ ? 'declares identity for the first time'
138
+ : !after
139
+ ? 'no longer declares identity — it is sent no users, its role grants are revoked and its user surface is no longer served'
140
+ : 'changes its identity';
141
+ return { headline, lines };
142
+ }
143
+
42
144
  /**
43
145
  * The command that approves what plugin `name` brings: the installed
44
146
  * version by its digest (`{ digest }`), or a pending update by its version