@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
@@ -5,14 +5,17 @@
5
5
  *
6
6
  * Approval binds bytes (see `hook-set.ts` in the core library): one digest
7
7
  * (`approvalDigest`) covers the relay hook set — the normalised relay
8
- * declarations and the SHA-256 of the relay entry module — and the agent
9
- * half Lamaste installs onto the hosting agent — its package, version,
10
- * tarball integrity, service and system packages. Approval is needed when
11
- * either exists. Lamaste runs relay hooks, hosts the plugin and installs its
12
- * agent half only while the installed version's digest is the approved
13
- * one. Settings are outside the digest, but an update that turns a secret
14
- * setting into a plain one waits for an administrator's approval all the
15
- * same (`plugin-lifecycle.js`): the same pending update, approved here.
8
+ * declarations and the SHA-256 of the relay entry module — the agent half
9
+ * Lamaste installs onto the hosting agent — its package, version, tarball
10
+ * integrity, service and system packages — and the plugin's identity block
11
+ * — the roles its users hold and the attributes it receives about them.
12
+ * Approval is needed when any exists. Lamaste runs relay hooks, hosts the
13
+ * plugin, installs its agent half and sends it its users only while the
14
+ * installed version's digest is the approved one. Settings are outside the
15
+ * digest, but an update that turns a secret setting into a plain one waits
16
+ * for an administrator's approval all the same (`plugin-lifecycle.js`), and
17
+ * so does one that adds or removes a role or widens the attributes: the
18
+ * same pending update, approved here.
16
19
  *
17
20
  * The runner is about the relay part alone: it holds a root-owned copy of
18
21
  * the approved module that lamaste-priv installed after checking its
@@ -216,22 +219,27 @@ export function installedAgentHalf(plugin) {
216
219
  }
217
220
 
218
221
  /**
219
- * What approving a plugin version covers — its relay hook set and the
220
- * agent half Lamaste installs — and the digest the approval names. `digest`
221
- * is null when there is nothing to approve.
222
+ * What approving a plugin version covers — its relay hook set, the agent
223
+ * half Lamaste installs and its identity block (normalized, as the registry
224
+ * stores it) — and the digest the approval names. `digest` is null when
225
+ * there is nothing to approve. A plugin without an identity block has the
226
+ * digest it had before identity existed.
222
227
  */
