@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
@@ -29,6 +29,7 @@ import {
29
29
  } from '@lamalibre/lamaste';
30
30
  import { readTarballFiles } from '@lamalibre/lamaste/server';
31
31
  import { httpError } from './app-error.js';
32
+ import { withRegistryUserconfig } from './registry-credential.js';
32
33
 
33
34
  const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
34
35
 
@@ -88,9 +89,11 @@ export async function installedHookEntrySha256(packageName, manifest) {
88
89
  }
89
90
 
90
91
  /**
91
- * Fetch `<package>@<version>` from the registry STATE_DIR is configured
92
- * for (`npm pack`, as the panel's user) and read its manifest and, when
93
- * the manifest names one, its relay hook module — in memory only.
92
+ * Fetch `<package>@<version>` from the registry the panel installs from
93
+ * (`npm pack` through {@link withRegistryUserconfig}: the configured
94
+ * registry credential, else the machine's npm configuration) and read its
95
+ * manifest and, when the manifest names one, its relay hook module — in
96
+ * memory only.
94
97
  *
95
98
  * @param {string} packageName bare `@lamalibre/<name>`
96
99
  * @param {string} version an exact version or a dist-tag (`latest`)
@@ -103,22 +106,24 @@ export async function readRegistryPackage(packageName, version) {
103
106
  try {
104
107
  let packed;
105
108
  try {
106
- const { stdout } = await execa(
107
- 'npm',
108
- [
109
- 'pack',
110
- `${packageName}@${version}`,
111
- '--json',
112
- '--ignore-scripts',
113
- '--pack-destination',
114
- dir,
115
- ],
116
- { cwd: STATE_DIR, timeout: 120000 },
109
+ const { stdout } = await withRegistryUserconfig((env) =>
110
+ execa(
111
+ 'npm',
112
+ [
113
+ 'pack',
114
+ `${packageName}@${version}`,
115
+ '--json',
116
+ '--ignore-scripts',
117
+ '--pack-destination',
118
+ dir,
119
+ ],
120
+ { cwd: STATE_DIR, timeout: 120000, env },
121
+ ),
117
122
  );
118
123
  packed = JSON.parse(stdout);
119
124
  } catch (err) {
120
125
  throw httpError(
121
- `Could not fetch ${packageName}@${version} from the registry: ${(err.stderr || err.message || '').split('\n')[0]}`,
126
+ `Could not fetch ${packageName}@${version} from the registry: ${npmFailureLine(err)}`,
122
127
  502,
123
128
  );
124
129
  }
@@ -171,9 +176,9 @@ function npmFailureLine(err) {
171
176
 
172
177
  /**
173
178
  * The npm Subresource Integrity of `<package>@<version>`'s tarball, as the
174
- * registry STATE_DIR is configured for publishes it — `npm view … dist.integrity`
175
- * as the panel's user, so the registry and credential are the ones plugin
176
- * installs use. Only a sha512 SRI is accepted: it is what the approval
179
+ * registry the panel installs from publishes it — `npm view … dist.integrity`
180
+ * through {@link withRegistryUserconfig}, so the registry and credential
181
+ * are the ones plugin installs use. Only a sha512 SRI is accepted: it is what the approval
177
182
  * binds and what the agent checks the tarball it fetches against.
178
183
  *
179
184
  * @param {string} packageName bare `@lamalibre/<name>`
@@ -186,10 +191,13 @@ export async function readRegistryIntegrity(packageName, version) {
186
191
  const spec = `${packageName}@${version}`;
187
192
  let stdout;
188
193
  try {
189
- ({ stdout } = await execa('npm', ['view', spec, 'dist.integrity', '--json'], {
190
- cwd: STATE_DIR,
191
- timeout: 60000,
192
- }));
194
+ ({ stdout } = await withRegistryUserconfig((env) =>
195
+ execa('npm', ['view', spec, 'dist.integrity', '--json'], {
196
+ cwd: STATE_DIR,
197
+ timeout: 60000,
198
+ env,
199
+ }),
200
+ ));
193
201
  } catch (err) {
194
202
  if (/\bE404\b/.test(`${err.stderr ?? ''}\n${err.stdout ?? ''}`)) {
195
203
  throw httpError(
@@ -19,12 +19,15 @@ 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';
25
26
  import { listRegisteredSubScopeNames } from './tickets.js';
26
27
  import { getStateDb } from './state-db.js';
27
28
  import { revokePluginCapabilitiesFromAgents } from './mtls.js';
29
+ import { withRegistryUserconfig } from './registry-credential.js';
30
+ import { RegistryCredentialError } from '@lamalibre/lamaste/server';
28
31
  import {
29
32
  bareLamalibrePackageName,
30
33
  installedHookEntrySha256,
@@ -69,10 +72,12 @@ async function getStmts() {
69
72
  INSERT INTO plugins
70
73
  (name, route, display_name, package_name, version, description, capabilities,
71
74
  packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
72
- agent_half_pin, settings, status, installed_at, enabled_at)
73
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
75
+ agent_half_pin, settings, identity, status, installed_at, enabled_at)
76
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
74
77
  `),
75
78
  deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
79
+ // The plugin's audience and the state of its identity feed
80
+ // (lib/audience.js, lib/identity-sync.js) go with its row.
76
81
  // The relay's cache of the plugin's settings (lib/plugin-settings.js)
77
82
  // goes with its row, and follows its settings schema.
78
83
  deleteSettingsByName: db.prepare('DELETE FROM plugin_settings WHERE plugin = ?'),
@@ -135,6 +140,7 @@ function rowToPlugin(row) {
135
140
  if (row.hooks_entry_sha256 != null) entry.hooksEntrySha256 = row.hooks_entry_sha256;
136
141
  if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
137
142
  if (row.settings != null) entry.settings = JSON.parse(row.settings);
143
+ if (row.identity != null) entry.identity = JSON.parse(row.identity);
138
144
  if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
139
145
  return entry;
140
146
  }
@@ -147,6 +153,29 @@ function nullableJson(value) {
147
153
  return value === undefined || value === null ? null : JSON.stringify(value);
148
154
  }
149
155
 
156
+ /**
157
+ * A manifest's `identity` block as the registry stores it — normalized
158
+ * (core `normalizeIdentityBlock`: roles by id, attributes sorted, the
159
+ * default applied) — or undefined when it declares none. The stored block
160
+ * enters the approval digest.
161
+ *
162
+ * @param {{ identity?: import('@lamalibre/lamaste').IdentityBlockInput }} manifest
163
+ * @returns {import('@lamalibre/lamaste').NormalizedIdentityBlock | undefined}
164
+ */
165
+ export function identityOf(manifest) {
166
+ return manifest.identity ? normalizeIdentityBlock(manifest.identity) : undefined;
167
+ }
168
+
169
+ /** Recompute the plugin audiences (lib/audience.js) beside other work — never throws. */
170
+ async function recomputeAudiencesInBackground(logger) {
171
+ try {
172
+ const { recomputeAudiences } = await import('./audience.js');
173
+ void recomputeAudiences({ logger });
174
+ } catch (err) {
175
+ logger?.warn({ err: err.message }, 'Could not recompute the plugin audiences');
176
+ }
177
+ }
178
+
150
179
  // --- Plugin registry persistence ---
