@lamalibre/create-lamaste 4.4.1 → 4.6.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 (170) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +234 -18
  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/02-guides/static-sites.md +4 -0
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +3 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +229 -24
  32. package/vendor/docs/03-architecture/overview.md +13 -17
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +3 -2
  35. package/vendor/docs/03-architecture/state-management.md +4 -8
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +378 -34
  38. package/vendor/docs/04-api-reference/app-host.md +3 -3
  39. package/vendor/docs/04-api-reference/gatekeeper.md +78 -5
  40. package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
  41. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  42. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  43. package/vendor/docs/04-api-reference/sites.md +4 -0
  44. package/vendor/docs/04-api-reference/tunnels.md +87 -47
  45. package/vendor/docs/04-api-reference/users.md +241 -45
  46. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  47. package/vendor/docs/05-operations/monitoring.md +39 -0
  48. package/vendor/docs/05-operations/uninstalling.md +2 -1
  49. package/vendor/docs/05-operations/upgrades.md +24 -6
  50. package/vendor/docs/06-reference/config-files.md +100 -202
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  54. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  55. package/vendor/docs/gatekeeper-design.md +10 -8
  56. package/vendor/docs/identity-directory-design.md +48 -14
  57. package/vendor/docs/package.json +1 -1
  58. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  59. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  60. package/vendor/gatekeeper/dist/index.d.ts +4 -3
  61. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/index.js +4 -2
  63. package/vendor/gatekeeper/dist/index.js.map +1 -1
  64. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  65. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  67. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  69. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  70. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  71. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  72. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  73. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  74. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  75. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  76. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  77. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  79. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  81. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  82. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  83. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  84. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +10 -0
  85. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  86. package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
  87. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  88. package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
  89. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  90. package/vendor/gatekeeper/dist/server/index.js +49 -56
  91. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  92. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  93. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  94. package/vendor/gatekeeper/dist/server/routes/authz.js +332 -104
  95. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  96. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  98. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  100. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  101. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  102. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  103. package/vendor/gatekeeper/package.json +2 -2
  104. package/vendor/server/bin/lamaste-server.js +15 -1
  105. package/vendor/server/package.json +2 -2
  106. package/vendor/server/src/commands/plugin-identity.js +335 -0
  107. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  108. package/vendor/server/src/commands/plugins.js +156 -11
  109. package/vendor/server/src/commands/registry-credential.js +2 -1
  110. package/vendor/server/src/commands/sites.js +15 -1
  111. package/vendor/server/src/commands/tunnels.js +102 -2
  112. package/vendor/server/src/commands/uninstall.js +3 -1
  113. package/vendor/server/src/commands/users.js +358 -0
  114. package/vendor/server/src/plugin-approval.js +186 -5
  115. package/vendor/server/src/plugin-identity.js +382 -0
  116. package/vendor/server/src/plugin-web-surface.js +307 -0
  117. package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
  118. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
  119. package/vendor/server-ui/dist/index.html +2 -2
  120. package/vendor/serverd/package.json +2 -2
  121. package/vendor/serverd/src/index.js +34 -5
  122. package/vendor/serverd/src/lib/audience.js +576 -0
  123. package/vendor/serverd/src/lib/authelia.js +78 -7
  124. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  125. package/vendor/serverd/src/lib/chisel.js +10 -2
  126. package/vendor/serverd/src/lib/config.js +68 -43
  127. package/vendor/serverd/src/lib/directory.js +1113 -0
  128. package/vendor/serverd/src/lib/entitlements.js +517 -0
  129. package/vendor/serverd/src/lib/gatekeeper-client.js +226 -10
  130. package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
  131. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  132. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  133. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  134. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  135. package/vendor/serverd/src/lib/mtls.js +6 -2
  136. package/vendor/serverd/src/lib/nginx.js +546 -48
  137. package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
  138. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  139. package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
  140. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  141. package/vendor/serverd/src/lib/plugins.js +95 -7
  142. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  143. package/vendor/serverd/src/lib/request-logging.js +19 -0
  144. package/vendor/serverd/src/lib/retained-data.js +50 -4
  145. package/vendor/serverd/src/lib/state.js +36 -0
  146. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  147. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  148. package/vendor/serverd/src/lib/user-caller.js +67 -0
  149. package/vendor/serverd/src/lib/user-surface.js +754 -0
  150. package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
  151. package/vendor/serverd/src/lib/web-surface.js +766 -0
  152. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  153. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  154. package/vendor/serverd/src/routes/invite.js +53 -12
  155. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  156. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  157. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  158. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  159. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  160. package/vendor/serverd/src/routes/management/plugins.js +2 -0
  161. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  162. package/vendor/serverd/src/routes/management/sites.js +143 -65
  163. package/vendor/serverd/src/routes/management/tunnels.js +109 -22
  164. package/vendor/serverd/src/routes/management/users.js +290 -208
  165. package/vendor/serverd/src/routes/management.js +6 -0
  166. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  167. package/vendor/serverd/src/routes/user-access.js +17 -4
  168. package/vendor/serverd/src/routes/user-surface.js +69 -0
  169. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  170. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
