@lamalibre/create-lamaste 4.6.0 → 4.7.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/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +22 -3
  4. package/scripts/pin-release.js +98 -0
  5. package/src/index.js +8 -1
  6. package/src/lib/env.js +19 -0
  7. package/src/lib/service-config.js +7 -0
  8. package/src/tasks/redeploy.js +8 -0
  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 +6 -2
  13. package/vendor/docs/01-concepts/authorization.md +4 -0
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/security-model.md +28 -3
  18. package/vendor/docs/01-concepts/tickets.md +2 -2
  19. package/vendor/docs/01-concepts/tunneling.md +4 -4
  20. package/vendor/docs/02-guides/agent-setup.md +5 -5
  21. package/vendor/docs/02-guides/certificate-management.md +2 -2
  22. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  23. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  24. package/vendor/docs/02-guides/installation.md +3 -3
  25. package/vendor/docs/02-guides/managing-users.md +5 -11
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/second-domain.md +82 -0
  28. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  29. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  30. package/vendor/docs/03-architecture/installer.md +4 -4
  31. package/vendor/docs/03-architecture/management-flow.md +1 -1
  32. package/vendor/docs/03-architecture/nginx-configuration.md +15 -5
  33. package/vendor/docs/03-architecture/overview.md +5 -5
  34. package/vendor/docs/03-architecture/panel-client.md +6 -6
  35. package/vendor/docs/03-architecture/panel-server.md +2 -2
  36. package/vendor/docs/03-architecture/state-management.md +3 -3
  37. package/vendor/docs/03-architecture/system-overview.md +1 -1
  38. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +17 -13
  39. package/vendor/docs/04-api-reference/app-host.md +3 -3
  40. package/vendor/docs/04-api-reference/domains.md +192 -0
  41. package/vendor/docs/04-api-reference/gatekeeper.md +35 -7
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +9 -0
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  46. package/vendor/docs/04-api-reference/sites.md +5 -0
  47. package/vendor/docs/04-api-reference/tunnels.md +15 -10
  48. package/vendor/docs/04-api-reference/users.md +41 -29
  49. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  50. package/vendor/docs/05-operations/uninstalling.md +1 -1
  51. package/vendor/docs/05-operations/upgrades.md +6 -6
  52. package/vendor/docs/06-reference/config-files.md +1 -1
  53. package/vendor/docs/06-reference/glossary.md +3 -3
  54. package/vendor/docs/06-reference/installer-flags.md +13 -13
  55. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  56. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  57. package/vendor/docs/_index.json +105 -19
  58. package/vendor/docs/external-members-design.md +816 -0
  59. package/vendor/docs/gatekeeper-design.md +8 -8
  60. package/vendor/docs/identity-directory-design.md +3 -3
  61. package/vendor/docs/multi-domain-relay-design.md +836 -0
  62. package/vendor/docs/package.json +1 -1
  63. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  64. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  65. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  67. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  69. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  70. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  72. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  74. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  76. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  77. package/vendor/gatekeeper/dist/server/index.d.ts +5 -1
  78. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.js +28 -2
  80. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  81. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.js +225 -36
  83. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  85. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  87. package/vendor/gatekeeper/package.json +2 -2
  88. package/vendor/server/bin/lamaste-server.js +7 -0
  89. package/vendor/server/package.json +2 -2
  90. package/vendor/server/src/commands/domains.js +214 -0
  91. package/vendor/server/src/commands/plugin-identity.js +2 -16
  92. package/vendor/server/src/commands/plugins.js +112 -8
  93. package/vendor/server/src/commands/registry-credential.js +10 -3
  94. package/vendor/server/src/commands/sites.js +3 -0
  95. package/vendor/server/src/commands/status.js +19 -0
  96. package/vendor/server/src/commands/tunnels.js +4 -1
  97. package/vendor/server/src/commands/users.js +8 -19
  98. package/vendor/server/src/domains.js +92 -0
  99. package/vendor/server/src/fail.js +21 -0
  100. package/vendor/server/src/oidc-client-grants.js +63 -0
  101. package/vendor/server/src/panel-api.js +15 -0
  102. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  103. package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
  104. package/vendor/server-ui/dist/index.html +2 -2
  105. package/vendor/serverd/package.json +2 -2
  106. package/vendor/serverd/src/index.js +42 -16
  107. package/vendor/serverd/src/lib/app-error.js +24 -0
  108. package/vendor/serverd/src/lib/audience.js +12 -3
  109. package/vendor/serverd/src/lib/authelia.js +341 -35
  110. package/vendor/serverd/src/lib/directory.js +67 -52
  111. package/vendor/serverd/src/lib/domains.js +1792 -0
  112. package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
  113. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  114. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  115. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  116. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  117. package/vendor/serverd/src/lib/nginx.js +318 -140
  118. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  119. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  120. package/vendor/serverd/src/lib/plugin-lifecycle.js +1 -0
  121. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  122. package/vendor/serverd/src/lib/plugins.js +45 -5
  123. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  124. package/vendor/serverd/src/lib/services.js +9 -1
  125. package/vendor/serverd/src/lib/state.js +11 -0
  126. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  127. package/vendor/serverd/src/lib/user-surface.js +29 -4
  128. package/vendor/serverd/src/lib/users-lock.js +23 -0
  129. package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
  130. package/vendor/serverd/src/lib/web-surface.js +49 -32
  131. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  132. package/vendor/serverd/src/routes/invite.js +15 -5
  133. package/vendor/serverd/src/routes/management/domains.js +154 -0
  134. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  135. package/vendor/serverd/src/routes/management/identity.js +21 -7
  136. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  137. package/vendor/serverd/src/routes/management/plugins.js +4 -2
  138. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  139. package/vendor/serverd/src/routes/management/sites.js +50 -11
  140. package/vendor/serverd/src/routes/management/tunnels.js +29 -5
  141. package/vendor/serverd/src/routes/management/users.js +184 -64
  142. package/vendor/serverd/src/routes/management.js +2 -0
  143. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  144. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  145. package/vendor/serverd/src/routes/user-access.js +13 -1
  146. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  147. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  148. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  149. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +0 -84