151
180
 
152
181
  /**
@@ -194,6 +223,7 @@ export async function writePlugins(data) {
194
223
  nullableString(p.hooksEntrySha256),
195
224
  nullableJson(p.agentHalfPin),
196
225
  nullableJson(p.settings),
226
+ nullableJson(p.identity),
197
227
  p.status,
198
228
  p.installedAt,
199
229
  nullableString(p.enabledAt),
@@ -326,6 +356,17 @@ export async function manifestPolicyError(manifest) {
326
356
  return null;
327
357
  }
328
358
 
359
+ /**
360
+ * What a failed install of `spec` is reported as. A registry that refused
361
+ * the panel's credential says so (the registry host and the status, from
362
+ * {@link withRegistryUserconfig}); anything else is npm's failure, whose
363
+ * output is in the log.
364
+ */
365
+ function installFailure(spec, err) {
366
+ const detail = err instanceof RegistryCredentialError ? `: ${err.message}` : '';
367
+ return `Failed to install "${spec}"${detail}`;
368
+ }
369
+
329
370
  export function installPlugin(packageName, logger) {
330
371
  return withPluginLock(async () => {
331
372
  // Validate package scope
@@ -358,19 +399,25 @@ export function installPlugin(packageName, logger) {
358
399
  }
359
400
  }
360
401
 
361
- // Install the npm package
402
+ // Install the npm package — from the configured registry, as the
403
+ // panel's own identity, when a registry credential is set.
362
404
  logger.info({ packageName: installSpec }, 'Installing plugin package');
363
405
  try {
364
- await execa('npm', ['install', '--ignore-scripts', installSpec], {
365
- cwd: STATE_DIR,
366
- timeout: 120000,
367
- });
406
+ await withRegistryUserconfig(
407
+ (env) =>
408
+ execa('npm', ['install', '--ignore-scripts', installSpec], {
409
+ cwd: STATE_DIR,
410
+ timeout: 120000,
411
+ env,
412
+ }),
413
+ { log: logger },
414
+ );
368
415
  } catch (err) {
369
416
  logger.error(
370
417
  { packageName: installSpec, stderr: err.stderr, err: err.message },
371
418
  'npm install failed',
372
419
  );
373
- throw Object.assign(new Error(`Failed to install "${installSpec}"`), { statusCode: 500 });
420
+ throw Object.assign(new Error(installFailure(installSpec, err)), { statusCode: 500 });
374
421
  }
375
422
 
376
423
  // Read the plugin manifest from the installed package
@@ -460,6 +507,7 @@ export function installPlugin(packageName, logger) {
460
507
  hooksEntrySha256,
461
508
  agentHalfPin,
462
509
  settings: manifest.settings,
510
+ identity: identityOf(manifest),
463
511
  status: 'disabled',
464
512
  installedAt: new Date().toISOString(),
465
513
  };
@@ -487,6 +535,7 @@ export function installPlugin(packageName, logger) {
487
535
  nullableString(entry.hooksEntrySha256),
488
536
  nullableJson(entry.agentHalfPin),
489
537
  nullableJson(entry.settings),
538
+ nullableJson(entry.identity),
490
539
  entry.status,
491
540
  entry.installedAt,
492
541
  null,
@@ -499,6 +548,13 @@ export function installPlugin(packageName, logger) {
499
548
 
500
549
  logger.info({ name: manifest.name, packageName }, 'Plugin installed');
501
550
  const oidcClients = await syncOidcClientsBestEffort(logger);
551
+ // Users already entitled to its route (grants that outlived an earlier
552
+ // installation) make up its audience from the start.
553
+ if (entry.identity) await recomputeAudiencesInBackground(logger);
554
+ // A new installation is served on no host (its version is not approved
555
+ // yet): a vhost still carrying the route from before goes in line.
556
+ const { refreshUserSurfaceVhosts } = await import('./user-surface.js');
557
+ await refreshUserSurfaceVhosts(entry.route, logger);
502
558
  return { ...entry, oidcClients };
503
559
  });
504
560
  }
@@ -572,6 +628,17 @@ export function uninstallPlugin(name, logger) {
572
628
  throw err;
573
629
  }
574
630
 
631
+ // Its identity ledger — everyone its agent halves were sent, and the
632
+ // erasures it owes — stays while an agent keeps its data (an uninstall
633
+ // without purge); with no data left anywhere it goes. A later install
634
+ // replays whatever is left of it at its first hosting.
635
+ try {
636
+ const { dropIdentityLedgerIfNoData } = await import('./audience.js');
637
+ await dropIdentityLedgerIfNoData(name, logger);
638
+ } catch (err) {
639
+ logger.warn({ name, err: err.message }, 'Could not settle the identity ledger on uninstall');
640
+ }
641
+
575
642
  // Sweep this plugin's capabilities off every agent so re-installing the
576
643
  // plugin later does NOT silently re-grant. Failure here is non-fatal
577
644
  // (the plugin is already gone), but logged loudly. A plugin without a
@@ -600,6 +667,11 @@ export function uninstallPlugin(name, logger) {
600
667
  );
601
668
  }
