@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
@@ -13,12 +13,18 @@ import {
13
13
  SiteError,
14
14
  withTunnelLock,
15
15
  } from '@lamalibre/lamaste/server';
16
+ import { DomainNameSchema } from '@lamalibre/lamaste';
16
17
  import { AppError } from '../../lib/app-error.js';
17
18
  import { webSurfaceServing } from '../../lib/web-surface.js';
18
19
  import { getConfig } from '../../lib/config.js';
19
20
  import { readSites, writeSites, readTunnels, withSitesLock } from '../../lib/state.js';
20
21
  import { writeStaticSiteVhost, removeStaticSiteVhost } from '../../lib/nginx.js';
21
- import { updateAccessControl } from '../../lib/authelia.js';
22
+ import {
23
+ activeDomainNames,
24
+ domainNames,
25
+ resolveActiveDomain,
26
+ syncDomainAccess,
27
+ } from '../../lib/domains.js';
22
28
  import { issueTunnelCert, getCertPath, issueSiteCert } from '../../lib/certbot.js';
23
29
  import {
24
30
  createSiteDirectory,
@@ -57,6 +63,8 @@ const CreateSiteSchema = z.object({
57
63
  aliases: z.array(AliasSchema).max(10, 'At most 10 aliases').optional(),
58
64
  spaMode: z.boolean().optional().default(false),
59
65
  autheliaProtected: z.boolean().optional().default(false),
66
+ /** A managed site's domain (an active one; the primary when absent). */
67
+ domain: DomainNameSchema.optional(),
60
68
  });
61
69
 
62
70
  const UpdateSiteSchema = z.object({
@@ -107,8 +115,31 @@ function buildTunnelReadDeps() {
107
115
  return { readTunnels };
108
116
  }
109
117
 
110
- function buildAutheliaDeps() {
111
- return { updateAccessControl };
118
+ /**
119
+ * Authelia renders every site's protection together with the domains'
120
+ * members (lib/domains.js) — given the sites being saved.
121
+ */
122
+ function buildAutheliaDeps(logger) {
123
+ return {
124
+ renderAccess: async (sites) => {
125
+ await syncDomainAccess({ logger, sites });
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * The active domain a managed site is put on (the primary unless named), as
132
+ * a SiteError the create route answers like its other refusals.
133
+ *
134
+ * @param {string | undefined} requested
135
+ */
136
+ async function siteDomain(requested) {
137
+ try {
138
+ return await resolveActiveDomain(requested);
139
+ } catch (err) {
140
+ if (Number.isInteger(err.statusCode)) throw new SiteError(err.message, 'DOMAIN_NOT_CONFIGURED');
141
+ throw err;
142
+ }
112
143
  }
113
144
 
114
145
  /**
@@ -119,7 +150,6 @@ function siteErrorStatus(code) {
119
150
  case 'CUSTOM_DOMAIN_REQUIRED':
120
151
  case 'NAME_IN_USE':
121
152
  case 'RESERVED_NAME':
122
- case 'NAME_TUNNEL_COLLISION':
123
153
  case 'FQDN_IN_USE':
124
154
  case 'FQDN_TUNNEL_COLLISION':
125
155
  case 'NOT_CUSTOM':
@@ -127,6 +157,7 @@ function siteErrorStatus(code) {
127
157
  case 'INVALID_ALIAS':
128
158
  case 'ALIAS_IN_USE':
129
159
  case 'DNS_MISMATCH':
160
+ case 'NOT_UNDER_DOMAIN':
130
161
  return 400;
131
162
  case 'NOT_FOUND':
132
163
  return 404;
@@ -227,7 +258,6 @@ export default async function sitesRoutes(fastify, _opts) {
227
258
  const filesDeps = buildFilesDeps();
228
259
  const siteStateDeps = buildSiteStateDeps();
229
260
  const tunnelReadDeps = buildTunnelReadDeps();
230
- const autheliaDeps = buildAutheliaDeps();
231
261
 
232
262
  // GET /api/sites
233
263
  //
@@ -288,9 +318,15 @@ export default async function sitesRoutes(fastify, _opts) {
288
318
  error: 'Domain and email must be configured before creating sites',
289
319
  });
290
320
  }
291
-
321
+ if (body.domain !== undefined && body.type !== 'managed') {
322
+ return reply.code(400).send({
323
+ error: "A custom site's hostname names its domain — `domain` is for managed sites",
324
+ });
325
+ }
292
326
  try {
293
- const result = await withHostnameLocks(() =>
327
+ // The domain is resolved under the hostname locks — those a domain's
328
+ // removal takes — so the site never lands on a removed domain.
329
+ const result = await withHostnameLocks(async () =>
294
330
  createSite({
295
331
  name: body.name,
296
332
  type: body.type,
@@ -298,7 +334,9 @@ export default async function sitesRoutes(fastify, _opts) {
298
334
  aliases: body.aliases,
299
335
  spaMode: body.spaMode,
300
336
  autheliaProtected: body.autheliaProtected,
301
- domain: config.domain,
337
+ domain: await siteDomain(body.domain),
338
+ domains: await activeDomainNames(),
339
+ relayDomains: await domainNames(),
302
340
  email: config.email,
303
341
  nginx: nginxDeps,
304
342
  certbot: certbotDeps,
@@ -388,14 +426,15 @@ export default async function sitesRoutes(fastify, _opts) {
388
426
  autheliaProtected: body.autheliaProtected,
389
427
  allowedUsers: body.allowedUsers,
390
428
  aliases: body.aliases,
391
- domain: config.domain,
429
+ domains: await activeDomainNames(),
430
+ relayDomains: await domainNames(),
392
431
  email: config.email,
393
432
  serverIp: config.ip,
394
433
  nginx: nginxDeps,
395
434
  certbot: certbotDeps,
396
435
  siteState: siteStateDeps,
397
436
  tunnelState: tunnelReadDeps,
398
- authelia: autheliaDeps,
437
+ authelia: buildAutheliaDeps(request.log),
399
438
  logger: request.log,
400
439
  });
401
440
  });
@@ -444,7 +483,7 @@ export default async function sitesRoutes(fastify, _opts) {
444
483
  return verifyDns({
445
484
  id,
446
485
  serverIp: config.ip,
447
- domain: config.domain,
486
+ domains: await activeDomainNames(),
448
487
  email: config.email,
449
488
  nginx: nginxDeps,
450
489
  certbot: certbotDeps,
@@ -22,16 +22,20 @@ import {
22
22
  updateTunnel,
23
23
  TUNNEL_MAX_BODY_MB,
24
24
  grantedTunnelsFor,
25
+ hostnameHolder,
25
26
  TunnelError,
26
27
  } from '@lamalibre/lamaste/server';
27
28
  import {
28
29
  AGENT_LABEL_REGEX,
30
+ DomainNameSchema,
29
31
  INTERNAL_PLUGIN_PORT_RANGE,
30
32
  PLUGIN_AGENT_CN_PREFIX,
31
33
  RESERVED_TUNNEL_PORTS,
32
34
  isInternalPluginPort,
33
35
  } from '@lamalibre/lamaste';
34
36
  import { getConfig } from '../../lib/config.js';
37
+ import { primaryDomainName, resolveActiveDomain } from '../../lib/domains.js';
38
+ import { sendFailure } from '../../lib/app-error.js';
35
39
  import { readTunnels, readSites } from '../../lib/state.js';
36
40
  import { getAgentHostFactsHash, loadAgentRegistry } from '../../lib/mtls.js';
37
41
  import { getChiselCredentialIssuedAt } from '../../lib/chisel-users.js';
@@ -147,6 +151,9 @@ const CreateTunnelSchema = z
147
151
  agentLabel: AgentLabelSchema.optional(),
148
152
  accessMode: AccessModeSchema.optional().default('restricted'),
149
153
  maxBodySizeMb: MaxBodySizeSchema.optional(),
154
+ // The base domain the hostname is under (an active one; the primary
155
+ // when absent). An agent panel is always on the primary.
156
+ domain: DomainNameSchema.optional(),
150
157
  })
151
158
  .refine((d) => d.type !== 'plugin' || (d.pluginName && d.agentLabel), {
152
159
  message: 'pluginName and agentLabel are required for plugin tunnels',
@@ -227,9 +234,8 @@ function maxBodyCeiling(request) {
227
234
  * every workflow giving a site a hostname holds too.
228
235
  */
229
236
  async function siteServing(fqdn) {
230
- const sites = await readSites();
231
- const site = sites.find((s) => s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn));
232
- return site ? `by a static site ('${site.name}')` : null;
237
+ const holder = hostnameHolder(fqdn, { sites: await readSites() });
238
+ return holder ? `by a static site ('${holder.name}')` : null;
233
239
  }
234
240
 
235
241
  /**
@@ -342,6 +348,7 @@ export default async function tunnelRoutes(fastify, _opts) {
342
348
  tunnels: carried.map((t) => ({
343
349
  port: t.port,
344
350
  subdomain: t.subdomain,
351
+ fqdn: t.fqdn,
345
352
  })),
346
353
  // The agent-hosted plugins this agent hosts: the agent fetches a
347
354
  // plugin's config when it does not hold the secret issued at
@@ -501,6 +508,17 @@ export default async function tunnelRoutes(fastify, _opts) {
501
508
  error: 'Domain and email must be configured before creating tunnels',
502
509
  });
503
510
  }
511
+ let domain;
512
+ try {
513
+ domain = await resolveActiveDomain(body.domain);
514
+ } catch (err) {
515
+ return sendFailure(request, reply, err, 'Failed to read the domains');
516
+ }
517
+ if (type === 'panel' && domain !== (await primaryDomainName())) {
518
+ return reply
519
+ .code(400)
520
+ .send({ error: 'An agent panel is served on the primary domain only' });
521
+ }
504
522
 
505
523
  try {
506
524
  const tunnel = await createTunnel({
@@ -512,7 +530,11 @@ export default async function tunnelRoutes(fastify, _opts) {
512
530
  pluginName,
513
531
  agentLabel,
514
532
  maxBodySizeMb,
515
- domain: config.domain,
533
+ domain,
534
+ // Under the tunnel lock: the domain may have been removed since.
535
+ assertDomainActive: async (d) => {
536
+ await resolveActiveDomain(d);
537
+ },
516
538
  email: config.email,
517
539
  nginx: nginxDeps,
518
540
  certbot: certbotDeps,
@@ -524,6 +546,9 @@ export default async function tunnelRoutes(fastify, _opts) {
524
546
  });
525
547
  return reply.code(201).send({ ok: true, tunnel });
526
548
  } catch (err) {
549
+ if (Number.isInteger(err.statusCode)) {
550
+ return sendFailure(request, reply, err, 'Failed to create tunnel');
551
+ }
527
552
  if (err instanceof TunnelError) {
528
553
  const status = tunnelErrorStatus(err.code);
529
554
  return reply.code(status).send({
@@ -589,7 +614,6 @@ export default async function tunnelRoutes(fastify, _opts) {
589
614
  agentLabel: body.agentLabel,
590
615
  accessMode: body.accessMode,
591
616
  maxBodySizeMb: body.maxBodySizeMb,
592
- domain: config.domain,
593
617
  email: config.email,
594
618
  nginx: nginxDeps,
595
619
  certbot: certbotDeps,
@@ -1,5 +1,10 @@
1
1
  import { z } from 'zod';
2
- import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
2
+ import {
3
+ USERNAME_MAX_LENGTH,
4
+ USERNAME_MIN_LENGTH,
5
+ USERNAME_REGEX,
6
+ isLamasteOwnedGroup,
7
+ } from '@lamalibre/lamaste';
3
8
  import {
4
9
  readUsers,
5
10
  readUsersRaw,
@@ -9,7 +14,7 @@ import {
9
14
  writeTotpToDatabase,
10
15
  } from '../../lib/authelia.js';
11
16
  import { revokeUserRegistryTokens } from '../../lib/registry-credential.js';
12
- import { httpError } from '../../lib/app-error.js';
17
+ import { httpError, sendFailure } from '../../lib/app-error.js';
13
18
  import { actorFromRequest } from '../../lib/actor.js';
14
19
  import { recordAuditBestEffort } from '../../lib/audit.js';
15
20
  import {
@@ -21,6 +26,19 @@ import {
21
26
  getDirectoryUser,
22
27
  usersFileError,
23
28
  } from '../../lib/directory.js';
29
+ import {
30
+ MembershipInputSchema,
31
+ adminGroups,
32
+ adminGroupsOf,
33
+ assertAssignableGroups,
34
+ domainNames,
35
+ domainsByUser,
36
+ domainsForNewUser,
37
+ domainsOfUser,
38
+ externalMemberGroupsError,
39
+ normalizeMemberships,
40
+ setUserDomains,
41
+ } from '../../lib/domains.js';
24
42
 
25
43
  const CreateUserSchema = z.object({
26
44
  username: z
@@ -34,7 +52,11 @@ const CreateUserSchema = z.object({
34
52
  displayname: z.string().min(1).max(100),
35
53
  email: z.string().email(),
36
54
  password: z.string().min(8).max(128),
37
- groups: z.array(z.string()).optional().default([]),
55
+ groups: z.array(z.string()).max(100).optional().default([]),
56
+ // The domains the user is a member of — a name (a member) or
57
+ // `{ name, kind }` — required while the relay has two or more; with one,
58
+ // the user becomes its member.
59
+ domains: z.array(MembershipInputSchema).max(50).optional(),
38
60
  });
39
61
 
40
62
  const UsernameParamSchema = z.object({
@@ -50,10 +72,14 @@ const UpdateUserSchema = z
50
72
  displayname: z.string().min(1).max(100).optional(),
51
73
  email: z.string().email().optional(),
52
74
  password: z.string().min(8).max(128).optional(),
53
- groups: z.array(z.string()).optional(),
75
+ groups: z.array(z.string()).max(100).optional(),
54
76
  // Authelia's `disabled: true`: the user cannot sign in, every plugin
55
77
  // audience shows them disabled, and the user surface refuses them.
56
78
  disabled: z.boolean().optional(),
79
+ // The domains the user is a member of afterwards — exactly these (the
80
+ // difference applied in one transaction, each membership audited): a
81
+ // name (a member) or `{ name, kind }`.
82
+ domains: z.array(MembershipInputSchema).max(50).optional(),
57
83
  })
58
84
  .refine((data) => Object.keys(data).length > 0, {
59
85
  message: 'At least one field must be provided',
@@ -76,17 +102,28 @@ async function readUsersOrFail() {
76
102
  }
77
103
  }
78
104
 
79
- async function writeUsersOrFail(usersData) {
105
+ async function writeUsersOrFail(usersData, opts) {
80
106
  try {
81
- await writeUsers(usersData);
107
+ await writeUsers(usersData, opts);
82
108
  } catch (err) {
83
109
  throw httpError('Failed to update user database', 500, { cause: err });
84
110
  }
85
111
  }
86
112
 
87
- /** The user's groups as a sorted list (users.yml may hold none). */
88
- function groupsOf(entry) {
89
- return Array.isArray(entry?.groups) ? [...entry.groups].sort() : [];
113
+ /**
114
+ * The groups an administrator gave a user as a sorted list — the groups
115
+ * Lamaste owns left out (users.yml may hold none). `domains`: every domain
116
+ * record.
117
+ */
118
+ function groupsOf(entry, domains) {
119
+ return adminGroups(entry?.groups, domains).sort();
120
+ }
121
+
122
+ /** The groups Lamaste owns that a users.yml entry holds — kept by every edit. */
123
+ function ownedGroupsOf(entry, domains) {
124
+ return Array.isArray(entry?.groups)
125
+ ? entry.groups.filter((g) => isLamasteOwnedGroup(g, domains))
126
+ : [];
90
127
  }
91
128
 
92
129
  /** Whether two lists hold the same strings. */
@@ -101,22 +138,6 @@ function isEnabledAdmin(entry) {
101
138
  );
102
139
  }
103
140
 
104
- /**
105
- * Answer a refusal a users change threw (an error with `statusCode`: its
106
- * message and `code`), or a 500 for anything else — logged either way when
107
- * it is the panel's own failure.
108
- */
109
- function sendFailure(request, reply, err, fallback) {
110
- if (Number.isInteger(err.statusCode)) {
111
- if (err.statusCode >= 500) request.log.error(err.cause ?? err, err.message);
112
- return reply
113
- .code(err.statusCode)
114
- .send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
115
- }
116
- request.log.error(err, fallback);
117
- return reply.code(500).send({ error: fallback });
118
- }
119
-
120
141
  export default async function usersRoutes(fastify, _opts) {
121
142
  // GET /api/users — list all users (no sensitive fields): users.yml's, with
122
143
  // each one's directory id (null until the directory has seen them, within
@@ -130,13 +151,17 @@ export default async function usersRoutes(fastify, _opts) {
130
151
  try {
131
152
  const users = await readUsers();
132
153
  const directory = await directoryUsersByUsername();
154
+ const memberships = await domainsByUser();
155
+ const domains = await domainNames();
133
156
  const listed = users
134
157
  .map((user) => {
135
158
  const known = directory.get(user.username);
136
159
  return {
137
160
  ...user,
161
+ groups: groupsOf(user, domains),
138
162
  userId: known?.userId ?? null,
139
163
  status: known?.status ?? (user.disabled ? 'disabled' : 'active'),
164
+ domains: (known && memberships.get(known.userId)) ?? [],
140
165
  };
141
166
  })
142
167
  .sort((a, b) => a.username.localeCompare(b.username));
@@ -160,6 +185,17 @@ export default async function usersRoutes(fastify, _opts) {
160
185
  async (request, reply) => {
161
186
  const body = CreateUserSchema.parse(request.body);
162
187
 
188
+ let domains;
189
+ try {
190
+ assertAssignableGroups(body.groups);
191
+ domains = await domainsForNewUser(body.domains, { required: true });
192
+ if (body.groups.length > 0 && domains.some((m) => m.kind === 'external')) {
193
+ throw externalMemberGroupsError(400);
194
+ }
195
+ } catch (err) {
196
+ return sendFailure(request, reply, err, 'Failed to read the domains');
197
+ }
198
+
163
199
  try {
164
200
  // Refused before a password is hashed; checked again under the lock.
165
201
  if ((await readUsersOrFail()).users[body.username]) {
@@ -198,6 +234,7 @@ export default async function usersRoutes(fastify, _opts) {
198
234
  // A removed user's grants, groups and second factors under the
199
235
  // username go first; a username with an OIDC identity is refused.
200
236
  creates: [body.username],
237
+ domains: { [body.username]: domains },
201
238
  expect: (users) => users[body.username]?.password === hash,
202
239
  },
203
240
  ));
@@ -217,6 +254,7 @@ export default async function usersRoutes(fastify, _opts) {
217
254
  disabled: false,
218
255
  userId: created?.userId ?? null,
219
256
  status: 'active',
257
+ domains: created ? await domainsOfUser(created.userId) : [],
220
258
  },
221
259
  ...(warnings.length > 0 ? { warnings } : {}),
222
260
  });
@@ -248,68 +286,149 @@ export default async function usersRoutes(fastify, _opts) {
248
286
  }
249
287
  }
250
288
 
289
+ const domains = await domainNames();
290
+
251
291
  /** Whether users.yml's entry holds every field this request set. */
252
292
  const intended = (entry) =>
253
293
  entry !== undefined &&
254
294
  entry !== null &&
255
295
  (body.displayname === undefined || entry.displayname === body.displayname) &&
256
296
  (body.email === undefined || entry.email === body.email) &&
257
- (body.groups === undefined || sameList(groupsOf(entry), [...body.groups].sort())) &&
297
+ (body.groups === undefined ||
298
+ sameList(groupsOf(entry, domains), [...body.groups].sort())) &&
258
299
  (passwordHash === undefined || entry.password === passwordHash) &&
259
300
  (body.disabled === undefined || (entry.disabled === true) === body.disabled);
260
301
 
302
+ const { domains: memberOf, ...fields } = body;
303
+ const memberships = memberOf === undefined ? undefined : normalizeMemberships(memberOf);
304
+ const known = await findDirectoryUser(username);
305
+ const userId = known?.userId ?? null;
306
+ if (memberships !== undefined && !userId) {
307
+ return reply.code(409).send({
308
+ error: 'The directory has not seen this user yet — try again within a minute',
309
+ code: 'user_not_in_directory',
310
+ });
311
+ }
312
+ const current = userId ? await domainsOfUser(userId) : [];
313
+ const external = (memberships ?? current).some((m) => m.kind === 'external');
314
+ // Refused before anything is written: groups kept or given to a user
315
+ // who is (or becomes) an external member. Checked again under the
316
+ // users lock, by each step.
317
+ if (external) {
318
+ let groups = body.groups;
319
+ if (groups === undefined) {
320
+ try {
321
+ groups = await adminGroupsOf(username);
322
+ } catch (err) {
323
+ return sendFailure(request, reply, err, 'Failed to read user database');
324
+ }
325
+ }
326
+ if (groups.length > 0) {
327
+ return sendFailure(request, reply, externalMemberGroupsError(409), 'Refused');
328
+ }
329
+ }
330
+ // A user leaving every external membership with groups given in the
331
+ // same request: the memberships first, then the groups (each step is
332
+ // checked against the other under the users lock).
333
+ const membershipsFirst =
334
+ memberships !== undefined && !external && current.some((m) => m.kind === 'external');
335
+
336
+ const warnings = [];
337
+ const changeMemberships = async () => {
338
+ if (memberships === undefined) return;
339
+ const membership = await setUserDomains(userId, memberships, {
340
+ actor: actorFromRequest(request),
341
+ logger: request.log,
342
+ });
343
+ warnings.push(...(membership.warnings ?? []));
344
+ };
345
+ if (membershipsFirst) {
346
+ try {
347
+ await changeMemberships();
348
+ } catch (err) {
349
+ return sendFailure(request, reply, err, 'Failed to change the memberships');
350
+ }
351
+ }
352
+
261
353
  let outcome;
262
354
  let before = null;
263
355
  try {
264
- outcome = await changeUsers(
265
- async () => {
266
- const usersData = await readUsersOrFail();
267
- const user = usersData.users[username];
268
- if (!user) throw httpError('User not found', 404);
269
- // As it was before this request (a second attempt finds it changed already).
270
- before ??= { groups: groupsOf(user), disabled: user.disabled === true };
271
- if (body.disabled === true && isEnabledAdmin(user)) {
272
- const enabledAdmins = Object.values(usersData.users).filter(isEnabledAdmin).length;
273
- if (enabledAdmins <= 1) {
274
- throw httpError('Cannot disable the last enabled administrator', 400, {
275
- code: 'last_admin',
276
- });
356
+ // Only memberships change: users.yml is not written, the user is read.
357
+ if (Object.keys(fields).length === 0) {
358
+ const usersData = await readUsersOrFail();
359
+ const user = usersData.users[username];
360
+ if (!user) throw httpError('User not found', 404);
361
+ before = { groups: groupsOf(user, domains), disabled: user.disabled === true };
362
+ outcome = { result: user, warnings: [], persisted: true };
363
+ } else
364
+ outcome = await changeUsers(
365
+ async () => {
366
+ const usersData = await readUsersOrFail();
367
+ const user = usersData.users[username];
368
+ if (!user) throw httpError('User not found', 404);
369
+ // As it was before this request (a second attempt finds it changed already).
370
+ before ??= { groups: groupsOf(user, domains), disabled: user.disabled === true };
371
+ if (body.groups !== undefined) {
372
+ assertAssignableGroups(body.groups, before.groups);
373
+ // Checked under the users lock, which a change of kind takes too.
374
+ if (
375
+ body.groups.length > 0 &&
376
+ userId &&
377
+ (await domainsOfUser(userId)).some((m) => m.kind === 'external')
378
+ ) {
379
+ throw externalMemberGroupsError(409);
380
+ }
381
+ }
382
+ if (body.disabled === true && isEnabledAdmin(user)) {
383
+ const enabledAdmins = Object.values(usersData.users).filter(isEnabledAdmin).length;
384
+ if (enabledAdmins <= 1) {
385
+ throw httpError('Cannot disable the last enabled administrator', 400, {
386
+ code: 'last_admin',
387
+ });
388
+ }
277
389
  }
278
- }
279
-
280
- if (body.displayname !== undefined) user.displayname = body.displayname;
281
- if (body.email !== undefined) user.email = body.email;
282
- if (body.groups !== undefined) user.groups = body.groups;
283
- if (passwordHash !== undefined) user.password = passwordHash;
284
- if (body.disabled === true) user.disabled = true;
285
- if (body.disabled === false) delete user.disabled;
286
390
 
287
- await writeUsersOrFail(usersData);
288
- return user;
289
- },
290
- {
291
- actor: actorFromRequest(request),
292
- logger: request.log,
293
- // Authelia's own password reset may rewrite users.yml under the
294
- // change: it is checked after the restart, and made once more.
295
- expect: (users) => intended(users[username]),
296
- },
297
- );
391
+ if (body.displayname !== undefined) user.displayname = body.displayname;
392
+ if (body.email !== undefined) user.email = body.email;
393
+ if (body.groups !== undefined) {
394
+ user.groups = [...body.groups, ...ownedGroupsOf(user, domains)];
395
+ }
396
+ if (passwordHash !== undefined) user.password = passwordHash;
397
+ if (body.disabled === true) user.disabled = true;
398
+ if (body.disabled === false) delete user.disabled;
399
+
400
+ await writeUsersOrFail(usersData);
401
+ return user;
402
+ },
403
+ {
404
+ actor: actorFromRequest(request),
405
+ logger: request.log,
406
+ // Authelia's own password reset may rewrite users.yml under the
407
+ // change: it is checked after the rendering, and made once more.
408
+ expect: (users) => intended(users[username]),
409
+ },
410
+ );
298
411
  } catch (err) {
299
412
  return sendFailure(request, reply, err, 'Failed to update user database');
300
413
  }
301
414
 
302
415
  const user = outcome.result;
303
416
  const disabled = user.disabled === true;
304
- const warnings = [...outcome.warnings];
305
- const known = await findDirectoryUser(username);
306
- const userId = known?.userId ?? null;
417
+ warnings.push(...outcome.warnings);
418
+
419
+ if (!membershipsFirst) {
420
+ try {
421
+ await changeMemberships();
422
+ } catch (err) {
423
+ return sendFailure(request, reply, err, 'Failed to change the memberships');
424
+ }
425
+ }
307
426
 
308
427
  // Disabling is offboarding, which the change did as the directory saw
309
428
  // the user become disabled (`changeUsers`): sessions ended, registry
310
429
  // tokens revoked. Registry scopes are granted at login from feria-*
311
430
  // groups too: losing one must not leave a valid token behind either.
312
- const groupsAfter = groupsOf(user);
431
+ const groupsAfter = groupsOf(user, domains);
313
432
  const groupsChanged = body.groups !== undefined && !sameList(before.groups, groupsAfter);
314
433
  if (groupsChanged && !(disabled && !before.disabled)) {
315
434
  try {
@@ -348,10 +467,11 @@ export default async function usersRoutes(fastify, _opts) {
348
467
  username,
349
468
  displayname: user.displayname,
350
469
  email: user.email,
351
- groups: user.groups || [],
470
+ groups: groupsAfter,
352
471
  disabled,
353
472
  userId,
354
473
  status: disabled ? 'disabled' : 'active',
474
+ domains: userId ? await domainsOfUser(userId) : [],
355
475
  },
356
476
  ...(outcome.persisted ? {} : { persisted: false }),
357
477
  ...(warnings.length > 0 ? { warnings } : {}),
@@ -386,7 +506,7 @@ export default async function usersRoutes(fastify, _opts) {
386
506
  throw httpError('Cannot delete the last user', 400);
387
507
  }
388
508
  delete usersData.users[username];
389
- await writeUsersOrFail(usersData);
509
+ await writeUsersOrFail(usersData, { deleting: [username] });
390
510
  },
391
511
  {
392
512
  actor,
@@ -20,6 +20,7 @@ import agentsRoutes from './management/agents.js';
20
20
  import registryCredentialRoutes from './management/registry-credential.js';
21
21
  import auditRoutes from './management/audit.js';
22
22
  import agentPanelAuthzRoutes from './management/agent-panel-authz.js';
23
+ import domainRoutes from './management/domains.js';
23
24
 
24
25
  export default async function managementRoutes(fastify, _opts) {
25
26
  fastify.addHook('onRequest', managementOnly());
@@ -45,4 +46,5 @@ export default async function managementRoutes(fastify, _opts) {
45
46
  await fastify.register(registryCredentialRoutes);
46
47
  await fastify.register(auditRoutes);
47
48
  await fastify.register(agentPanelAuthzRoutes);
49
+ await fastify.register(domainRoutes);
48
50
  }
@@ -1,21 +1,7 @@
1
- import dns from 'node:dns/promises';
2
- import { CORE_SUBDOMAINS } from '@lamalibre/lamaste';
1
+ import { CORE_SUBDOMAINS, DNS_WILDCARD_PROBE_LABEL } from '@lamalibre/lamaste';
2
+ import { resolveHostA as resolveA } from '@lamalibre/lamaste/server';
3
3
  import { getConfig, updateConfig } from '../../lib/config.js';
4
4
 
5
- /**
6
- * Resolve A records for a hostname, returning an empty array on expected DNS errors.
7
- */
8
- async function resolveA(hostname) {
9
- try {
10
- return await dns.resolve4(hostname);
11
- } catch (err) {
12
- if (err.code === 'ENOTFOUND' || err.code === 'ENODATA' || err.code === 'ETIMEOUT') {
13
- return [];
14
- }
15
- throw err;
16
- }
17
- }
18
-
19
5
  /**
20
6
  * Build a human-readable diagnostic message for the DNS verification result.
21
7
  */
@@ -69,7 +55,7 @@ export default async function dnsRoute(fastify, _opts) {
69
55
  const [coreResolved, apexResolvedIps, wildcardResolvedIps] = await Promise.all([
70
56
  Promise.all(coreNames.map((name) => resolveA(name))),
71
57
  resolveA(domain),
72
- resolveA(`test-lamaste-check.${domain}`),
58
+ resolveA(`${DNS_WILDCARD_PROBE_LABEL}.${domain}`),
73
59
  ]);
74
60
 
75
61
  const records = coreNames.map((name, i) => {