223
228
  export function installedApproval(plugin) {
224
229
  const relay = installedRelayHooks(plugin);
225
230
  const agentHalf = installedAgentHalf(plugin);
226
- const required = relay !== null || agentHalf !== null;
231
+ const identity = plugin.identity ?? null;
232
+ const required = relay !== null || agentHalf !== null || identity !== null;
227
233
  return {
228
234
  relay,
229
235
  agentHalf,
236
+ identity,
230
237
  required,
231
238
  digest: required
232
239
  ? approvalDigest({
233
240
  relay: relay ? { set: relay.set, entrySha256: relay.entrySha256 } : null,
234
241
  agentHalf,
242
+ identity,
235
243
  })
236
244
  : null,
237
245
  };
@@ -243,9 +251,10 @@ function isApproved(approval, state) {
243
251
  }
244
252
 
245
253
  /**
246
- * Whether the installed version is approved — it brings neither relay hooks
247
- * nor an agent half Lamaste installs, or its digest is the approved one.
248
- * Relay hooks run, and the plugin is hosted, only then.
254
+ * Whether the installed version is approved — it brings no relay hooks, no
255
+ * agent half Lamaste installs and no identity block, or its digest is the
256
+ * approved one. Relay hooks run, the plugin is hosted and its users are
257
+ * sent to it only then.
249
258
  */
250
259
  export async function installedVersionApproved(plugin) {
251
260
  const approval = installedApproval(plugin);
@@ -253,17 +262,34 @@ export async function installedVersionApproved(plugin) {
253
262
  return isApproved(approval, await readHookState(plugin.name));
254
263
  }
255
264
 
256
- /** What the panel shows about a plugin's hooks, its agent half and their approval. */
265
+ /**
266
+ * What the panel shows about a plugin's hooks, its agent half, its identity
267
+ * (`identity`: whether the installed version declares it, whether that
268
+ * version is approved, and the normalized block) and their approval.
269
+ */
257
270
  export async function hookStatus(plugin) {
258
271
  const approval = installedApproval(plugin);
259
- const { relay, agentHalf } = approval;
272
+ const { relay, agentHalf, identity } = approval;
260
273
  const agent = agentHookSetOf({ hooks: plugin.hooks });
261
274
  const state = await readHookState(plugin.name);
262
275
  const approved = isApproved(approval, state);
276
+ let pendingUpdate = null;
277
+ if (state?.pendingUpdate) {
278
+ const { withIdentityGrantImpact } = await import('./entitlements.js');
279
+ pendingUpdate = await withIdentityGrantImpact(
280
+ pendingUpdateView(state.pendingUpdate),
281
+ plugin.route,
282
+ );
283
+ }
263
284
  return {
264
285
  relay: relay ? relayHookSetPreview(relay.set, relay.entrySha256) : null,
265
286
  agent,
266
287
  agentHalf: agentHalf ? agentHalfPreview(derivePluginRoute(plugin.name), agentHalf) : null,
288
+ identity: {
289
+ declared: identity !== null,
290
+ approved: identity !== null && approved,
291
+ block: identity,
292
+ },
267
293
  approval: {
268
294
  required: approval.required,
269
295
  state: !approval.required ? 'not-required' : approved ? 'approved' : 'pending',
@@ -279,7 +305,7 @@ export async function hookStatus(plugin) {
279
305
  installedDigest: state?.installedDigest ?? null,
280
306
  current: relay !== null && approved && state?.installedDigest === relay.digest,
281
307
  },
282
- pendingUpdate: state?.pendingUpdate ? pendingUpdateView(state.pendingUpdate) : null,
308
+ pendingUpdate,
283
309
  };
284
310
  }
285
311
 
@@ -287,22 +313,29 @@ export async function hookStatus(plugin) {
287
313
  export const DIGEST_UNAPPROVED = 'digest.unapproved';
288
314
  /** Why an update waits: it turns secret settings into plain ones. */
289
315
  export const SETTINGS_SECRET_TO_PLAIN = 'settings.secret-to-plain';
316
+ /** Why an update waits: it adds or removes a role, or widens the attributes it receives. */
317
+ export const IDENTITY_CHANGED = 'identity.changed';
290
318
 
291
319
  /**
292
320
  * The reason a staged version waits because what it brings — its relay
293
- * hooks (`relay`), the agent half Lamaste installs (`agentHalf`); either
294
- * truthy when it brings one — is not what was approved.
321
+ * hooks (`relay`), the agent half Lamaste installs (`agentHalf`), its
322
+ * identity block (`identity`); each truthy when it brings one — is not what
323
+ * was approved.
295
324
  *
296
325
  * @returns {{ code: string, reason: string }}
297
326
  */
298
- export function digestUnapprovedReason({ relay, agentHalf }) {
299
- const what =
300
- relay && agentHalf
301
- ? 'its relay hooks and agent half are'
302
- : agentHalf
303
- ? 'its agent half is'
304
- : 'its relay hooks are';
305
- return { code: DIGEST_UNAPPROVED, reason: `${what} not what was approved` };
327
+ export function digestUnapprovedReason({ relay, agentHalf, identity = null }) {
328
+ const parts = [
329
+ ...(relay || (!agentHalf && !identity) ? ['relay hooks'] : []),
330
+ ...(agentHalf ? ['agent half'] : []),
331
+ ...(identity ? ['identity'] : []),
332
+ ];
333
+ const named =
334
+ parts.length === 1
335
+ ? parts[0]
336
+ : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
337
+ const verb = parts.length === 1 && parts[0] !== 'relay hooks' ? 'is' : 'are';
338
+ return { code: DIGEST_UNAPPROVED, reason: `its ${named} ${verb} not what was approved` };
306
339
  }
307
340
 
308
341
  /**
@@ -314,7 +347,11 @@ export function digestUnapprovedReason({ relay, agentHalf }) {
314
347
  function pendingApprovalRequired(pending) {
315
348
  return (
316
349
  pending.approvalRequired ?? [
317
- digestUnapprovedReason({ relay: pending.preview, agentHalf: pending.agentHalf }),
350
+ digestUnapprovedReason({
351
+ relay: pending.preview,
352
+ agentHalf: pending.agentHalf,
353
+ identity: pending.identity?.after ?? null,
354
+ }),
318
355
  ]
319
356
  );
320
357
  }
@@ -323,14 +360,18 @@ function pendingApprovalRequired(pending) {
323
360
  * A staged update waiting for approval as clients see it — in the hook
324
361
  * status and in the update response alike: its version, the digest to
325
362
  * approve it with (null when it brings neither relay hooks nor an agent
326
- * half Lamaste installs: it is approved by its version), what it brings
327
- * (its relay hook set and the agent half Lamaste installs, each null when
328
- * it brings none), how it changes the settings schema (`settings`:
329
- * `{ added: [{ key, type }], removed: [{ key, type }], retyped: [{ key,
330
- * from, to }], newRequired: [key], secretToPlain: [key] }`, null when
331
- * neither version declares settings — not part of the digest), why it
332
- * waits for an administrator (`approvalRequired`: `[{ code, reason }]`,
333
- * `digest.unapproved` and `settings.secret-to-plain`; never empty), and —
363
+ * half Lamaste installs nor an identity block: it is approved by its
364
+ * version), what it brings (its relay hook set and the agent half Lamaste
365
+ * installs, each null when it brings none), how it changes the settings
366
+ * schema (`settings`: `{ added: [{ key, type }], removed: [{ key, type }],
367
+ * retyped: [{ key, from, to }], newRequired: [key], secretToPlain: [key] }`,
368
+ * null when neither version declares settings — not part of the digest),
369
+ * how it changes the identity block (`identity`: `{ before, after,
370
+ * rolesAdded, rolesRemoved, attributesAdded, attributesRemoved,
371
+ * labelsChanged }` with the normalized blocks, null when neither version
372
+ * declares one — part of the digest), why it waits for an administrator
373
+ * (`approvalRequired`: `[{ code, reason }]`, `digest.unapproved`,
374
+ * `settings.secret-to-plain` and `identity.changed`; never empty), and —
334
375
  * when the plugin has auto-approve on — why auto-approve did not cover it
335
376
  * (`autoApproveWithheld`, sentences; null otherwise). The stored record
336
377
  * names the relay hook set's preview `preview`.
@@ -343,6 +384,7 @@ export function pendingUpdateView(pending) {
343
384
  relay: pending.preview ?? null,
344
385
  agentHalf: pending.agentHalf ?? null,
345
386
  settings: pending.settings ?? null,
387
+ identity: pending.identity ?? null,
346
388
  approvalRequired: pendingApprovalRequired(pending),
347
389
  autoApproveWithheld: pending.autoApproveWithheld ?? null,
348
390
  };
@@ -462,12 +504,14 @@ export async function restartRelayHookRunner(plugin, logger) {
462
504
 
463
505
  /**
464
506
  * An administrator approves what a plugin version brings — its relay hook
465
- * set and the agent half Lamaste installs — with the digest they were
466
- * shown: the installed version's, or a pending update's. A pending update
467
- * is also approved by naming its `version`, with its digest when it has
468
- * one — the only way to approve one that brings neither relay hooks nor an
469
- * agent half (its digest is null), and what binds the approval of a
470
- * settings change to the release that was shown.
507
+ * set, the agent half Lamaste installs and its identity block — with the
508
+ * digest they were shown: the installed version's, or a pending update's.
509
+ * A pending update is also approved by naming its `version`, with its
510
+ * digest when it has one — the only way to approve one that brings neither
511
+ * relay hooks nor an agent half nor an identity block (its digest is
512
+ * null), and what binds the approval of a settings change to the release
513
+ * that was shown. Approving an installed version that declares identity
514
+ * computes its audience and sends it to its agent half.
471
515
  *
472
516
  * The installed version's approval is recorded here, in the hook state and
473
517
  * the audit log. A pending update's is checked against the update that is
@@ -506,7 +550,7 @@ export async function approveHookSet(
506
550
  const pendingDigest = pending.digest ?? null;
507
551
  if (digest === null && pendingDigest !== null) {
508
552
  throw httpError(
509
- `Version ${version} brings relay hooks or an agent half — approve it with the approval digest it shows`,
553
+ `Version ${version} brings relay hooks, an agent half or an identity block — approve it with the approval digest it shows`,
510
554
  400,
511
555
  );
512
556
  }
@@ -530,7 +574,7 @@ export async function approveHookSet(
530
574
  if (pending && pending.digest === digest) return pendingUpdateApproval(pending);
531
575
  if (!approval.required) {
532
576
  throw httpError(
533
- `Plugin "${plugin.name}" has no relay hooks and no agent half Lamaste installs — nothing to approve`,
577
+ `Plugin "${plugin.name}" has no relay hooks, no agent half Lamaste installs and no identity block — nothing to approve`,
534
578
  400,
535
579
  );
536
580
  }
@@ -553,6 +597,15 @@ export async function approveHookSet(
553
597
  details: { digest, version: plugin.version, for: 'installed', certSerial },
554
598
  });
555
599
  await ensureRelayHooksInstalled(plugin, logger);
600
+ // An approved identity block lets the plugin's users through: its
601
+ // audience is computed and sent to its agent half (when it is hosted), and
602
+ // the hosts its user surface is bound to start serving it.
603
+ if (approval.identity) {
604
+ const { refreshPluginIdentity } = await import('./identity-sync.js');
605
+ await refreshPluginIdentity(plugin.name, logger);
606
+ const { refreshUserSurfaceVhosts } = await import('./user-surface.js');
607
+ await refreshUserSurfaceVhosts(plugin.route, logger);
608
+ }
556
609
  return { target: 'installed', digest };
557
610
  }
558
611
 
@@ -69,6 +69,8 @@ import { recordAudit } from './audit.js';
69
69
  import { onHealthTransition } from './tunnel-hooks.js';
70
70
  import { agentLastSeenAt, agentPresenceMap } from './agent-presence.js';
71
71
  import { refreshPluginSettingsBestEffort } from './plugin-settings.js';
72
+ import { refreshPluginIdentity } from './identity-sync.js';
73
+ import { refreshUserSurfaceVhosts } from './user-surface.js';
72
74
 
73
75
  const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
74
76
 
@@ -311,7 +313,7 @@ export async function hostPlugin(name, agentLabel, { actor, logger, sourceIp })
311
313
  }
312
314
  if (!(await installedVersionApproved(plugin))) {
313
315
  throw httpError(
314
- `Plugin "${name}" brings relay hooks or an agent half that are not approved — review and approve them first`,
316
+ `Plugin "${name}" brings relay hooks, an agent half or an identity block that are not approved — review and approve them first`,
315
317
  409,
316
318
  { code: 'hooks_not_approved' },
317
319
  );
@@ -339,9 +341,17 @@ export async function hostPlugin(name, agentLabel, { actor, logger, sourceIp })
339
341
  logger,
340
342
  run: async (op) => {
341
343
  const result = await runInstallPipeline(op, ctx);
342
- // The agent half starts without settings — or with those it kept on
343
- // this agent from an earlier hosting: the relay's cache follows it.
344
- if (result.status === 'succeeded') await refreshPluginSettingsBestEffort(plugin.name, logger);
344
+ if (result.status === 'succeeded') {
345
+ // The agent half starts without settings — or with those it kept on
346
+ // this agent from an earlier hosting: the relay's cache follows it.
347
+ await refreshPluginSettingsBestEffort(plugin.name, logger);
348
+ // A plugin that declares identity learns its users now: its first
349
+ // sync replays the whole audience.
350
+ await refreshPluginIdentity(plugin.name, logger);
351
+ // Its user surface's locations take the body limit of the internal
352
+ // tunnel the hosting brought.
353
+ await refreshUserSurfaceVhosts(plugin.route, logger);
354
+ }
345
355
  return result;
346
356
  },
347
357
  });
@@ -528,6 +538,9 @@ export async function purgeRetainedData(name, agentLabel, { actor, logger, sourc
528
538
  plugin: name,
529
539
  details: { agentLabel, route: retained.route, package: retained.package },
530
540
  });
541
+ // The last data of an uninstalled plugin: its identity ledger goes too.
542
+ const { dropIdentityLedgerIfNoData } = await import('./audience.js');
543
+ await dropIdentityLedgerIfNoData(name, logger);
531
544
  return { forgotten: retained };
532
545
  }
533
546
  if (!(await isActiveAgent(agentLabel))) {
@@ -613,7 +626,19 @@ export async function acknowledgeAgentPluginConfig(label, route, issuedAt) {
613
626
  // ---------------------------------------------------------------------------
614
627
 
615
628
  /**
616
- * Resolve a route to a forward target (see `agentForwarderPlugin`).
629
+ * Resolve a route to a forward target — of the admin forwarder
630
+ * (`agentForwarderPlugin`) and the user surface alike.
631
+ *
632
+ * While the hosting's health is `down` — the monitor's probes found nothing
633
+ * on the relay-loopback port that answers with the installation secret —
634
+ * this refuses with `down` and the health's reason (`tunnel` or `app`), so
635
+ * nothing of an administrator's, an agent's or a user's request reaches
636
+ * whatever holds that port: the admin forwarder answers as for a dead
637
+ * tunnel (the `tunnel:down` fallback, or `502` with the reason), the user
638
+ * surface `503`. Lamaste's own calls — the health probe, settings, hooks,
639
+ * the identity feed — do not come this way, so the monitor still finds the
640
+ * agent half when it is back and the hosting comes up again.
641
+ *
617
642
  * @param {string} route
618
643
  */
619
644
  export async function resolveForwardTarget(route) {
@@ -623,6 +648,9 @@ export async function resolveForwardTarget(route) {
623
648
  if (plugin.status !== 'enabled') return { kind: 'disabled' };
624
649
  const record = await ownHosting(plugin);
625
650
  if (!record || pluginHostingPhase(record) !== 'active') return { kind: 'not_hosted' };
651
+ if (record.health?.state === 'down') {
652
+ return { kind: 'down', reason: record.health.reason === 'app' ? 'app' : 'tunnel' };
653
+ }
626
654
  const tunnels = await readTunnels();
627
655
  const tunnel = tunnels.find((t) => t.id === record.tunnelId);
628
656
  const maxBodyMb = tunnel?.maxBodySizeMb ?? TUNNEL_MAX_BODY_MB.default;
@@ -18,7 +18,11 @@
18
18
  * a version that turns a secret setting into a plain one always waits for
19
19
  * an administrator's approval, its digest approved or not, and whatever
20
20
  * auto-approve says (`pendingUpdate.approvalRequired` says why an update
21
- * waits).
21
+ * waits). The identity block is inside the digest; a version that adds or
22
+ * removes a role or widens the attributes its users' records carry waits
23
+ * for an administrator too, whatever auto-approve says
24
+ * (`identity.changed`; `pendingUpdate.identity` shows the change), and
25
+ * after the update the audiences are recomputed and the agent half told.
22
26
  */
23
27
 
24
28
  import {
@@ -27,6 +31,8 @@ import {
27
31
  derivePluginRoute,
28
32
  hasAgentHalf,
29
33
  hookEntrySha256,
34
+ identityChangeNeedsApproval,
35
+ identityDiff,
30
36
  relayHookSetNeedsEntry,
31
37
  relayHookSetOf,
32
38
  relayHookSetPreview,
@@ -39,6 +45,7 @@ import { setPluginCapabilities } from './mtls.js';
39
45
  import {
40
46
  findPlugin,
41
47
  getPluginCapabilities,
48
+ identityOf,
42
49
  manifestPolicyError,
43
50
  replacePluginPackage,
44
51
  uninstallPlugin,
@@ -47,6 +54,7 @@ import {
47
54
  import { pinAgentHalf, readRegistryPackage } from './plugin-packages.js';
48
55
  import {
49
56
  DIGEST_UNAPPROVED,
57
+ IDENTITY_CHANGED,
50
58
  SETTINGS_SECRET_TO_PLAIN,
51
59
  digestUnapprovedReason,
52
60
  installRunnerModule,
@@ -82,6 +90,8 @@ import { getConfig } from './config.js';
82
90
  import { recordAudit, recordAuditBestEffort } from './audit.js';
83
91
  import { notifyAdminsBestEffort } from './notifications.js';
84
92
  import { refreshPluginSettingsBestEffort, settingsBlockOf } from './plugin-settings.js';
93
+ import { refreshPluginIdentity } from './identity-sync.js';
94
+ import { refreshUserSurfaceVhosts } from './user-surface.js';
85
95
 
86
96
  /** Replaces the OIDC client sync (tests: there is no Authelia to write to). */
87
97
  let oidcSyncOverride = null;
@@ -163,6 +173,16 @@ export async function uninstallPluginWithHosting(
163
173
  }
164
174
  await op.step('remove the package', () => uninstallPlugin(name, logger));
165
175
  setPluginCapabilities(await getPluginCapabilities());
176
+ // With purge nothing of the plugin is kept — not who held its roles.
177
+ if (purge) {
178
+ const { revokePluginRoleGrants } = await import('./entitlements.js');
179
+ const revoked = await revokePluginRoleGrants(plugin, { actor, logger });
180
+ if (!revoked.ok) {
181
+ op.warn(
182
+ `The plugin's role grants were not revoked (${revoked.error}) — revoke them in Gatekeeper`,
183
+ );
184
+ }
185
+ }
166
186
  await recordAuditBestEffort(
167
187
  { actor, action: 'plugin.uninstalled', plugin: name, details: { operation: op.id } },
168
188
  logger,
@@ -261,6 +281,32 @@ export function settingsApprovalRequired(diff) {
261
281
  ];
262
282
  }
263
283
 
284
+ /**
285
+ * Why a version waits for an administrator because of how it changes the
286
+ * plugin's identity block (`identity.changed`): it adds or removes a role —
287
+ * entitlements name roles, and a new role is a new power over its users —
288
+ * or widens the attributes it receives about them. Narrowing the
289
+ * attributes and editing labels only change the digest. Auto-approve never
290
+ * covers it.
291
+ *
292
+ * @param {(import('@lamalibre/lamaste').IdentityDiff & { before: object | null, after: object | null }) | null} identity
293
+ * @returns {Array<{ code: string, reason: string }>}
294
+ */
295
+ function identityApprovalRequired(identity) {
296
+ if (!identity || !identityChangeNeedsApproval(identity)) return [];
297
+ const changes = [];
298
+ if (identity.rolesAdded.length > 0) {
299
+ changes.push(`adds roles (${identity.rolesAdded.join(', ')})`);
300
+ }
301
+ if (identity.rolesRemoved.length > 0) {
302
+ changes.push(`removes roles (${identity.rolesRemoved.join(', ')})`);
303
+ }
304
+ if (identity.attributesAdded.length > 0) {
305
+ changes.push(`receives more about its users (${identity.attributesAdded.join(', ')})`);
306
+ }
307
+ return [{ code: IDENTITY_CHANGED, reason: `it ${changes.join(' and ')}` }];
308
+ }
309
+
264
310
  /**
265
311
  * Why a staged version waits for an administrator's approval (`[{ code,
266
312
  * reason }]`; empty: it needs none and runs at once) — what the approval of
@@ -268,21 +314,30 @@ export function settingsApprovalRequired(diff) {
268
314
  * in for an administrator on the first reason alone:
269
315
  *
270
316
  * - `digest.unapproved` — what it brings (relay hooks, the agent half
271
- * Lamaste installs) is not what was approved;
272
- * - `settings.secret-to-plain` — {@link settingsApprovalRequired}.
317
+ * Lamaste installs, its identity block) is not what was approved;
318
+ * - `settings.secret-to-plain` — {@link settingsApprovalRequired};
319
+ * - `identity.changed` — {@link identityApprovalRequired}.
273
320
  *
274
- * @param {{ approvalRequired: boolean, digest: string | null, relaySet: object | null, agentHalf: object | null }} staged
321
+ * @param {{ approvalRequired: boolean, digest: string | null, relaySet: object | null, agentHalf: object | null, identity: object | null }} staged
275
322
  * @param {{ approvedDigest: string | null } | null} state
276
323
  * @param {import('@lamalibre/lamaste').SettingsSchemaDiff | null} settings
324
+ * @param {(import('@lamalibre/lamaste').IdentityDiff & { before: object | null, after: object | null }) | null} identity
277
325
  * @returns {Array<{ code: string, reason: string }>}
278
326
  */
279
- function approvalRequiredFor(staged, state, settings) {
327
+ function approvalRequiredFor(staged, state, settings, identity) {
280
328
  const digestApproved = !staged.approvalRequired || state?.approvedDigest === staged.digest;
281
329
  return [
282
330
  ...(digestApproved
283
331
  ? []
284
- : [digestUnapprovedReason({ relay: staged.relaySet, agentHalf: staged.agentHalf })]),
332
+ : [
333
+ digestUnapprovedReason({
334
+ relay: staged.relaySet,
335
+ agentHalf: staged.agentHalf,
336
+ identity: staged.identity,
337
+ }),
338
+ ]),
285
339
  ...settingsApprovalRequired(settings),
340
+ ...identityApprovalRequired(identity),
286
341
  ];
287
342
  }
288
343
 
@@ -401,6 +456,7 @@ async function stageVersion(plugin, requestedVersion, record) {
401
456
  agent: manifest.agent,
402
457
  agentHalfPin,
403
458
  settings: manifest.settings,
459
+ identity: identityOf(manifest),
404
460
  };
405
461
  const approval = installedApproval(entry);
406
462
  return {
@@ -412,6 +468,8 @@ async function stageVersion(plugin, requestedVersion, record) {
412
468
  entrySha256,
413
469
  agentHalfPin,
414
470
  agentHalf: approval.agentHalf,
471
+ // The staged identity block, normalized (null: it declares none).
472
+ identity: approval.identity,
415
473
  // The relay hook set's own digest — what the runner holds.
416
474
  relayDigest: approval.relay?.digest ?? null,
417
475
  approvalRequired: approval.required,
@@ -439,6 +497,23 @@ function settingsPreview(plugin, manifest) {
439
497
  return settingsSchemaDiff(installed, staged);
440
498
  }
441
499
 
500
+ /**
501
+ * How a staged version changes the identity block, for the update's
502
+ * preview: both blocks, normalized (null: that version declares none), and
503
+ * core's `identityDiff` — roles added and removed, attributes added and
504
+ * removed, roles whose label or description changed. Null when neither
505
+ * version declares one.
506
+ *
507
+ * @param {object} plugin the installed registry entry
508
+ * @param {object} manifest the staged version's manifest
509
+ */
510
+ function identityPreview(plugin, manifest) {
511
+ const before = plugin.identity ?? null;
512
+ const after = identityOf(manifest) ?? null;
513
+ if (!before && !after) return null;
514
+ return { before, after, ...identityDiff(before, after) };
515
+ }
516
+
442
517
  /**
443
518
  * Update plugin `name` to `requestedVersion` (an exact version or a
444
519
  * dist-tag; default `latest`). Returns `{ upToDate }`, `{ pendingUpdate }`
@@ -489,7 +564,8 @@ export async function updatePlugin(
489
564
 
490
565
  const state = await readHookState(name);
491
566
  const settings = settingsPreview(plugin, staged.manifest);
492
- const approvalRequired = approvalRequiredFor(staged, state, settings);
567
+ const identity = identityPreview(plugin, staged.manifest);
568
+ const approvalRequired = approvalRequiredFor(staged, state, settings, identity);
493
569
  let approvedBy = null;
494
570
  let autoApproved = false;
495
571
  if (approvedUpdate !== null) {
@@ -499,7 +575,7 @@ export async function updatePlugin(
499
575
  // Auto-approve stands in for an administrator on a digest that changed,
500
576
  // when the agent half changes nothing that runs as root on the agent —
501
577
  // and on nothing else: every other reason (a secret setting turned
502
- // plain) withholds it.
578
+ // plain, a role added or removed, an attribute widened) withholds it.
503
579
  const digestChanged = approvalRequired.some((reason) => reason.code === DIGEST_UNAPPROVED);
504
580
  const withheldReasons = state?.autoApprove
505
581
  ? [
@@ -514,6 +590,7 @@ export async function updatePlugin(
514
590
  plugin,
515
591
  staged,
516
592
  settings,
593
+ identity,
517
594
  approvalRequired,
518
595
  withheldReasons,
519
596
  actor,
@@ -592,6 +669,7 @@ async function awaitApproval({
592
669
  plugin,
593
670
  staged,
594
671
  settings,
672
+ identity,
595
673
  approvalRequired,
596
674
  withheldReasons,
597
675
  actor,
@@ -609,6 +687,7 @@ async function awaitApproval({
609
687
  ? agentHalfPreview(derivePluginRoute(plugin.name), staged.agentHalf)
610
688
  : null,
611
689
  settings,
690
+ identity,
612
691
  approvalRequired,
613
692
  ...(withheld.length > 0 ? { autoApproveWithheld: withheld } : {}),
614
693
  };
@@ -654,7 +733,10 @@ async function awaitApproval({
654
733
  },
655
734
  logger,
656
735
  );
657
- return { pendingUpdate: pendingUpdateView(pendingUpdate) };
736
+ const { withIdentityGrantImpact } = await import('./entitlements.js');
737
+ return {
738
+ pendingUpdate: await withIdentityGrantImpact(pendingUpdateView(pendingUpdate), plugin.route),
739
+ };
658
740
  }
659
741
 
660
742
  async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
@@ -704,6 +786,7 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
704
786
  hooks: installed.manifest.hooks,
705
787
  hooksEntrySha256: installed.hooksEntrySha256,
706
788
  agent: installed.manifest.agent,
789
+ identity: identityOf(installed.manifest),
707
790
  };
708
791
  let approval;
709
792
  try {
@@ -760,6 +843,14 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
760
843
  // The agent half may have dropped values the new version retyped or no
761
844
  // longer knows (and keeps that after a revert): the relay re-reads them.
762
845
  if (hosted) await refreshPluginSettingsBestEffort(name, logger);
846
+ // The roles and attributes may have changed with the version (or back,
847
+ // after a revert); the agent half of the installed version must answer
848
+ // protocol 1 again before its users reach it.
849
+ await refreshPluginIdentity(name, logger);
850
+ // So may whether it declares identity at all: the hosts its user surface
851
+ // is bound to serve it from the version that does — a plugin's
852
+ // switch-over to the relay's user surface is this update.
853
+ await refreshUserSurfaceVhosts(plugin.route, logger);
763
854
 
764
855
  // Hooks the new version no longer has: the old runner goes once the new
765
856
  // version is in place.