@@ -19,6 +19,7 @@ import {
19
19
  RESERVED_NAV_LABELS,
20
20
  derivePluginRoute,
21
21
  hasAgentHalf,
22
+ normalizeIdentityBlock,
22
23
  pluginHostCapability,
23
24
  validateManifest as coreValidateManifest,
24
25
  } from '@lamalibre/lamaste';
@@ -30,6 +31,7 @@ import { RegistryCredentialError } from '@lamalibre/lamaste/server';
30
31
  import {
31
32
  bareLamalibrePackageName,
32
33
  installedHookEntrySha256,
34
+ installedWebTreeSha256,
33
35
  pinAgentHalf,
34
36
  } from './plugin-packages.js';
35
37
 
@@ -71,10 +73,13 @@ async function getStmts() {
71
73
  INSERT INTO plugins
72
74
  (name, route, display_name, package_name, version, description, capabilities,
73
75
  packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
74
- agent_half_pin, settings, status, installed_at, enabled_at)
75
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
76
+ agent_half_pin, settings, identity, user_surface, web_tree_sha256, status,
77
+ installed_at, enabled_at)
78
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
76
79
  `),
77
80
  deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
81
+ // The plugin's audience and the state of its identity feed
82
+ // (lib/audience.js, lib/identity-sync.js) go with its row.
78
83
  // The relay's cache of the plugin's settings (lib/plugin-settings.js)
79
84
  // goes with its row, and follows its settings schema.
80
85
  deleteSettingsByName: db.prepare('DELETE FROM plugin_settings WHERE plugin = ?'),
@@ -137,6 +142,9 @@ function rowToPlugin(row) {
137
142
  if (row.hooks_entry_sha256 != null) entry.hooksEntrySha256 = row.hooks_entry_sha256;
138
143
  if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
139
144
  if (row.settings != null) entry.settings = JSON.parse(row.settings);
145
+ if (row.identity != null) entry.identity = JSON.parse(row.identity);
146
+ if (row.user_surface != null) entry.userSurface = JSON.parse(row.user_surface);
147
+ if (row.web_tree_sha256 != null) entry.webTreeSha256 = row.web_tree_sha256;
140
148
  if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
141
149
  return entry;
142
150
  }
@@ -149,6 +157,29 @@ function nullableJson(value) {
149
157
  return value === undefined || value === null ? null : JSON.stringify(value);
150
158
  }
151
159
 
160
+ /**
161
+ * A manifest's `identity` block as the registry stores it — normalized
162
+ * (core `normalizeIdentityBlock`: roles by id, attributes sorted, the
163
+ * default applied) — or undefined when it declares none. The stored block
164
+ * enters the approval digest.
165
+ *
166
+ * @param {{ identity?: import('@lamalibre/lamaste').IdentityBlockInput }} manifest
167
+ * @returns {import('@lamalibre/lamaste').NormalizedIdentityBlock | undefined}
168
+ */
169
+ export function identityOf(manifest) {
170
+ return manifest.identity ? normalizeIdentityBlock(manifest.identity) : undefined;
171
+ }
172
+
173
+ /** Recompute the plugin audiences (lib/audience.js) beside other work — never throws. */
174
+ async function recomputeAudiencesInBackground(logger) {
175
+ try {
176
+ const { recomputeAudiences } = await import('./audience.js');
177
+ void recomputeAudiences({ logger });
178
+ } catch (err) {
179
+ logger?.warn({ err: err.message }, 'Could not recompute the plugin audiences');
180
+ }
181
+ }
182
+
152
183
  // --- Plugin registry persistence ---
153
184
 
154
185
  /**
@@ -196,6 +227,9 @@ export async function writePlugins(data) {
196
227
  nullableString(p.hooksEntrySha256),
197
228
  nullableJson(p.agentHalfPin),
198
229
  nullableJson(p.settings),
230
+ nullableJson(p.identity),
231
+ nullableJson(p.userSurface),
232
+ nullableString(p.webTreeSha256),
199
233
  p.status,
200
234
  p.installedAt,
201
235
  nullableString(p.enabledAt),
@@ -429,6 +463,17 @@ export function installPlugin(packageName, logger) {
429
463
  throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
430
464
  }
431
465
 
466
+ // A web surface: its tree must be in the package and keep every rule of
467
+ // a web tree; its digest goes into the approval digest, and the relay
468
+ // serves only a copy that matches it.
469
+ let webTreeSha256 = null;
470
+ try {
471
+ webTreeSha256 = await installedWebTreeSha256(packageName, manifest);
472
+ } catch (err) {
473
+ await discardPackage();
474
+ throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
475
+ }
476
+
432
477
  // An agent half Lamaste installs: its tarball's integrity is pinned now,
433
478
  // from the registry this install used — the approval covers it and the
434
479
  // agent installs only a tarball that matches it.
@@ -479,6 +524,9 @@ export function installPlugin(packageName, logger) {
479
524
  hooksEntrySha256,
480
525
  agentHalfPin,
481
526
  settings: manifest.settings,
527
+ identity: identityOf(manifest),
528
+ userSurface: manifest.userSurface,
529
+ webTreeSha256,
482
530
  status: 'disabled',
483
531
  installedAt: new Date().toISOString(),
484
532
  };
@@ -506,6 +554,9 @@ export function installPlugin(packageName, logger) {
506
554
  nullableString(entry.hooksEntrySha256),
507
555
  nullableJson(entry.agentHalfPin),
508
556
  nullableJson(entry.settings),
557
+ nullableJson(entry.identity),
558
+ nullableJson(entry.userSurface),
559
+ nullableString(entry.webTreeSha256),
509
560
  entry.status,
510
561
  entry.installedAt,
511
562
  null,
@@ -518,6 +569,13 @@ export function installPlugin(packageName, logger) {
518
569
 
519
570
  logger.info({ name: manifest.name, packageName }, 'Plugin installed');
520
571
  const oidcClients = await syncOidcClientsBestEffort(logger);
572
+ // Users already entitled to its route (grants that outlived an earlier
573
+ // installation) make up its audience from the start.
574
+ if (entry.identity) await recomputeAudiencesInBackground(logger);
575
+ // A new installation is served on no host (its version is not approved
576
+ // yet): a vhost still carrying the route from before goes in line.
577
+ const { refreshUserSurfaceVhosts } = await import('./user-surface.js');
578
+ await refreshUserSurfaceVhosts(entry.route, logger);
521
579
  return { ...entry, oidcClients };
522
580
  });
523
581
  }
@@ -591,6 +649,17 @@ export function uninstallPlugin(name, logger) {
591
649
  throw err;
592
650
  }
593
651
 
652
+ // Its identity ledger — everyone its agent halves were sent, and the
653
+ // erasures it owes — stays while an agent keeps its data (an uninstall
654
+ // without purge); with no data left anywhere it goes. A later install
655
+ // replays whatever is left of it at its first hosting.
656
+ try {
657
+ const { dropIdentityLedgerIfNoData } = await import('./audience.js');
658
+ await dropIdentityLedgerIfNoData(name, logger);
659
+ } catch (err) {
660
+ logger.warn({ name, err: err.message }, 'Could not settle the identity ledger on uninstall');
661
+ }
662
+
594
663
  // Sweep this plugin's capabilities off every agent so re-installing the
595
664
  // plugin later does NOT silently re-grant. Failure here is non-fatal
596
665
  // (the plugin is already gone), but logged loudly. A plugin without a
@@ -619,6 +688,11 @@ export function uninstallPlugin(name, logger) {
619
688
  );
620
689
  }
621
690
 
691
+ // Its user surface is unbound from every host, so a later install
692
+ // starts unbound (a plugin sharing another's route never held one).
693
+ const { releasePluginUserSurface } = await import('./user-surface.js');
694
+ await releasePluginUserSurface(plugin.route, { plugin: name, logger });
695
+
622
696
  logger.info({ name }, 'Plugin uninstalled');
623
697
  await syncOidcClientsBestEffort(logger);
624
698
  return { ok: true, name };
@@ -631,7 +705,7 @@ const updateRowSql = `
631
705
  UPDATE plugins SET