602
669
 
670
+ // Its user surface is unbound from every host, so a later install
671
+ // starts unbound (a plugin sharing another's route never held one).
672
+ const { releasePluginUserSurface } = await import('./user-surface.js');
673
+ await releasePluginUserSurface(plugin.route, { plugin: name, logger });
674
+
603
675
  logger.info({ name }, 'Plugin uninstalled');
604
676
  await syncOidcClientsBestEffort(logger);
605
677
  return { ok: true, name };
@@ -612,7 +684,7 @@ const updateRowSql = `
612
684
  UPDATE plugins SET
613
685
  display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
614
686
  config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
615
- agent_half_pin = ?, settings = ?
687
+ agent_half_pin = ?, settings = ?, identity = ?
616
688
  WHERE name = ?`;
617
689
 
618
690
  /**
@@ -624,7 +696,8 @@ const updateRowSql = `
624
696
  * (`prunedSettingsCache`) — so no read pairs the new schema with values
625
697
  * stored for the old one: a value whose field became a secret never shows
626
698
  * beside it, and a secret whose field became a plain value is no longer
627
- * reported set.
699
+ * reported set. The identity block is stored normalized; the update's
700
+ * caller recomputes the audiences once the version is in place.
628
701
  */
629
702
  export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalfPin) {
630
703
  const s = await getStmts();
@@ -649,6 +722,7 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
649
722
  nullableString(hooksEntrySha256),
650
723
  nullableJson(agentHalfPin),
651
724
  nullableJson(manifest.settings),
725
+ nullableJson(identityOf(manifest)),
652
726
  name,
653
727
  );
