@lamalibre/create-lamaste 4.4.1 → 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 (149) 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 +291 -22
  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 +14 -12
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  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 +82 -188
  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/plugins.js +59 -4
  125. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  126. package/vendor/serverd/src/lib/request-logging.js +19 -0
  127. package/vendor/serverd/src/lib/retained-data.js +50 -4
  128. package/vendor/serverd/src/lib/state.js +18 -0
  129. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  130. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  131. package/vendor/serverd/src/lib/user-caller.js +67 -0
  132. package/vendor/serverd/src/lib/user-surface.js +733 -0
  133. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  134. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  135. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  136. package/vendor/serverd/src/routes/invite.js +53 -12
  137. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  138. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  139. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  140. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  141. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  142. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  143. package/vendor/serverd/src/routes/management/users.js +290 -208
  144. package/vendor/serverd/src/routes/management.js +4 -0
  145. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  146. package/vendor/serverd/src/routes/user-access.js +17 -4
  147. package/vendor/serverd/src/routes/user-surface.js +69 -0
  148. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  149. 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';
@@ -71,10 +72,12 @@ async function getStmts() {
71
72
  INSERT INTO plugins
72
73
  (name, route, display_name, package_name, version, description, capabilities,
73
74
  packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
74
- agent_half_pin, settings, status, installed_at, enabled_at)
75
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
75
+ agent_half_pin, settings, identity, status, installed_at, enabled_at)
76
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
76
77
  `),
77
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.
78
81
  // The relay's cache of the plugin's settings (lib/plugin-settings.js)
79
82
  // goes with its row, and follows its settings schema.
80
83
  deleteSettingsByName: db.prepare('DELETE FROM plugin_settings WHERE plugin = ?'),
@@ -137,6 +140,7 @@ function rowToPlugin(row) {
137
140
  if (row.hooks_entry_sha256 != null) entry.hooksEntrySha256 = row.hooks_entry_sha256;
138
141
  if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
139
142
  if (row.settings != null) entry.settings = JSON.parse(row.settings);
143
+ if (row.identity != null) entry.identity = JSON.parse(row.identity);
140
144
  if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
141
145
  return entry;
142
146
  }
@@ -149,6 +153,29 @@ function nullableJson(value) {
149
153
  return value === undefined || value === null ? null : JSON.stringify(value);
150
154
  }
151
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
+
152
179
  // --- Plugin registry persistence ---
153
180
 
154
181
  /**
@@ -196,6 +223,7 @@ export async function writePlugins(data) {
196
223
  nullableString(p.hooksEntrySha256),
197
224
  nullableJson(p.agentHalfPin),
198
225
  nullableJson(p.settings),
226
+ nullableJson(p.identity),
199
227
  p.status,
200
228
  p.installedAt,
201
229
  nullableString(p.enabledAt),
@@ -479,6 +507,7 @@ export function installPlugin(packageName, logger) {
479
507
  hooksEntrySha256,
480
508
  agentHalfPin,
481
509
  settings: manifest.settings,
510
+ identity: identityOf(manifest),
482
511
  status: 'disabled',
483
512
  installedAt: new Date().toISOString(),
484
513
  };
@@ -506,6 +535,7 @@ export function installPlugin(packageName, logger) {
506
535
  nullableString(entry.hooksEntrySha256),
507
536
  nullableJson(entry.agentHalfPin),
508
537
  nullableJson(entry.settings),
538
+ nullableJson(entry.identity),
509
539
  entry.status,
510
540
  entry.installedAt,
511
541
  null,
@@ -518,6 +548,13 @@ export function installPlugin(packageName, logger) {
518
548
 
519
549
  logger.info({ name: manifest.name, packageName }, 'Plugin installed');
520
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);
521
558
  return { ...entry, oidcClients };
522
559
  });
523
560
  }
@@ -591,6 +628,17 @@ export function uninstallPlugin(name, logger) {
591
628
  throw err;
592
629
  }
593
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
+
594
642
  // Sweep this plugin's capabilities off every agent so re-installing the
595
643
  // plugin later does NOT silently re-grant. Failure here is non-fatal
596
644
  // (the plugin is already gone), but logged loudly. A plugin without a
@@ -619,6 +667,11 @@ export function uninstallPlugin(name, logger) {
619
667
  );
620
668
  }
621
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
+
622
675
  logger.info({ name }, 'Plugin uninstalled');
623
676
  await syncOidcClientsBestEffort(logger);
624
677
  return { ok: true, name };
@@ -631,7 +684,7 @@ const updateRowSql = `
631
684
  UPDATE plugins SET
632
685
  display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
633
686
  config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
634
- agent_half_pin = ?, settings = ?
687
+ agent_half_pin = ?, settings = ?, identity = ?
635
688
  WHERE name = ?`;
636
689
 
637
690
  /**
@@ -643,7 +696,8 @@ const updateRowSql = `
643
696
  * (`prunedSettingsCache`) — so no read pairs the new schema with values
644
697
  * stored for the old one: a value whose field became a secret never shows
645
698
  * beside it, and a secret whose field became a plain value is no longer
646
- * 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.
647
701
  */
648
702
  export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalfPin) {
649
703
  const s = await getStmts();
@@ -668,6 +722,7 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
668
722
  nullableString(hooksEntrySha256),
669
723
  nullableJson(agentHalfPin),
670
724
  nullableJson(manifest.settings),
725
+ nullableJson(identityOf(manifest)),
671
726
  name,
672
727
  );
673
728
  const pruned = prunedSettingsCache(
@@ -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
 
@@ -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
  }
@@ -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
+ }