632
706
  display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
633
707
  config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
634
- agent_half_pin = ?, settings = ?
708
+ agent_half_pin = ?, settings = ?, identity = ?, user_surface = ?, web_tree_sha256 = ?
635
709
  WHERE name = ?`;
636
710
 
637
711
  /**
@@ -643,9 +717,18 @@ const updateRowSql = `
643
717
  * (`prunedSettingsCache`) — so no read pairs the new schema with values
644
718
  * stored for the old one: a value whose field became a secret never shows
645
719
  * beside it, and a secret whose field became a plain value is no longer
646
- * reported set.
720
+ * reported set. The identity block is stored normalized; the update's
721
+ * caller recomputes the audiences once the version is in place.
722
+ * `webTreeSha256` is the digest of the version's web tree, read from the
723
+ * installed package (null when it declares no web surface).
647
724
  */
648
- export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalfPin) {
725
+ export async function writePluginRow(
726
+ name,
727
+ manifest,
728
+ hooksEntrySha256,
729
+ agentHalfPin,
730
+ webTreeSha256 = null,
731
+ ) {
649
732
  const s = await getStmts();
650
733
  const { prunedSettingsCache } = await import('./plugin-settings.js');
651
734
  s.begin.run();
@@ -668,6 +751,9 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
668
751
  nullableString(hooksEntrySha256),
669
752
  nullableJson(agentHalfPin),
670
753
  nullableJson(manifest.settings),
754
+ nullableJson(identityOf(manifest)),
755
+ nullableJson(manifest.userSurface),
756
+ nullableString(webTreeSha256),
671
757
  name,
672
758
  );
673
759
  const pruned = prunedSettingsCache(
@@ -695,7 +781,8 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
695
781
 
696
782
  /**
697
783
  * Install `<packageName>@<version>` over the installed copy and read it
698
- * back: the validated manifest and its relay entry module's SHA-256. The
784
+ * back: the validated manifest, its relay entry module's SHA-256 and its
785
+ * web tree's digest. The
699
786
  * row is not touched — the caller compares the result with what was
700
787
  * reviewed and then writes it. Refuses a package whose manifest names
701
788
  * another plugin.
@@ -735,7 +822,8 @@ export function replacePluginPackage(name, packageName, version, logger) {
735
822
  );
736
823
  }
737
824
  const hooksEntrySha256 = await installedHookEntrySha256(packageName, manifest);
738
- return { manifest, hooksEntrySha256 };
825
+ const webTreeSha256 = await installedWebTreeSha256(packageName, manifest);
826
+ return { manifest, hooksEntrySha256, webTreeSha256 };
739
827
  });
740
828
  }
741
829
 
@@ -96,7 +96,7 @@ async function oidcDeps(config) {
96
96
 
97
97
  /**
98
98
  * Verify a candidate credential end to end (for `oidc`: Authelia token
99
- * endpoint → Feria login → whoami with the admin scope). The
99
+ * endpoint → Feria login → whoami with the read and admin scopes). The
100
100
  * `lamaste-server` client must already be in Authelia — the route syncs it
101
101
  * first.
102
102
  * @param {{ registryUrl: string, auth: { kind: 'token', token: string } | { kind: 'oidc', clientId: string } }} candidate
@@ -158,14 +158,17 @@ export async function acquireAgentToken(agentLabel) {
158
158
 
159
159
  /**
160
160
  * Revoke a user's registry tokens after their `feria-*` groups changed or
161
- * they were deleted. Returns the number revoked, or `null` when nothing
162
- * needed doing (no credential configured, or no feria group lost).
161
+ * they were offboarded (`{ deleted: true }`: disabled or removed). Returns
162
+ * the number revoked, or `null` when nothing needed doing (no credential
163
+ * configured, or no feria group lost). The registry knows the user by
164
+ * username (`user:<username>`); the log line names them by `userId` alone.
163
165
  *
164
166
  * @param {string} username
165
167
  * @param {{ before: string[], after: string[] } | { deleted: true }} change
166
- * @param {import('pino').Logger} log
168
+ * @param {import('pino').Logger | undefined} log
169
+ * @param {{ userId?: string | null }} [who]
167
170
  */
168
- export async function revokeUserRegistryTokens(username, change, log) {
171
+ export async function revokeUserRegistryTokens(username, change, log, { userId = null } = {}) {
169
172
  const config = await readRegistryCredentialConfig();
170
173
  if (!config) return null;
171
174
 
@@ -177,6 +180,6 @@ export async function revokeUserRegistryTokens(username, change, log) {
177
180
  const revoked = await revokeRegistryTokensForOwner(config, owner, {
178
181
  serviceToken: await serviceToken(config),
179
182
  });
180
- log.info({ username, lost, revoked }, 'Revoked registry tokens after group change');
183
+ log?.info({ userId, lost, revoked }, 'Revoked a user’s registry tokens');
181
184
  return revoked;
182
185
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * How the panel logs its requests: one metadata line per request (core
3
+ * `logRequestMetadata`) instead of Fastify's own lines, which carry every
4
+ * URL with its query and the client address. The line has the method, the
5
+ * route pattern (never the URL a username or a query may be in), the
6
+ * status, the duration and the caller's certificate role; the client
7
+ * address only for certificate holders — administrators and agents, not
8
+ * people: never for a user of a plugin's user surface, the desktop's
9
+ * user-access calls or the public routes.
10
+ */
11
+
12
+ /** Callers whose address a request line may carry: certificate holders, not people. */
13
+ const CERT_ROLES_LOGGED_WITH_IP = new Set(['admin', 'agent', 'plugin-agent']);
14
+
15
+ /** The options the panel passes to `logRequestMetadata`. */
16
+ export const panelRequestLogOptions = Object.freeze({
17
+ fields: (request) => ({ certRole: request.certRole ?? null }),
18
+ logIp: (request) => CERT_ROLES_LOGGED_WITH_IP.has(request.certRole),
19
+ });
@@ -38,18 +38,64 @@ function rowToRecord(row) {
38
38
  * agent package `pkg` of plugin `plugin`. A record that exists already keeps
39
39
  * its `since` — the data has been there since then.
40
40
  *
41
+ * With it goes how far the agent half's identity projection — part of that
42
+ * data — had applied the plugin's feed, as the half last answered (the
43
+ * plugin's feed state, read now, before the hosting's is forgotten): the
44
+ * erasures of the plugin's ledger after that cursor are pending on this
45
+ * agent (`pendingErasures` in `lib/identity-sync.js`). When the relay knows
46
+ * no position, a record that exists keeps the one it had — the projection
47
+ * did not move without a feed.
48
+ *
41
49
  * @param {{ agentLabel: string, route: string, plugin: string, package: string, operation?: string | null }} record
42
50
  */
43
51
  export async function recordRetainedData({ agentLabel, route, plugin, package: pkg, operation }) {
44
52
  const db = await getStateDb();
53
+ const feed = db
54
+ .prepare('SELECT directory_id, cursor FROM plugin_identity_sync WHERE plugin = ?')
55
+ .get(plugin);
56
+ const known = feed && feed.directory_id !== null && feed.cursor !== null;
45
57
  db.prepare(
46
- `INSERT INTO retained_agent_data (agent_label, route, plugin, package, since, operation)
47
- VALUES (?, ?, ?, ?, ?, ?)
58
+ `INSERT INTO retained_agent_data
59
+ (agent_label, route, plugin, package, since, operation, identity_directory_id, identity_cursor)
60
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
48
61
  ON CONFLICT(agent_label, route) DO UPDATE SET
49
62
  plugin = excluded.plugin,
50
63
  package = excluded.package,
51
- operation = excluded.operation`,
52
- ).run(agentLabel, route, plugin, pkg, new Date().toISOString(), operation ?? null);
64
+ operation = excluded.operation,
65
+ identity_directory_id = CASE WHEN excluded.identity_cursor IS NULL
66
+ THEN identity_directory_id ELSE excluded.identity_directory_id END,
67
+ identity_cursor = COALESCE(excluded.identity_cursor, identity_cursor)`,
68
+ ).run(
69
+ agentLabel,
70
+ route,
71
+ plugin,
72
+ pkg,
73
+ new Date().toISOString(),
74
+ operation ?? null,
75
+ known ? feed.directory_id : null,
76
+ known ? Number(feed.cursor) : null,
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Where the identity projection in each record's data stands, for plugin
82
+ * `plugin`: `[{ agentLabel, directoryId, cursor }]` (both null when unknown).
83
+ *
84
+ * @param {string} plugin
85
+ */
86
+ export async function retainedIdentityPositions(plugin) {
87
+ const db = await getStateDb();
88
+ return db
89
+ .prepare(
90
+ `SELECT agent_label, identity_directory_id, identity_cursor FROM retained_agent_data
91
+ WHERE plugin = ? ORDER BY since, agent_label`,
92
+ )
93
+ .all(plugin)
94
+ .map((row) => ({
95
+ agentLabel: row.agent_label,
96
+ directoryId: row.identity_directory_id,
97
+ cursor: row.identity_cursor === null ? null : Number(row.identity_cursor),
98
+ }));
53
99
  }
54
100
 
55
101
  /** Drop the record of the data agent `agentLabel` keeps for route `route` (purged, or taken back). */
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile, rename, open } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { PromiseChainMutex } from '@lamalibre/lamaste';
3
4
 
4
5
  const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
5
6
 
@@ -94,6 +95,24 @@ export async function writeSites(sites) {
94
95
  await rename(tmpPath, filePath);
95
96
  }
96
97
 
98
+ /** Serializes every read-modify-write of sites.json in this process. */
99
+ const sitesMutex = new PromiseChainMutex();
100
+
101
+ /**
102
+ * Run `fn` holding the sites lock: a read of sites.json, the decisions made
103
+ * on it and the write that follows must not interleave with another. A
104
+ * workflow that gives a site a hostname (creating it, adding an alias,
105
+ * taking a tunnel's host) takes core's tunnel lock first, then this one —
106
+ * always in that order — so a site and a tunnel never take one name at once.
107
+ *
108
+ * @template T
109
+ * @param {() => Promise<T>} fn
110
+ * @returns {Promise<T>}
111
+ */
112
+ export function withSitesLock(fn) {
113
+ return sitesMutex.run(fn);
114
+ }
115
+
97
116
  function invitationsPath() {
98
117
  return path.join(STATE_DIR, 'invitations.json');
99
118
  }
@@ -138,3 +157,20 @@ export async function writeInvitations(invitations) {
138
157
 
139
158
  await rename(tmpPath, filePath);
140
159
  }
160
+
161
+ /** Serializes every read-modify-write of invitations.json in this process. */
162
+ const invitationsMutex = new PromiseChainMutex();
163
+
164
+ /**
165
+ * Run `fn` holding the invitations lock: a read of invitations.json, the
166
+ * decisions made on it and the write that follows must not interleave with
167
+ * another — creating, revoking, accepting an invitation, and purging a
168
+ * removed user's.
169
+ *
170
+ * @template T
171
+ * @param {() => Promise<T>} fn
172
+ * @returns {Promise<T>}
173
+ */
174
+ export function withInvitationsLock(fn) {
175
+ return invitationsMutex.run(fn);
176
+ }
@@ -12,7 +12,8 @@
12
12
  * Whenever the plugin comes up — after a `down`, or first seen up — the
13
13
  * relay also re-reads its settings from the agent half: they may have
14
14
  * changed while the relay could not see it (a restore, a retyped field
15
- * dropped by a new version).
15
+ * dropped by a new version) — and, for a plugin that declares identity,
16
+ * sends its agent half the audience changes it missed while it was down.
16
17
  *
17
18
  * Calls for one plugin are serialised and run beside the monitor, which
18
19
  * does not wait for them.
@@ -27,6 +28,7 @@ import {
27
28
  import { runRelayHook } from './relay-hooks.js';
28
29
  import { rememberTunnelFallback } from './request-hooks.js';
29
30
  import { refreshPluginSettingsBestEffort } from './plugin-settings.js';
31
+ import { syncPluginIdentity } from './identity-sync.js';
30
32
 
31
33
  const chains = new Map();
32
34
 
@@ -44,6 +46,7 @@ export function onHealthTransition(plugin, before, after, logger) {
44
46
  .then(async () => {
45
47
  if (after.state === 'up' && before?.state !== 'up') {
46
48
  await refreshPluginSettingsBestEffort(plugin, logger);
49
+ await syncPluginIdentity(plugin, { logger });
47
50
  }
48
51
  });
49
52
  chains.set(plugin, next);
@@ -1,4 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
+ import { isIdentityUuid } from '@lamalibre/lamaste';
2
3
 
3
4
  const SESSION_MAX_AGE = 12 * 60 * 60; // 12 hours in seconds
4
5
  const INACTIVITY_TIMEOUT = 2 * 60 * 60; // 2 hours in seconds
@@ -21,19 +22,23 @@ function signPayload(payloadB64, secretHex) {
21
22
  }
22
23
 
23
24
  /**
24
- * Create a signed user-access session token.
25
+ * Create a signed user-access session token, bound to the user's directory
26
+ * id as well as their username: a later user given the same username never
27
+ * holds this session (the middleware checks the username's current holder).
25
28
  *
26
29
  * @param {string} sessionSecret - Hex-encoded session secret
27
- * @param {string} username - Authelia username to bind the session to
30
+ * @param {{ username: string, userId: string }} user - the Authelia username and its directory id
28
31
  * @returns {{ value: string, maxAge: number, expiresAt: string }}
29
32
  */
30
- export function createUserSession(sessionSecret, username) {
33
+ export function createUserSession(sessionSecret, { username, userId }) {
34
+ if (!isIdentityUuid(userId)) throw new Error('A user session names a directory user id');
31
35
  const now = Math.floor(Date.now() / 1000);
32
36
  const payload = {
33
37
  iat: now,
34
38
  exp: now + SESSION_MAX_AGE,
35
39
  lastActivity: now,
36
40
  username,
41
+ userId,
37
42
  type: 'user-access',
38
43
  };
39
44
  const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
@@ -52,10 +57,12 @@ export function createUserSession(sessionSecret, username) {
52
57
  * @param {string} tokenValue - The token (payload.sig)
53
58
  * @param {string} sessionSecret - Hex-encoded session secret
54
59
  * @param {{ userEpochs?: Record<string, number> }} [options]
55
- * userEpochs maps username → Unix-second epoch. If the session's `iat`
56
- * is older than the user's epoch, the session is rejected as revoked.
57
- * This is how Authelia user deletions invalidate live sessions whose
58
- * HMAC signature is otherwise still valid.
60
+ * userEpochs maps a directory user id → Unix-second epoch. If the
61
+ * session's `iat` is older than its user's epoch, the session is rejected
62
+ * as revoked. This is how offboarding (disable, delete) invalidates live
63
+ * sessions whose HMAC signature is otherwise still valid. A session
64
+ * without a user id (issued before 4.5) is rejected: it could not be
65
+ * told from a later user's.
59
66
  * @returns {{ valid: boolean, payload?: object, reason?: string }}
60
67
  */
61
68
  export function validateUserSession(tokenValue, sessionSecret, options = {}) {
@@ -96,6 +103,10 @@ export function validateUserSession(tokenValue, sessionSecret, options = {}) {
96
103
  return { valid: false, reason: 'missing_username' };
97
104
  }
98
105
 
106
+ if (!isIdentityUuid(payload.userId)) {
107
+ return { valid: false, reason: 'missing_user_id' };
108
+ }
109
+
99
110
  const now = Math.floor(Date.now() / 1000);
100
111
 
101
112
  if (payload.exp && now > payload.exp) {
@@ -106,12 +117,11 @@ export function validateUserSession(tokenValue, sessionSecret, options = {}) {
106
117
  return { valid: false, reason: 'inactive' };
107
118
  }
108
119
 
109
- // Per-user invalidation epoch (set when a user is deleted from Authelia).
110
- // Reject sessions issued before that epoch — they belong to a user who no
111
- // longer exists, even if a same-named user is later re-created.
120
+ // Per-user invalidation epoch (set when a user is offboarded). Reject
121
+ // sessions issued before it.
112
122
  const userEpochs = options.userEpochs;
113
123
  if (userEpochs && typeof userEpochs === 'object') {
114
- const epoch = userEpochs[payload.username];
124
+ const epoch = userEpochs[payload.userId];
115
125
  if (typeof epoch === 'number' && typeof payload.iat === 'number' && payload.iat < epoch) {
116
126
  return { valid: false, reason: 'revoked' };
117
127
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Who a person on a plugin's user surface is to the plugin — what the
3
+ * panel's user-surface context (core `userSurfacePlugin`,
4
+ * `resolveUserCaller`) signs into the caller assertion it forwards to the
5
+ * agent half's `/app/*`: `{ type: 'user', userId, username, roles }`.
6
+ *
7
+ * Gatekeeper attests only the username; everything else comes from here:
8
+ * the directory's user holding that username now, and their roles from the
9
+ * plugin's audience as last computed (`lib/audience.js`) — restricted to
10
+ * the roles the installed version declares. A disabled user is refused at
11
+ * once (the directory is written before Authelia restarts); a user the
12
+ * audience does not hold as an active member is not entitled, and neither
13
+ * is one whose username's earlier holder still has a principal in
14
+ * Gatekeeper to clear.
15
+ */
16
+
17
+ import { isLamasteUsername } from '@lamalibre/lamaste';
18
+ import { readAudienceRow } from './audience.js';
19
+ import { findDirectoryUser, principalCleanupPending } from './directory.js';
20
+ import { declaredRoleIds } from './entitlements.js';
21
+ import { identityReadiness } from './identity-sync.js';
22
+ import { findPluginByRoute } from './plugins.js';
23
+
24
+ const NOT_ENTITLED = 'This user may not use this plugin';
25
+
26
+ /**
27
+ * The user caller for `username` on the plugin at `route`, or why there is
28
+ * none: 404 when no installed plugin holds the route or it declares no
29
+ * identity; 503 when its identity is not ready (not approved, not hosted,
30
+ * or its agent half has not answered protocol 1 for the installed
31
+ * version); 403 when the user is disabled, removed, unknown or not
32
+ * entitled.
33
+ *
34
+ * @param {string} route
35
+ * @param {string} username the Authelia username Gatekeeper attested
36
+ * @returns {Promise<{ ok: true, caller: { type: 'user', userId: string, username: string, roles: string[] } } | { ok: false, status: 403 | 404 | 503, error: string }>}
37
+ */
38
+ export async function resolveUserCaller(route, username) {
39
+ const plugin = await findPluginByRoute(route);
40
+ if (!plugin || !plugin.identity) return { ok: false, status: 404, error: 'Not found' };
41
+ const readiness = await identityReadiness(route);
42
+ if (!readiness.ready) {
43
+ return { ok: false, status: 503, error: 'The plugin is not ready for its users yet' };
44
+ }
45
+ if (!isLamasteUsername(username)) return { ok: false, status: 403, error: NOT_ENTITLED };
46
+ const user = await findDirectoryUser(username);
47
+ if (!user) return { ok: false, status: 403, error: NOT_ENTITLED };
48
+ // An earlier holder's grants may still be in Gatekeeper under this name.
49
+ if (await principalCleanupPending(username)) {
50
+ return { ok: false, status: 403, error: NOT_ENTITLED };
51
+ }
52
+ if (user.status !== 'active') return { ok: false, status: 403, error: 'This user is disabled' };
53
+ const row = await readAudienceRow(plugin.name, user.userId);
54
+ const member = row?.change?.kind === 'upsert' ? row.change.member : null;
55
+ if (!member || member.status !== 'active' || member.userId !== user.userId) {
56
+ return { ok: false, status: 403, error: NOT_ENTITLED };
57
+ }
58
+ const declared = declaredRoleIds(plugin.identity);
59
+ const roles = (Array.isArray(member.roles) ? member.roles : []).filter((role) =>
60
+ declared.has(role),
61
+ );
62
+ if (roles.length === 0) return { ok: false, status: 403, error: NOT_ENTITLED };
63
+ return {
64
+ ok: true,
65
+ caller: { type: 'user', userId: user.userId, username: user.username, roles },
66
+ };
67
+ }