654
728
  const pruned = prunedSettingsCache(
@@ -688,13 +762,18 @@ export function replacePluginPackage(name, packageName, version, logger) {
688
762
  assertSafeLamalibrePackage(spec);
689
763
  logger?.info({ name, spec }, 'Installing plugin package version');
690
764
  try {
691
- await execa('npm', ['install', '--ignore-scripts', spec], {
692
- cwd: STATE_DIR,
693
- timeout: 120000,
694
- });
765
+ await withRegistryUserconfig(
766
+ (env) =>
767
+ execa('npm', ['install', '--ignore-scripts', spec], {
768
+ cwd: STATE_DIR,
769
+ timeout: 120000,
770
+ env,
771
+ }),
772
+ { log: logger },
773
+ );
695
774
  } catch (err) {
696
775
  logger?.error({ spec, stderr: err.stderr, err: err.message }, 'npm install failed');
697
- throw Object.assign(new Error(`Failed to install "${spec}"`), { statusCode: 502 });
776
+ throw Object.assign(new Error(installFailure(spec, err)), { statusCode: 502 });
698
777
  }
699
778
  const require = createRequire(`${STATE_DIR}/`);
700
779
  const manifestPath = require.resolve(`${bare}/lamaste-plugin.json`);
@@ -12,6 +12,13 @@
12
12
  * install within the hour and limited to ten new tokens per agent per
13
13
  * hour.
14
14
  *
15
+ * The panel's own npm calls — installing a plugin, staging an update with
16
+ * `npm pack`, pinning an agent half with `npm view` — go through
17
+ * {@link withRegistryUserconfig}: with a credential configured, npm is
18
+ * routed to its registry for its scope and authenticated with the service
19
+ * token through a throw-away userconfig; without one, npm runs as it is
20
+ * configured on the machine (the public registry by default).
21
+ *
15
22
  * Also the one piece of policy that belongs here: when a user loses a
16
23
  * `feria-*` group or is deleted, their registry tokens are revoked (best
17
24
  * effort, never blocks the user operation).
@@ -26,6 +33,7 @@ import {
26
33
  revokeRegistryTokensForOwner,
27
34
  lostFeriaGroups,
28
35
  readOidcClientSecret,
36
+ withRegistryUserconfig as withRegistryUserconfigCore,
29
37
  RegistryServiceTokenCache,
30
38
  AgentTokenBroker,
31
39
  RegistryCredentialError,
@@ -88,7 +96,7 @@ async function oidcDeps(config) {
88
96
 
89
97
  /**
90
98
  * Verify a candidate credential end to end (for `oidc`: Authelia token
91
- * endpoint → Feria login → whoami with the admin scope). The
99
+ * endpoint → Feria login → whoami with the read and admin scopes). The
92
100
  * `lamaste-server` client must already be in Authelia — the route syncs it
93
101
  * first.
94
102
  * @param {{ registryUrl: string, auth: { kind: 'token', token: string } | { kind: 'oidc', clientId: string } }} candidate
@@ -102,6 +110,35 @@ async function serviceToken(config) {
102
110
  return (await serviceTokens.resolve(config, await oidcDeps(config))).token;
103
111
  }
104
112
 
113
+ /**
114
+ * Run `fn(env)` — one npm call of the panel's own — against the configured
115
+ * registry: with a credential, `env` names an isolated userconfig (0600,
116
+ * removed afterwards) routing the credential's scope to its registry with
117
+ * the panel's service token; a `401` refreshes the token once and retries.
118
+ * Without a credential `fn({})` runs npm as the machine configures it.
119
+ * A credential file that cannot be read fails the call rather than
120
+ * silently installing from the public registry.
121
+ *
122
+ * @template T
123
+ * @param {(env: Record<string, string>) => Promise<T>} fn
124
+ * @param {{ log?: import('pino').Logger | undefined }} [options]
125
+ * @returns {Promise<T>}
126
+ */
127
+ export async function withRegistryUserconfig(fn, options = {}) {
128
+ const config = await readRegistryCredentialConfig();
129
+ if (!config) return fn({});
130
+ const deps = await oidcDeps(config);
131
+ return withRegistryUserconfigCore(
132
+ config,
133
+ async ({ refresh }) => {
134
+ if (refresh) serviceTokens.invalidate();
135
+ return (await serviceTokens.resolve(config, deps)).token;
136
+ },
137
+ fn,
138
+ { log: options.log },
139
+ );
140
+ }
141
+
105
142
  /**
106
143
  * The calling agent's registry token: the cached one when it is still good
107
144
  * for an install, otherwise a freshly brokered one-hour token — unless the
@@ -121,14 +158,17 @@ export async function acquireAgentToken(agentLabel) {
121
158
 
122
159
  /**
123
160
  * Revoke a user's registry tokens after their `feria-*` groups changed or
124
- * they were deleted. Returns the number revoked, or `null` when nothing
125
- * 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.
126
165
  *
127
166
  * @param {string} username
128
167
  * @param {{ before: string[], after: string[] } | { deleted: true }} change
129
- * @param {import('pino').Logger} log
168
+ * @param {import('pino').Logger | undefined} log
169
+ * @param {{ userId?: string | null }} [who]
130
170
  */
131
- export async function revokeUserRegistryTokens(username, change, log) {
171
+ export async function revokeUserRegistryTokens(username, change, log, { userId = null } = {}) {
132
172
  const config = await readRegistryCredentialConfig();
133
173
  if (!config) return null;
134
174
 
@@ -140,6 +180,6 @@ export async function revokeUserRegistryTokens(username, change, log) {
140
180
  const revoked = await revokeRegistryTokensForOwner(config, owner, {
141
181
  serviceToken: await serviceToken(config),
142
182
  });
143
- log.info({ username, lost, revoked }, 'Revoked registry tokens after group change');
183
+ log?.info({ userId, lost, revoked }, 'Revoked a user’s registry tokens');
144
184
  return revoked;
145
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
 
@@ -138,3 +139,20 @@ export async function writeInvitations(invitations) {
138
139
 
139
140
  await rename(tmpPath, filePath);
140
141
  }
142
+
143
+ /** Serializes every read-modify-write of invitations.json in this process. */
144
+ const invitationsMutex = new PromiseChainMutex();
145
+
146
+ /**
147
+ * Run `fn` holding the invitations lock: a read of invitations.json, the
148
+ * decisions made on it and the write that follows must not interleave with
149
+ * another — creating, revoking, accepting an invitation, and purging a
150
+ * removed user's.
151
+ *
152
+ * @template T
153
+ * @param {() => Promise<T>} fn
154
+ * @returns {Promise<T>}
155
+ */
156
+ export function withInvitationsLock(fn) {
157
+ return invitationsMutex.run(fn);
158
+ }
@@ -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
  }