@@ -19,6 +19,16 @@
19
19
  * `identity_providers.oidc` section is written; Authelia is restarted only
20
20
  * when the section actually changed.
21
21
  *
22
+ * Every client belongs to a domain — its plugin's (the panel's own: the
23
+ * registry credential's issuer domain): `{domain}` in its audience and
24
+ * redirect URIs is that domain, and a user-facing client has its own
25
+ * authorization policy (core `renderOidcClientPolicies`): that domain's
26
+ * members, and its external members granted the client (a Gatekeeper grant
27
+ * `resourceType: 'oidc-client'`, rendered by lib/domains.js into users.yml's
28
+ * owned groups). The provider is written under lib/domains.js
29
+ * `withDomainRendering` — the one writer of what members may do — with the
30
+ * domains read under it.
31
+ *
22
32
  * Best effort by design where a plugin lifecycle calls it: a failure here
23
33
  * must not fail the plugin operation — the caller logs it and the next
24
34
  * lifecycle change retries.
@@ -26,9 +36,11 @@
26
36
 
27
37
  import { readFile } from 'node:fs/promises';
28
38
  import { createRequire } from 'node:module';
29
- import { validateManifest } from '@lamalibre/lamaste';
39
+ import { PromiseChainMutex, validateInstalledManifest } from '@lamalibre/lamaste';
30
40
  import {
31
41
  LAMASTE_REGISTRY_OIDC_SCOPES,
42
+ LAMASTE_SERVER_OIDC_CLIENT_ID,
43
+ registryIssuerDomain,
32
44
  lamasteServerOidcClient,
33
45
  listOidcClientSecrets,
34
46
  reconcileOidcClientSecrets,
@@ -36,32 +48,43 @@ import {
36
48
  rotateOidcClientSecret,
37
49
  } from '@lamalibre/lamaste/server';
38
50
 
39
- import { getConfig } from './config.js';
40
51
  import { updateOidcProvider } from './authelia.js';
52
+ import { listGrantsOfType } from './gatekeeper-client.js';
53
+ import { OIDC_CLIENT_RESOURCE, isUserFacingOidcClient } from './owned-groups.js';
54
+ import { pluginDomainOr, primaryDomainName, withDomainRendering } from './domains.js';
41
55
  import { readPlugins } from './plugins.js';
42
56
  import { readRegistryCredentialConfig } from './registry-credential.js';
43
57
 
44
58
  const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
45
59
 
60
+ /** The domains a rendering read, as the OIDC provider's policies name them: name and group key. */
61
+ const keyed = (domains) => domains.map(({ name, groupKey }) => ({ name, groupKey }));
62
+
46
63
  /**
47
64
  * The OIDC clients every installed plugin declares, tagged with the plugin
48
- * name and whether the plugin is enabled. Manifests are re-read from the
49
- * installed packages — the plugin registry does not store them.
65
+ * name, its domain and whether the plugin is enabled. Manifests are re-read
66
+ * from the installed packages — the plugin registry does not store them.
50
67
  * @returns {Promise<Array<import('@lamalibre/lamaste/server').AutheliaOidcClient & { enabled: boolean }>>}
51
68
  */
52
69
  export async function collectPluginOidcClients() {
53
70
  const require = createRequire(`${STATE_DIR}/`);
71
+ const primary = await primaryDomainName();
54
72
  const clients = [];
55
73
  for (const plugin of (await readPlugins()).plugins) {
56
74
  let manifest;
57
75
  try {
58
76
  const manifestPath = require.resolve(`${plugin.packageName}/lamaste-plugin.json`);
59
- manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
77
+ manifest = validateInstalledManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
60
78
  } catch {
61
79
  continue; // an unreadable manifest contributes no clients
62
80
  }
63
81
  for (const client of manifest.oidcClients) {
64
- clients.push({ ...client, plugin: manifest.name, enabled: plugin.status === 'enabled' });
82
+ clients.push({
83
+ ...client,
84
+ plugin: manifest.name,
85
+ domain: pluginDomainOr(plugin, primary),
86
+ enabled: plugin.status === 'enabled',
87
+ });
65
88
  }
66
89
  }
67
90
  return clients;
@@ -69,31 +92,77 @@ export async function collectPluginOidcClients() {
69
92
 
70
93
  /**
71
94
  * The panel's own client, when the registry credential (stored, or the
72
- * candidate being verified) uses it.
73
- * @param {{ registryUrl: string } | null | undefined} registryAuth
95
+ * candidate being verified) uses it — of the credential's issuer domain.
96
+ * @param {{ registryUrl: string, issuerDomain?: string } | null | undefined} registryAuth
74
97
  */
75
- function lamasteOwnClient(registryAuth) {
98
+ async function lamasteOwnClient(registryAuth) {
76
99
  if (!registryAuth) return [];
77
- return [{ ...lamasteServerOidcClient(registryAuth.registryUrl), enabled: true }];
100
+ const domain = registryIssuerDomain(
101
+ { kind: 'oidc', clientId: LAMASTE_SERVER_OIDC_CLIENT_ID, ...registryAuth },
102
+ await primaryDomainName(),
103
+ );
104
+ return [{ ...lamasteServerOidcClient(registryAuth.registryUrl, domain), enabled: true }];
105
+ }
106
+
107
+ /**
108
+ * A registry credential being set (`PUT /api/registry-credential`): its
109
+ * `registryAuth` while the panel's client is synced, the chain verified and
110
+ * the credential stored. Every sync that names no override serves it — the
111
+ * identity tick's every-minute sync included, which otherwise reads the
112
+ * stored credential, removes the client the candidate needs and restarts
113
+ * Authelia under the verification's token request. Every sync reads it under
114
+ * the one writer ({@link syncOidcClients}), so none can write a plan made
115
+ * before it was set.
116
+ * @type {{ registryUrl: string, issuerDomain?: string } | undefined}
117
+ */
118
+ let registryCandidate;
119
+ const registryCandidateMutex = new PromiseChainMutex();
120
+
121
+ /**
122
+ * Run `fn` with `registryAuth` as the candidate registry credential
123
+ * (see {@link registryCandidate}); one candidate at a time. Without one
124
+ * (a pasted token), `fn` just runs.
125
+ * @template T
126
+ * @param {{ registryUrl: string, issuerDomain?: string } | undefined} registryAuth
127
+ * @param {() => Promise<T>} fn
128
+ * @returns {Promise<T>}
129
+ */
130
+ export function withRegistryCandidate(registryAuth, fn) {
131
+ if (registryAuth === undefined) return fn();
132
+ return registryCandidateMutex.run(async () => {
133
+ registryCandidate = registryAuth;
134
+ try {
135
+ return await fn();
136
+ } finally {
137
+ registryCandidate = undefined;
138
+ }
139
+ });
78
140
  }
79
141
 
80
142
  /**
81
143
  * Everything Authelia should know, from the stored state.
82
- * @param {{ registryAuth?: { registryUrl: string } | null }} [overrides]
144
+ * @param {{ registryAuth?: { registryUrl: string, issuerDomain?: string } | null }} [overrides]
83
145
  * `registryAuth` replaces the stored registry credential's say in the
84
- * matter: `{ registryUrl }` while a candidate oidc credential is being
85
- * verified, `null` to leave the panel's client out.
146
+ * matter: `{ registryUrl, issuerDomain }` while a candidate oidc
147
+ * credential is being verified, `null` to leave the panel's client out.
86
148
  */
87
149
  async function collectAllClients(overrides = {}) {
88
150
  let registryAuth;
89
151
  if (overrides.registryAuth !== undefined) {
90
152
  registryAuth = overrides.registryAuth;
153
+ } else if (registryCandidate !== undefined) {
154
+ registryAuth = registryCandidate;
91
155
  } else {
92
156
  const stored = await readRegistryCredentialConfig().catch(() => null);
93
157
  registryAuth =
94
- stored && stored.auth.kind === 'oidc' ? { registryUrl: stored.registryUrl } : null;
158
+ stored && stored.auth.kind === 'oidc'
159
+ ? {
160
+ registryUrl: stored.registryUrl,
161
+ ...(stored.auth.issuerDomain ? { issuerDomain: stored.auth.issuerDomain } : {}),
162
+ }
163
+ : null;
95
164
  }
96
- return [...(await collectPluginOidcClients()), ...lamasteOwnClient(registryAuth)];
165
+ return [...(await collectPluginOidcClients()), ...(await lamasteOwnClient(registryAuth))];
97
166
  }
98
167
 
99
168
  /**
@@ -103,7 +172,7 @@ async function collectAllClients(overrides = {}) {
103
172
  * enabled plugins served, and the panel's own scopes when its client is.
104
173
  * Pure with respect to Authelia — nothing is written.
105
174
  *
106
- * @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
175
+ * @param {{ registryAuth?: { registryUrl: string, issuerDomain?: string } | null }} [overrides] see collectAllClients
107
176
  * @returns {Promise<{
108
177
  * confidential: Array<{ id: string, plugin: string }>,
109
178
  * served: Array<import('@lamalibre/lamaste/server').AutheliaOidcClient>,
@@ -123,37 +192,41 @@ export async function planOidcClients(overrides = {}) {
123
192
  /**
124
193
  * Sync Authelia's OIDC clients and the secret store with the plugins and
125
194
  * the registry credential. Returns what was done, or `null` when the panel
126
- * has no domain yet (onboarding).
195
+ * has no domain yet (onboarding). Plan, secrets and write all happen under
196
+ * the one writer: a sync that waited for it plans from what is true then,
197
+ * never from before another sync's change (a registry credential being
198
+ * set, a plugin moved).
127
199
  *
128
200
  * @param {import('pino').Logger} logger
129
- * @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
130
201
  * @returns {Promise<{ changed: boolean, clients: string[], createdSecrets: Array<{ id: string, plugin: string, secret: string }> } | null>}
131
202
  */
132
- export async function syncOidcClients(logger, overrides = {}) {
133
- let domain = null;
134
- try {
135
- domain = getConfig().domain;
136
- } catch {
137
- domain = null;
138
- }
139
- if (!domain) {
203
+ export async function syncOidcClients(logger) {
204
+ if (!(await primaryDomainName())) {
140
205
  logger.debug('OIDC client sync skipped — no domain configured yet');
141
206
  return null;
142
207
  }
143
208
 
144
- const { confidential, served, scopes } = await planOidcClients(overrides);
145
- const reconciled = await reconcileOidcClientSecrets(STATE_DIR, confidential);
209
+ const { served, reconciled, result } = await withDomainRendering(
210
+ async (domains) => {
211
+ const plan = await planOidcClients();
212
+ const secrets = await reconcileOidcClientSecrets(STATE_DIR, plan.confidential);
213
+ return {
214
+ served: plan.served,
215
+ reconciled: secrets,
216
+ result: await updateOidcProvider(keyed(domains), plan.served, {
217
+ secretDigests: secrets.digests,
218
+ scopes: plan.scopes,
219
+ }),
220
+ };
221
+ },
222
+ { logger },
223
+ );
146
224
  if (reconciled.created.length > 0 || reconciled.removed.length > 0) {
147
225
  logger.info(
148
226
  { created: reconciled.created.map((c) => c.id), removed: reconciled.removed },
149
227
  'OIDC client secrets reconciled',
150
228
  );
151
229
  }
152
-
153
- const result = await updateOidcProvider(domain, served, {
154
- secretDigests: reconciled.digests,
155
- scopes,
156
- });
157
230
  if (result.changed) {
158
231
  logger.info({ clients: served.map((c) => c.id) }, 'Authelia OIDC provider updated');
159
232
  }
@@ -166,16 +239,37 @@ export async function syncOidcClients(logger, overrides = {}) {
166
239
 
167
240
  /**
168
241
  * Metadata of a plugin's OIDC clients: what the manifest declares, whether
169
- * it is served, and — for confidential clients — when the secret was
170
- * created and last rotated. Never the secret.
242
+ * it is served, whether it signs users in (`userFacing`) and — for those —
243
+ * the Gatekeeper grants on it (`grants`: who among the external members of
244
+ * its domain may sign in with it; members of the domain always may; null
245
+ * when Gatekeeper cannot be read), and — for confidential clients — when
246
+ * the secret was created and last rotated. Never the secret.
171
247
  * @param {string} pluginName
172
248
  */
173
249
  export async function listPluginOidcClients(pluginName) {
174
250
  const declared = (await collectPluginOidcClients()).filter((c) => c.plugin === pluginName);
175
251
  const secrets = new Map((await listOidcClientSecrets(STATE_DIR)).map((m) => [m.id, m]));
252
+ const grants = declared.some(isUserFacingOidcClient)
253
+ ? await listGrantsOfType(OIDC_CLIENT_RESOURCE)
254
+ : null;
176
255
  return declared.map((client) => {
177
256
  const secret = client.confidential ? (secrets.get(client.id) ?? null) : null;
178
257
  return {
258
+ userFacing: isUserFacingOidcClient(client),
259
+ ...(isUserFacingOidcClient(client)
260
+ ? {
261
+ grants: grants?.ok
262
+ ? grants.grants
263
+ .filter((g) => g.resourceId === client.id)
264
+ .map((g) => ({
265
+ grantId: g.grantId,
266
+ principalType: g.principalType,
267
+ principalId: g.principalId,
268
+ createdAt: g.createdAt,
269
+ }))
270
+ : null,
271
+ }
272
+ : {}),
179
273
  id: client.id,
180
274
  name: client.name ?? null,
181
275
  confidential: client.confidential,
@@ -216,13 +310,21 @@ export async function rotatePluginOidcClientSecret(pluginName, clientId, logger)
216
310
  // The store is reconciled first so a client that was never synced (an
217
311
  // earlier Authelia failure) gets its record now.
218
312
  await syncOidcClients(logger);
219
- const rotated = await rotateOidcClientSecret(STATE_DIR, clientId);
220
- const digests = await readOidcClientSecretDigests(STATE_DIR);
221
- if (client.enabled) {
222
- const domain = getConfig().domain;
223
- const { served, scopes } = await planOidcClients();
224
- await updateOidcProvider(domain, served, { secretDigests: digests, scopes });
225
- }
313
+ // Rotate, plan and write under the one writer (see syncOidcClients).
314
+ const rotated = await withDomainRendering(
315
+ async (domains) => {
316
+ const secret = await rotateOidcClientSecret(STATE_DIR, clientId);
317
+ if (client.enabled) {
318
+ const { served, scopes } = await planOidcClients();
319
+ await updateOidcProvider(keyed(domains), served, {
320
+ secretDigests: await readOidcClientSecretDigests(STATE_DIR),
321
+ scopes,
322
+ });
323
+ }
324
+ return secret;
325
+ },
326
+ { logger },
327
+ );
226
328
  logger.info({ plugin: pluginName, clientId }, 'OIDC client secret rotated');
227
329
  return {
228
330
  id: rotated.id,
@@ -22,6 +22,7 @@ import {
22
22
  normalizeIdentityBlock,
23
23
  pluginHostCapability,
24
24
  validateManifest as coreValidateManifest,
25
+ validateInstalledManifest,
25
26
  } from '@lamalibre/lamaste';
26
27
  import { listRegisteredSubScopeNames } from './tickets.js';
27
28
  import { getStateDb } from './state-db.js';
@@ -74,8 +75,8 @@ async function getStmts() {
74
75
  (name, route, display_name, package_name, version, description, capabilities,
75
76
  packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
76
77
  agent_half_pin, settings, identity, user_surface, web_tree_sha256, status,
77
- installed_at, enabled_at)
78
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
78
+ installed_at, enabled_at, domain)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
79
80
  `),
80
81
  deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
81
82
  // The plugin's audience and the state of its identity feed
@@ -146,6 +147,8 @@ function rowToPlugin(row) {
146
147
  if (row.user_surface != null) entry.userSurface = JSON.parse(row.user_surface);
147
148
  if (row.web_tree_sha256 != null) entry.webTreeSha256 = row.web_tree_sha256;
148
149
  if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
150
+ // The domain the plugin belongs to; null: the primary (lib/domains.js).
151
+ entry.domain = row.domain ?? null;
149
152
  return entry;
150
153
  }
151
154
 
@@ -233,6 +236,7 @@ export async function writePlugins(data) {
233
236
  p.status,
234
237
  p.installedAt,
235
238
  nullableString(p.enabledAt),
239
+ nullableString(p.domain),
236
240
  );
237
241
  }
238
242
  s.commit.run();
@@ -560,6 +564,8 @@ export function installPlugin(packageName, logger) {
560
564
  entry.status,
561
565
  entry.installedAt,
562
566
  null,
567
+ // A plugin installed now belongs to the primary domain (lib/plugin-domain.js moves it).
568
+ null,
563
569
  );
564
570
  s.commit.run();
565
571
  } catch (err) {
@@ -622,6 +628,13 @@ export function uninstallPlugin(name, logger) {
622
628
  const { removeRelayHooks } = await import('./plugin-hooks.js');
623
629
  await removeRelayHooks(name, logger);
624
630
 
631
+ // The OIDC clients it declares, read while its manifest is installed:
632
+ // their grants go with it below.
633
+ const { collectPluginOidcClients } = await import('./plugin-oidc.js');
634
+ const clientIds = (await collectPluginOidcClients().catch(() => []))
635
+ .filter((c) => c.plugin === name)
636
+ .map((c) => c.id);
637
+
625
638
  // Uninstall the npm package
626
639
  logger.info({ name, packageName: plugin.packageName }, 'Uninstalling plugin package');
627
640
  await execa('npm', ['uninstall', plugin.packageName], {
@@ -695,6 +708,24 @@ export function uninstallPlugin(name, logger) {
695
708
 
696
709
  logger.info({ name }, 'Plugin uninstalled');
697
710
  await syncOidcClientsBestEffort(logger);
711
+
712
+ // Grants on its OIDC clients end with it: client ids are chosen, not
713
+ // random, so a later install of the plugin (or another declaring the
714
+ // same id) would otherwise admit whoever was granted before.
715
+ const { revokeOidcClientGrants } = await import('./owned-groups.js');
716
+ const revoked = await revokeOidcClientGrants(clientIds);
717
+ if (!revoked.ok) {
718
+ logger.warn(
719
+ { name, err: revoked.error },
720
+ "Grants on the uninstalled plugin's OIDC clients stayed — revoke them in Gatekeeper",
721
+ );
722
+ } else if (revoked.revoked > 0) {
723
+ logger.info({ name, revoked: revoked.revoked }, 'Grants on its OIDC clients revoked');
724
+ const { syncDomainAccess } = await import('./domains.js');
725
+ await syncDomainAccess({ logger }).catch((err) => {
726
+ logger.warn({ err: err.message }, 'Rendering domain access after an uninstall failed');
727
+ });
728
+ }
698
729
  return { ok: true, name };
699
730
  });
700
731
  }
@@ -785,9 +816,17 @@ export async function writePluginRow(
785
816
  * web tree's digest. The
786
817
  * row is not touched — the caller compares the result with what was
787
818
  * reviewed and then writes it. Refuses a package whose manifest names
788
- * another plugin.
819
+ * another plugin. `restoring` puts back the version that was installed (an
820
+ * update's revert): its manifest is read as an installed one, so a rule
821
+ * tightened since it was installed does not leave the revert half done.
789
822
  */
790
- export function replacePluginPackage(name, packageName, version, logger) {
823
+ export function replacePluginPackage(
824
+ name,
825
+ packageName,
826
+ version,
827
+ logger,
828
+ { restoring = false } = {},
829
+ ) {
791
830
  return withPluginLock(async () => {
792
831
  const bare = bareLamalibrePackageName(packageName);
793
832
  const spec = `${bare}@${version}`;
@@ -809,7 +848,8 @@ export function replacePluginPackage(name, packageName, version, logger) {
809
848
  }
810
849
  const require = createRequire(`${STATE_DIR}/`);
811
850
  const manifestPath = require.resolve(`${bare}/lamaste-plugin.json`);
812
- const manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
851
+ const raw = JSON.parse(await readFile(manifestPath, 'utf-8'));
852
+ const manifest = restoring ? validateInstalledManifest(raw) : validateManifest(raw);
813
853
  if (manifest.name !== name) {
814
854
  throw Object.assign(new Error(`${spec} is plugin "${manifest.name}", not "${name}"`), {
815
855
  statusCode: 400,
@@ -57,6 +57,33 @@ export async function writeRegistryCredentialConfig(config) {
57
57
  resetRegistryTokenState();
58
58
  }
59
59
 
60
+ /**
61
+ * Store `config` as the registry credential: written while its issuer
62
+ * domain is still an active domain (a domain's removal checks the
63
+ * credential under the same lock — 400 `unknown_domain` otherwise). When the
64
+ * registry's domains change (its issuer domain, the domain its host is
65
+ * under), the tokens of every user who is a member of none of the new ones
66
+ * are revoked: who may hold a token follows them. Returns the warnings.
67
+ *
68
+ * @param {object} config
69
+ * @param {{ logger?: import('pino').Logger }} [opts]
70
+ * @returns {Promise<Array<{ step: string, message: string }>>}
71
+ */
72
+ export async function setRegistryCredential(config, { logger } = {}) {
73
+ const { registryDomainsOf, revokeRegistryTokensOutside, withActiveDomain } =
74
+ await import('./domains.js');
75
+ const issuerDomain = config.auth?.kind === 'oidc' ? config.auth.issuerDomain : undefined;
76
+ const change = await withActiveDomain(issuerDomain, async () => {
77
+ const before = await registryDomainsOf(await readRegistryCredentialConfig().catch(() => null));
78
+ await writeRegistryCredentialConfig(config);
79
+ const after = await registryDomainsOf(config);
80
+ const same = after.length === before.length && after.every((d) => before.includes(d));
81
+ return { changed: !same, after };
82
+ });
83
+ if (!change.changed) return [];
84
+ return revokeRegistryTokensOutside(change.after, config, logger);
85
+ }
86
+
60
87
  export async function clearRegistryCredentialConfig() {
61
88
  const removed = await clearCore(STATE_DIR);
62
89
  resetRegistryTokenState();
@@ -70,9 +97,9 @@ export function resetRegistryTokenState() {
70
97
  }
71
98
 
72
99
  /**
73
- * What the core needs to run the OIDC chain for a credential: the relay
74
- * domain and the `lamaste-server` client secret. Pasted-token credentials
75
- * need nothing.
100
+ * What the core needs to run the OIDC chain for a credential: the primary
101
+ * domain (the issuer when the credential names no `issuerDomain`) and the
102
+ * `lamaste-server` client secret. Pasted-token credentials need nothing.
76
103
  * @param {{ auth: { kind: string } }} config
77
104
  */
78
105
  async function oidcDeps(config) {
@@ -166,10 +193,16 @@ export async function acquireAgentToken(agentLabel) {
166
193
  * @param {string} username
167
194
  * @param {{ before: string[], after: string[] } | { deleted: true }} change
168
195
  * @param {import('pino').Logger | undefined} log
169
- * @param {{ userId?: string | null }} [who]
196
+ * @param {{ userId?: string | null, config?: object }} [who] `config`: the credential
197
+ * already read (a batch of revocations reads it once)
170
198
  */
171
- export async function revokeUserRegistryTokens(username, change, log, { userId = null } = {}) {
172
- const config = await readRegistryCredentialConfig();
199
+ export async function revokeUserRegistryTokens(
200
+ username,
201
+ change,
202
+ log,
203
+ { userId = null, config: given } = {},
204
+ ) {
205
+ const config = given ?? (await readRegistryCredentialConfig());
173
206
  if (!config) return null;
174
207
 
175
208
  const lost =
@@ -1,4 +1,5 @@
1
1
  import { execa } from 'execa';
2
+ import { autheliaReady } from '@lamalibre/lamaste/server';
2
3
 
3
4
  export const ALLOWED_SERVICES = ['nginx', 'chisel', 'authelia', 'lamalibre-lamaste-serverd'];
4
5
 
@@ -120,7 +121,6 @@ export async function executeServiceAction(name, action) {
120
121
 
121
122
  try {
122
123
  await execa('sudo', ['systemctl', action, name], { timeout: 30_000 });
123
- return { ok: true, name, action };
124
124
  } catch (err) {
125
125
  const details = err.stderr || err.message;
126
126
  const error = new Error(`Failed to ${action} ${name}`);
@@ -128,4 +128,12 @@ export async function executeServiceAction(name, action) {
128
128
  error.details = details;
129
129
  throw error;
130
130
  }
131
+ // systemctl returns when Authelia's process is spawned, before it serves
132
+ // requests: the action is done once it does.
133
+ if (name === 'authelia' && action !== 'stop' && !(await autheliaReady(execa))) {
134
+ const error = new Error(`Authelia is not serving requests after ${action}`);
135
+ error.statusCode = 500;
136
+ throw error;
137
+ }
138
+ return { ok: true, name, action };
131
139
  }
@@ -48,6 +48,16 @@ export async function writeTunnels(tunnels) {
48
48
  await fd.close();
49
49
 
50
50
  await rename(tmpPath, filePath);
51
+ await hostsChanged();
52
+ }
53
+
54
+ /**
55
+ * A tunnel or a site changed: the hosts that name external members are
56
+ * rendered again shortly (lib/domains.js `scheduleHostRender`).
57
+ */
58
+ async function hostsChanged() {
59
+ const { scheduleHostRender } = await import('./domains.js');
60
+ scheduleHostRender();
51
61
  }
52
62
 
53
63
  function sitesPath() {
@@ -93,6 +103,7 @@ export async function writeSites(sites) {
93
103
  await fd.close();
94
104
 
95
105
  await rename(tmpPath, filePath);
106
+ await hostsChanged();
96
107
  }
97
108
 
98
109
  /** Serializes every read-modify-write of sites.json in this process. */
@@ -7,8 +7,7 @@
7
7
 
8
8
  import {
9
9
  writePublicVhost,
10
- writeAuthenticatedVhost,
11
- writeRestrictedVhost,
10
+ writeGatekeeperVhost,
12
11
  removeAppVhost,
13
12
  enableAppVhost,
14
13
  disableAppVhost,
@@ -23,8 +22,7 @@ import { readTunnels, writeTunnels } from './state.js';
23
22
 
24
23
  export const tunnelNginxDeps = Object.freeze({
25
24
  writePublicVhost,
26
- writeAuthenticatedVhost,
27
- writeRestrictedVhost,
25
+ writeGatekeeperVhost,
28
26
  writeAgentPanelVhost,
29
27
  removeAppVhost,
30
28
  removeAgentPanelVhost,
@@ -57,11 +57,13 @@ import {
57
57
  TunnelError,
58
58
  bindUserSurface,
59
59
  releaseUserSurfaceRoute,
60
+ tunnelHost,
60
61
  unbindUserSurface,
61
62
  } from '@lamalibre/lamaste/server';
62
63
  import { httpError } from './app-error.js';
63
64
  import { recordAuditBestEffort } from './audit.js';
64
65
  import { getConfig } from './config.js';
66
+ import { pluginDomainName } from './domains.js';
65
67
  import { installedVersionApproved } from './plugin-hooks.js';
66
68
  import { findPluginByRoute } from './plugins.js';
67
69
  import { relaySigner } from './relay-signing.js';
@@ -225,8 +227,8 @@ export async function withUserSurfaceStatus(tunnel) {
225
227
  // ---------------------------------------------------------------------------
226
228
 
227
229
  /**
228
- * What core's binding workflows need from the panel — among them its domain
229
- * and Let's Encrypt email, which a vhost rewrite needs.
230
+ * What core's binding workflows need from the panel — among them its Let's
231
+ * Encrypt email, which a vhost rewrite needs.
230
232
  */
231
233
  function bindingOptions(id, route, logger) {
232
234
  const config = getConfig();
@@ -236,7 +238,6 @@ function bindingOptions(id, route, logger) {
236
238
  return {
237
239
  id,
238
240
  route,
239
- domain: config.domain,
240
241
  email: config.email,
241
242
  nginx: tunnelNginxDeps,
242
243
  certbot: tunnelCertbotDeps,
@@ -300,6 +301,26 @@ async function assertBindableRoute(route) {
300
301
  }
301
302
  }
302
303
 
304
+ /**
305
+ * Refuse binding tunnel `id` as the user surface of the plugin at `route`
306
+ * when the tunnel's hostname is not on the plugin's domain (400
307
+ * `not_plugin_domain`): a plugin's users are its domain's members.
308
+ */
309
+ async function assertOnPluginDomain(id, route) {
310
+ const tunnel = (await readTunnels()).find((t) => t.id === id);
311
+ const host = tunnel ? tunnelHost(tunnel) : null;
312
+ if (!host) return; // core refuses a tunnel without a hostname
313
+ const plugin = await findPluginByRoute(route);
314
+ const domain = await pluginDomainName(plugin);
315
+ if (host.domain !== domain) {
316
+ throw httpError(
317
+ `The tunnel is on ${host.domain}; the plugin holding '${route}' belongs to ${domain}`,
318
+ 400,
319
+ { code: 'not_plugin_domain' },
320
+ );
321
+ }
322
+ }
323
+
303
324
  /** Whether an installed agent-hosted plugin holds `route`. */
304
325
  async function isBindableRoute(route) {
305
326
  return assertBindableRoute(route).then(
@@ -329,7 +350,11 @@ async function isBindableRoute(route) {
329
350
  export async function bindTunnelUserSurface(id, route, { actor, logger }) {
330
351
  const result = await bindUserSurface({
331
352
  ...bindingOptions(id, route, logger),
332
- assertRoute: assertBindableRoute,
353
+ // Under the tunnel lock, as the plugin's domain changes (lib/domains.js).
354
+ assertRoute: async (r) => {
355
+ await assertBindableRoute(r);
356
+ await assertOnPluginDomain(id, r);
357
+ },
333
358
  });
334
359
  if (result.changed && !(await isBindableRoute(route))) {
335
360
  await pruneUserSurfaceBindings(route, { plugin: null, logger });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The users lock — the one lock around every read-modify-write of users.yml
3
+ * and every change of the user directory in this process (core's users
4
+ * functions take none), and around the changes that must agree with
5
+ * users.yml's groups: a membership's kind (an external member holds no
6
+ * Authelia group — lib/domains.js). Taken before the domains lock, never
7
+ * after it.
8
+ */
9
+
10
+ import { PromiseChainMutex } from '@lamalibre/lamaste';
11
+
12
+ const usersMutex = new PromiseChainMutex();
13
+
14
+ /**
15
+ * Run `fn` holding the users lock.
16
+ *
17
+ * @template T
18
+ * @param {() => Promise<T>} fn
19
+ * @returns {Promise<T>}
20
+ */
21
+ export function withUsersLock(fn) {
22
+ return usersMutex.run(fn);
23
+ }