@lamalibre/create-lamaste 4.5.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 (178) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +56 -5
  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/02-guides/static-sites.md +4 -0
  30. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  31. package/vendor/docs/03-architecture/installer.md +4 -4
  32. package/vendor/docs/03-architecture/management-flow.md +1 -1
  33. package/vendor/docs/03-architecture/nginx-configuration.md +80 -12
  34. package/vendor/docs/03-architecture/overview.md +5 -5
  35. package/vendor/docs/03-architecture/panel-client.md +6 -6
  36. package/vendor/docs/03-architecture/panel-server.md +2 -2
  37. package/vendor/docs/03-architecture/state-management.md +3 -3
  38. package/vendor/docs/03-architecture/system-overview.md +1 -1
  39. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +116 -37
  40. package/vendor/docs/04-api-reference/app-host.md +3 -3
  41. package/vendor/docs/04-api-reference/domains.md +192 -0
  42. package/vendor/docs/04-api-reference/gatekeeper.md +54 -8
  43. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  44. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  45. package/vendor/docs/04-api-reference/overview.md +9 -0
  46. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  47. package/vendor/docs/04-api-reference/sites.md +9 -0
  48. package/vendor/docs/04-api-reference/tunnels.md +35 -28
  49. package/vendor/docs/04-api-reference/users.md +41 -29
  50. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  51. package/vendor/docs/05-operations/uninstalling.md +1 -1
  52. package/vendor/docs/05-operations/upgrades.md +15 -6
  53. package/vendor/docs/06-reference/config-files.md +19 -15
  54. package/vendor/docs/06-reference/glossary.md +3 -3
  55. package/vendor/docs/06-reference/installer-flags.md +13 -13
  56. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  57. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  58. package/vendor/docs/_index.json +105 -19
  59. package/vendor/docs/external-members-design.md +816 -0
  60. package/vendor/docs/gatekeeper-design.md +8 -8
  61. package/vendor/docs/identity-directory-design.md +3 -3
  62. package/vendor/docs/multi-domain-relay-design.md +836 -0
  63. package/vendor/docs/package.json +1 -1
  64. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  65. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  66. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/index.js +2 -2
  68. package/vendor/gatekeeper/dist/index.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  70. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  72. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  73. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  74. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  76. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  77. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  79. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  81. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  82. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  83. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  84. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  85. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  86. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  88. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  89. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  90. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  91. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  92. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  93. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  94. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  95. package/vendor/gatekeeper/dist/server/index.d.ts +6 -1
  96. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/index.js +56 -11
  98. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  100. package/vendor/gatekeeper/dist/server/routes/authz.js +325 -55
  101. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  102. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  103. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  104. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  105. package/vendor/gatekeeper/package.json +2 -2
  106. package/vendor/server/bin/lamaste-server.js +7 -0
  107. package/vendor/server/package.json +2 -2
  108. package/vendor/server/src/commands/domains.js +214 -0
  109. package/vendor/server/src/commands/plugin-identity.js +2 -16
  110. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  111. package/vendor/server/src/commands/plugins.js +175 -14
  112. package/vendor/server/src/commands/registry-credential.js +10 -3
  113. package/vendor/server/src/commands/sites.js +18 -1
  114. package/vendor/server/src/commands/status.js +19 -0
  115. package/vendor/server/src/commands/tunnels.js +4 -1
  116. package/vendor/server/src/commands/users.js +8 -19
  117. package/vendor/server/src/domains.js +92 -0
  118. package/vendor/server/src/fail.js +21 -0
  119. package/vendor/server/src/oidc-client-grants.js +63 -0
  120. package/vendor/server/src/panel-api.js +15 -0
  121. package/vendor/server/src/plugin-approval.js +80 -1
  122. package/vendor/server/src/plugin-web-surface.js +307 -0
  123. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  124. package/vendor/server-ui/dist/assets/index-Cb-ADsbB.css +1 -0
  125. package/vendor/server-ui/dist/index.html +2 -2
  126. package/vendor/serverd/package.json +2 -2
  127. package/vendor/serverd/src/index.js +50 -19
  128. package/vendor/serverd/src/lib/app-error.js +24 -0
  129. package/vendor/serverd/src/lib/audience.js +12 -3
  130. package/vendor/serverd/src/lib/authelia.js +341 -35
  131. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  132. package/vendor/serverd/src/lib/chisel.js +10 -2
  133. package/vendor/serverd/src/lib/directory.js +67 -52
  134. package/vendor/serverd/src/lib/domains.js +1792 -0
  135. package/vendor/serverd/src/lib/gatekeeper-client.js +34 -7
  136. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  137. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  138. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  139. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  140. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  141. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  142. package/vendor/serverd/src/lib/nginx.js +576 -131
  143. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  144. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  145. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  146. package/vendor/serverd/src/lib/plugin-lifecycle.js +129 -5
  147. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  148. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  149. package/vendor/serverd/src/lib/plugins.js +82 -9
  150. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  151. package/vendor/serverd/src/lib/services.js +9 -1
  152. package/vendor/serverd/src/lib/state.js +29 -0
  153. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  154. package/vendor/serverd/src/lib/user-surface.js +77 -31
  155. package/vendor/serverd/src/lib/users-lock.js +23 -0
  156. package/vendor/serverd/src/lib/vhost-reconcile.js +151 -41
  157. package/vendor/serverd/src/lib/web-surface.js +783 -0
  158. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  159. package/vendor/serverd/src/routes/invite.js +15 -5
  160. package/vendor/serverd/src/routes/management/domains.js +154 -0
  161. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  162. package/vendor/serverd/src/routes/management/identity.js +21 -7
  163. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  164. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  165. package/vendor/serverd/src/routes/management/plugins.js +6 -2
  166. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  167. package/vendor/serverd/src/routes/management/sites.js +188 -71
  168. package/vendor/serverd/src/routes/management/tunnels.js +37 -22
  169. package/vendor/serverd/src/routes/management/users.js +184 -64
  170. package/vendor/serverd/src/routes/management.js +4 -0
  171. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  172. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  173. package/vendor/serverd/src/routes/user-access.js +13 -1
  174. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  175. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  176. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  177. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  178. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
@@ -1,6 +1,7 @@
1
1
  import fp from 'fastify-plugin';
2
2
  import { getConfig } from '../lib/config.js';
3
3
  import { findDirectoryUser } from '../lib/directory.js';
4
+ import { isPrimaryMember } from '../lib/domains.js';
4
5
  import {
5
6
  REFRESH_THRESHOLD,
6
7
  validateUserSession,
@@ -38,11 +39,17 @@ async function userAccessSessionPlugin(fastify, _opts) {
38
39
  return reply.code(401).send({ error: 'user_session_required' });
39
40
  }
40
41
 
41
- // The session's user must still hold the username, and be enabled: a
42
- // deleted user's username given to someone else never inherits it, and
43
- // a disabled user is out at once.
42
+ // The session's user must still hold the username, be enabled and be a
43
+ // member of the primary domain: a deleted user's username given to
44
+ // someone else never inherits it, and a disabled user, one whose
45
+ // membership ended or one made an external member is out at once.
44
46
  const holder = await findDirectoryUser(result.payload.username);
45
- if (!holder || holder.userId !== result.payload.userId || holder.status !== 'active') {
47
+ if (
48
+ !holder ||
49
+ holder.userId !== result.payload.userId ||
50
+ holder.status !== 'active' ||
51
+ !(await isPrimaryMember(holder.userId))
52
+ ) {
46
53
  return reply.code(401).send({ error: 'user_session_required' });
47
54
  }
48
55
 
@@ -8,9 +8,9 @@ import {
8
8
  generateTotpSecret,
9
9
  writeTotpToDatabase,
10
10
  } from '../lib/authelia.js';
11
- import { getConfig } from '../lib/config.js';
12
11
  import { httpError } from '../lib/app-error.js';
13
12
  import { changeUsers, usersFileError } from '../lib/directory.js';
13
+ import { invitationDomainsNow, invitationSignInDomain } from '../lib/domains.js';
14
14
 
15
15
  /**
16
16
  * Find an invitation by token using a constant-time comparison.
@@ -119,8 +119,11 @@ export default async function inviteRoutes(fastify, _opts) {
119
119
  return reply.code(500).send({ error: 'Failed to process password' });
120
120
  }
121
121
 
122
- // Through the users lock: the directory gives the user their id before
123
- // Authelia restarts and lets them sign in.
122
+ const grantedDomains = await invitationDomainsNow(invitation);
123
+
124
+ // Through the users lock: the directory gives the user their id, and
125
+ // the domains are rendered (their memberships, with the kinds the
126
+ // invitation named), before they can sign in.
124
127
  try {
125
128
  await changeUsers(
126
129
  async () => {
@@ -149,6 +152,12 @@ export default async function inviteRoutes(fastify, _opts) {
149
152
  // A removed user's grants, groups and second factors under the
150
153
  // username go first; a username with an OIDC identity is refused.
151
154
  creates: [invitation.username],
155
+ // The domains the invitation names that still are the ones it
156
+ // named (an invitation from before the multi-domain relay names
157
+ // none: the relay's one domain).
158
+ ...(Array.isArray(invitation.domains)
159
+ ? { domains: { [invitation.username]: grantedDomains } }
160
+ : {}),
152
161
  expect: (users) => users[invitation.username]?.password === hashedPassword,
153
162
  },
154
163
  );
@@ -191,8 +200,9 @@ export default async function inviteRoutes(fastify, _opts) {
191
200
  request.log.warn(err, 'Failed to update invitation status (user was created)');
192
201
  }
193
202
 
194
- const config = getConfig();
195
- const loginUrl = config.domain ? `https://auth.${config.domain}/` : null;
203
+ // The sign-in of the first domain the user is a member of.
204
+ const loginDomain = await invitationSignInDomain(invitation);
205
+ const loginUrl = loginDomain ? `https://auth.${loginDomain}/` : null;
196
206
 
197
207
  return {
198
208
  ok: true,
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The relay's base domains and their members (the multi-domain relay,
3
+ * lib/domains.js). Admin only (mTLS).
4
+ *
5
+ * GET /api/domains domains, member counts (members, external), what uses each
6
+ * POST /api/domains add one (`pending`)
7
+ * POST /api/domains/:name/verify-dns does auth.<domain> resolve here?
8
+ * POST /api/domains/:name/activate issue auth.<domain>, serve its sign-in
9
+ * DELETE /api/domains/:name remove an unused, non-primary domain
10
+ * GET /api/domains/:name/members its members, each with the membership's kind
11
+ * PUT /api/domains/:name/members/:user make a user (id or username) a member —
12
+ * `{ kind: 'member' | 'external' }`, member
13
+ * unless named; another kind changes it
14
+ * DELETE /api/domains/:name/members/:user end a membership
15
+ * PUT /api/plugins/:name/domain move a plugin to another domain
16
+ *
17
+ * Routes validate and answer; the work is in lib/domains.js and
18
+ * lib/plugin-domain.js. Refusals answer `{ error, code }`.
19
+ */
20
+
21
+ import { z } from 'zod';
22
+ import { DomainNameSchema, MEMBERSHIP_KINDS } from '@lamalibre/lamaste';
23
+ import { actorFromRequest } from '../../lib/actor.js';
24
+ import { sendFailure } from '../../lib/app-error.js';
25
+ import {
26
+ activateDomain,
27
+ addDomain,
28
+ addDomainMember,
29
+ domainsOverview,
30
+ getDomain,
31
+ listDomainMembers,
32
+ newUsersNeedDomains,
33
+ removeDomain,
34
+ removeDomainMember,
35
+ verifyDomainDns,
36
+ } from '../../lib/domains.js';
37
+ import { setPluginDomain } from '../../lib/plugin-domain.js';
38
+
39
+ const NameParamSchema = z.object({ name: DomainNameSchema });
40
+ // `user`: a directory id or a username (lib/domains.js decides which).
41
+ const MemberParamsSchema = z.object({ name: DomainNameSchema, user: z.string().min(1).max(64) });
42
+ const AddDomainSchema = z.object({ name: DomainNameSchema }).strict();
43
+ const MemberBodySchema = z.object({ kind: z.enum(MEMBERSHIP_KINDS).optional() }).strict();
44
+ const PluginParamSchema = z.object({ name: z.string().min(1).max(200) });
45
+ const PluginDomainSchema = z.object({ domain: DomainNameSchema }).strict();
46
+
47
+ export default async function domainRoutes(fastify) {
48
+ const admin = { preHandler: fastify.requireRole(['admin']) };
49
+
50
+ fastify.get('/domains', admin, async () => {
51
+ const domains = await domainsOverview();
52
+ // Whether users and invitations must name their domains (`domains_required`).
53
+ return { domains, newUsersNeedDomains: newUsersNeedDomains(domains) };
54
+ });
55
+
56
+ fastify.post('/domains', admin, async (request, reply) => {
57
+ const { name } = AddDomainSchema.parse(request.body);
58
+ try {
59
+ const domain = await addDomain(name, {
60
+ actor: actorFromRequest(request),
61
+ logger: request.log,
62
+ });
63
+ return reply.code(201).send({ domain });
64
+ } catch (err) {
65
+ return sendFailure(request, reply, err, 'The domain change failed');
66
+ }
67
+ });
68
+
69
+ fastify.post(
70
+ '/domains/:name/verify-dns',
71
+ { ...admin, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
72
+ async (request, reply) => {
73
+ const { name } = NameParamSchema.parse(request.params);
74
+ try {
75
+ return await verifyDomainDns(name);
76
+ } catch (err) {
77
+ return sendFailure(request, reply, err, 'The domain change failed');
78
+ }
79
+ },
80
+ );
81
+
82
+ fastify.post(
83
+ '/domains/:name/activate',
84
+ { ...admin, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
85
+ async (request, reply) => {
86
+ const { name } = NameParamSchema.parse(request.params);
87
+ try {
88
+ const domain = await activateDomain(name, {
89
+ actor: actorFromRequest(request),
90
+ logger: request.log,
91
+ });
92
+ return { domain };
93
+ } catch (err) {
94
+ return sendFailure(request, reply, err, 'The domain change failed');
95
+ }
96
+ },
97
+ );
98
+
99
+ fastify.delete('/domains/:name', admin, async (request, reply) => {
100
+ const { name } = NameParamSchema.parse(request.params);
101
+ try {
102
+ return await removeDomain(name, { actor: actorFromRequest(request), logger: request.log });
103
+ } catch (err) {
104
+ return sendFailure(request, reply, err, 'The domain change failed');
105
+ }
106
+ });
107
+
108
+ fastify.get('/domains/:name/members', admin, async (request, reply) => {
109
+ const { name } = NameParamSchema.parse(request.params);
110
+ if (!(await getDomain(name))) {
111
+ return reply.code(404).send({ error: 'Domain not found', code: 'domain_not_found' });
112
+ }
113
+ return { members: await listDomainMembers(name) };
114
+ });
115
+
116
+ fastify.put('/domains/:name/members/:user', admin, async (request, reply) => {
117
+ const { name, user } = MemberParamsSchema.parse(request.params);
118
+ const { kind } = MemberBodySchema.parse(request.body ?? {});
119
+ try {
120
+ return await addDomainMember(name, user, {
121
+ ...(kind ? { kind } : {}),
122
+ actor: actorFromRequest(request),
123
+ logger: request.log,
124
+ });
125
+ } catch (err) {
126
+ return sendFailure(request, reply, err, 'The domain change failed');
127
+ }
128
+ });
129
+
130
+ fastify.delete('/domains/:name/members/:user', admin, async (request, reply) => {
131
+ const { name, user } = MemberParamsSchema.parse(request.params);
132
+ try {
133
+ return await removeDomainMember(name, user, {
134
+ actor: actorFromRequest(request),
135
+ logger: request.log,
136
+ });
137
+ } catch (err) {
138
+ return sendFailure(request, reply, err, 'The domain change failed');
139
+ }
140
+ });
141
+
142
+ fastify.put('/plugins/:name/domain', admin, async (request, reply) => {
143
+ const { name } = PluginParamSchema.parse(request.params);
144
+ const { domain } = PluginDomainSchema.parse(request.body);
145
+ try {
146
+ return await setPluginDomain(name, domain, {
147
+ actor: actorFromRequest(request),
148
+ logger: request.log,
149
+ });
150
+ } catch (err) {
151
+ return sendFailure(request, reply, err, 'The domain change failed');
152
+ }
153
+ });
154
+ }
@@ -21,13 +21,24 @@
21
21
  * cannot be) — a reused username inherits nothing. The access log is purged
22
22
  * for one username by POST, its name in the body (never in a URL, which
23
23
  * logs keep).
24
+ *
25
+ * Grants and groups also decide what external members of a domain reach
26
+ * (packages/core/docs/external-members-design.md): after every change that
27
+ * went through, the domains are rendered (users.yml's owned groups — the
28
+ * restricted hosts and OIDC clients that name each external member), which
29
+ * follows whoever it narrowed (registry tokens, a device-code session). A
30
+ * rendering that fails is answered with `warnings` and tried again within
31
+ * seconds. A grant on an OIDC client (`resourceType: 'oidc-client'`) must
32
+ * name a user-facing client an installed plugin declares.
24
33
  */
25
34
 
26
35
  import { recomputeAudiences } from '../../lib/audience.js';
27
36
  import { withUsernamesForGrants } from '../../lib/directory.js';
37
+ import { scheduleRenderRetry, syncDomainAccess } from '../../lib/domains.js';
28
38
  import { checkProxiedGrant } from '../../lib/entitlements.js';
29
39
  import { actorFromRequest } from '../../lib/actor.js';
30
40
  import { gatekeeperRequest } from '../../lib/gatekeeper-client.js';
41
+ import { checkOidcClientGrant } from '../../lib/owned-groups.js';
31
42
 
32
43
  /** Gatekeeper's `path`, with the request's query string when it has one. */
33
44
  function withQuery(path, request) {
@@ -55,18 +66,43 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
55
66
  };
56
67
  };
57
68
 
58
- /** When Gatekeeper took a grant or group change, the plugin audiences follow it. */
59
- const followAudiences = async (request, answer) => {
60
- if (answer.statusCode < 400) await recomputeAudiences({ logger: request.log });
61
- return answer;
69
+ /**
70
+ * When Gatekeeper took a grant or group change: the plugin audiences and
71
+ * the domains' rendering follow it — which follows whoever it narrowed —
72
+ * all before the answer goes back. When the rendering fails, the answer
73
+ * carries `warnings` (Gatekeeper took the change, and enforces it on its
74
+ * own hosts; an OIDC client it no longer grants waits for a rendering)
75
+ * and one is tried again within seconds.
76
+ */
77
+ const followChange = async (request, answer) => {
78
+ if (answer.statusCode >= 400) return answer;
79
+ await recomputeAudiences({ logger: request.log });
80
+ let warnings;
81
+ try {
82
+ warnings = (await syncDomainAccess({ logger: request.log })).warnings ?? [];
83
+ } catch (err) {
84
+ request.log.warn({ err: err.message }, 'Rendering domain access after a grant change failed');
85
+ scheduleRenderRetry(request.log);
86
+ warnings = [
87
+ ...(err.warnings ?? []),
88
+ {
89
+ step: 'render',
90
+ message: `Gatekeeper took the change, but who may reach what was not fully rendered (${err.message}); the panel tries again within seconds`,
91
+ },
92
+ ];
93
+ }
94
+ if (warnings.length === 0 || typeof answer.data !== 'object' || answer.data === null) {
95
+ return answer;
96
+ }
97
+ return { ...answer, data: { ...answer.data, warnings } };
62
98
  };
63
99
 
64
100
  /**
65
101
  * A grant or group change: forwarded, and when Gatekeeper took it, the
66
- * plugin audiences follow it before the answer goes back.
102
+ * plugin audiences and the domains follow it before the answer goes back.
67
103
  */
68
104
  const proxyChange = async (request, method, gatekeeperPath, body) =>
69
- followAudiences(request, await proxy(method, gatekeeperPath, body));
105
+ followChange(request, await proxy(method, gatekeeperPath, body));
70
106
 
71
107
  /**
72
108
  * Give something to `usernames` in Gatekeeper: forwarded holding the
@@ -85,7 +121,7 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
85
121
  if (!Number.isInteger(err.statusCode)) throw err;
86
122
  return { statusCode: err.statusCode, data: { error: err.message, code: err.code } };
87
123
  }
88
- return followAudiences(request, answer);
124
+ return followChange(request, answer);
89
125
  };
90
126
 
91
127
  // --- Groups ---
@@ -164,7 +200,7 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
164
200
  fastify.post('/gatekeeper/grants', async (request, reply) => {
165
201
  let body;
166
202
  try {
167
- body = await checkProxiedGrant(request.body);
203
+ body = await checkOidcClientGrant(await checkProxiedGrant(request.body));
168
204
  } catch (err) {
169
205
  if (!Number.isInteger(err.statusCode)) throw err;
170
206
  return reply.code(err.statusCode).send({
@@ -1,5 +1,16 @@
1
1
  import { z } from 'zod';
2
2
  import { readUsers } from '../../lib/authelia.js';
3
+ import { adminGroups, domainNames } from '../../lib/domains.js';
4
+
5
+ /**
6
+ * Authelia's users with their groups as anything outside Authelia sees
7
+ * them: the groups Lamaste owns (domain memberships, host groups, OIDC
8
+ * client grants) left out.
9
+ */
10
+ async function shownUsers() {
11
+ const [users, domains] = await Promise.all([readUsers(), domainNames()]);
12
+ return users.map((u) => ({ ...u, groups: adminGroups(u.groups, domains) }));
13
+ }
3
14
 
4
15
  const UsernameParamSchema = z.object({
5
16
  username: z
@@ -37,10 +48,13 @@ export default async function identityRoutes(fastify, _opts) {
37
48
  }
38
49
 
39
50
  const groupsRaw = headerValue(request.headers['remote-groups']) || '';
40
- const groups = groupsRaw
41
- .split(',')
42
- .map((g) => g.trim())
43
- .filter(Boolean);
51
+ const groups = adminGroups(
52
+ groupsRaw
53
+ .split(',')
54
+ .map((g) => g.trim())
55
+ .filter(Boolean),
56
+ await domainNames(),
57
+ );
44
58
  const name = headerValue(request.headers['remote-name']) || '';
45
59
  const email = headerValue(request.headers['remote-email']) || '';
46
60
 
@@ -54,7 +68,7 @@ export default async function identityRoutes(fastify, _opts) {
54
68
  { preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'identity:query' }) },
55
69
  async (request, reply) => {
56
70
  try {
57
- const users = await readUsers();
71
+ const users = await shownUsers();
58
72
  return { users };
59
73
  } catch (err) {
60
74
  request.log.error({ err }, 'Failed to read Authelia users');
@@ -70,7 +84,7 @@ export default async function identityRoutes(fastify, _opts) {
70
84
  async (request, reply) => {
71
85
  const { username } = UsernameParamSchema.parse(request.params);
72
86
  try {
73
- const users = await readUsers();
87
+ const users = await shownUsers();
74
88
  const user = users.find((u) => u.username === username);
75
89
  if (!user) {
76
90
  return reply.code(404).send({ error: 'User not found' });
@@ -89,7 +103,7 @@ export default async function identityRoutes(fastify, _opts) {
89
103
  { preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'identity:query' }) },
90
104
  async (request, reply) => {
91
105
  try {
92
- const users = await readUsers();
106
+ const users = await shownUsers();
93
107
  /** @type {Set<string>} */
94
108
  const groupSet = new Set();
95
109
  for (const u of users) {
@@ -3,7 +3,15 @@ import { z } from 'zod';
3
3
  import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
4
4
  import { readInvitations, withInvitationsLock, writeInvitations } from '../../lib/state.js';
5
5
  import { readUsersRaw } from '../../lib/authelia.js';
6
- import { getConfig } from '../../lib/config.js';
6
+ import {
7
+ MembershipInputSchema,
8
+ assertAssignableGroups,
9
+ domainsForNewUser,
10
+ externalMemberGroupsError,
11
+ invitationMemberships,
12
+ invitationSignInDomain,
13
+ } from '../../lib/domains.js';
14
+ import { sendFailure } from '../../lib/app-error.js';
7
15
 
8
16
  const IdParamSchema = z.object({ id: z.string().uuid() });
9
17
 
@@ -19,8 +27,15 @@ const CreateInvitationSchema = z.object({
19
27
  'Username must start with a lowercase letter or digit and may contain underscores or hyphens',
20
28
  ),
21
29
  email: z.string().email(),
22
- groups: z.array(z.string()).optional().default([]),
30
+ // Authelia groups (`admins`, `feria-*`): letters, digits, "-" and "_" —
31
+ // never for an external member.
32
+ groups: z.array(z.string()).max(100).optional().default([]),
23
33
  expiresInDays: z.number().int().min(1).max(30).optional().default(7),
34
+ // The domains the user becomes a member of on accepting — a name (a
35
+ // member) or `{ name, kind }` (an external member reaches only what names
36
+ // them). Required while the relay has two or more; with one, the user
37
+ // becomes its member.
38
+ domains: z.array(MembershipInputSchema).max(50).optional(),
24
39
  });
25
40
 
26
41
  export default async function invitationRoutes(fastify, _opts) {
@@ -37,6 +52,7 @@ export default async function invitationRoutes(fastify, _opts) {
37
52
  const enriched = invitations.map((inv) => ({
38
53
  ...inv,
39
54
  token: undefined,
55
+ domains: invitationMemberships(inv),
40
56
  status: inv.used ? 'accepted' : new Date(inv.expiresAt) < now ? 'expired' : 'pending',
41
57
  }));
42
58
  return { invitations: enriched };
@@ -55,6 +71,16 @@ export default async function invitationRoutes(fastify, _opts) {
55
71
  },
56
72
  async (request, reply) => {
57
73
  const body = CreateInvitationSchema.parse(request.body);
74
+ let domains;
75
+ try {
76
+ assertAssignableGroups(body.groups);
77
+ domains = await domainsForNewUser(body.domains, { required: true });
78
+ if (body.groups.length > 0 && domains.some((m) => m.kind === 'external')) {
79
+ throw externalMemberGroupsError(400);
80
+ }
81
+ } catch (err) {
82
+ return sendFailure(request, reply, err, 'Failed to read the domains');
83
+ }
58
84
 
59
85
  // Check username doesn't already exist in Authelia
60
86
  let usersData;
@@ -97,6 +123,7 @@ export default async function invitationRoutes(fastify, _opts) {
97
123
  username: body.username,
98
124
  email: body.email,
99
125
  groups: body.groups,
126
+ domains,
100
127
  createdAt: now.toISOString(),
101
128
  expiresAt: expiresAt.toISOString(),
102
129
  used: false,
@@ -116,8 +143,10 @@ export default async function invitationRoutes(fastify, _opts) {
116
143
 
117
144
  const { invitation } = outcome;
118
145
  const { token } = invitation;
119
- const config = getConfig();
120
- const inviteUrl = config.domain ? `https://auth.${config.domain}/invite/${token}` : null;
146
+ // On the sign-in page of the first domain it names (the invitation
147
+ // page is served on every domain's auth portal).
148
+ const inviteDomain = await invitationSignInDomain(invitation);
149
+ const inviteUrl = inviteDomain ? `https://auth.${inviteDomain}/invite/${token}` : null;
121
150
 
122
151
  return reply.code(201).send({
123
152
  ok: true,
@@ -0,0 +1,151 @@
1
+ /**
2
+ * A plugin's web surface (slice 7): the site it owns, where the relay
3
+ * serves the shell its server package ships beside its user surface's API.
4
+ * Admin only. Business logic lives in `lib/web-surface.js`.
5
+ *
6
+ * GET /plugins/:name/web-surface what the installed version declares, the
7
+ * site, whether it is served, the trees, the offer
8
+ * POST /plugins/:name/web-surface { subdomain?, accessMode? } — create the site
9
+ * POST /plugins/:name/web-surface/move { tunnelId } — take over the bound tunnel's host
10
+ * DELETE /plugins/:name/web-surface delete the site and its trees
11
+ */
12
+
13
+ import { z } from 'zod';
14
+ import { WEB_SURFACE_ACCESS_MODES, derivePluginRoute } from '@lamalibre/lamaste';
15
+ import { actorFromRequest } from '../../lib/actor.js';
16
+ import { findPlugin } from '../../lib/plugins.js';
17
+ import {
18
+ createWebSurface,
19
+ deleteWebSurface,
20
+ moveWebSurfaceFromTunnel,
21
+ webSurfaceStatus,
22
+ } from '../../lib/web-surface.js';
23
+
24
+ const PluginNameParamSchema = z.object({
25
+ name: z
26
+ .string()
27
+ .min(1)
28
+ .max(100)
29
+ .regex(/^[a-z0-9-]+$/, 'Plugin name must contain only lowercase letters, numbers, and hyphens'),
30
+ });
31
+
32
+ const CreateBodySchema = z
33
+ .object({
34
+ subdomain: z
35
+ .string()
36
+ .min(1)
37
+ .max(63)
38
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'subdomain is a DNS label')
39
+ .optional(),
40
+ accessMode: z.enum(WEB_SURFACE_ACCESS_MODES).optional(),
41
+ })
42
+ .strict();
43
+
44
+ const MoveBodySchema = z.object({ tunnelId: z.string().min(1).max(64) }).strict();
45
+
46
+ /** The refusal a library threw (its `statusCode`, message and `code`), or a 500. */
47
+ function sendFailure(request, reply, err, fallback) {
48
+ if (Number.isInteger(err.statusCode) && err.statusCode < 500) {
49
+ return reply
50
+ .code(err.statusCode)
51
+ .send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
52
+ }
53
+ request.log.error(err, fallback);
54
+ return reply
55
+ .code(Number.isInteger(err.statusCode) ? err.statusCode : 500)
56
+ .send({ error: fallback, details: err.message });
57
+ }
58
+
59
+ /**
60
+ * The route plugin `:name` holds: the installed plugin's, else the one its
61
+ * name derives — a site outlives its plugin, and its status and deletion
62
+ * stay reachable (creating or moving refuses without an installed plugin).
63
+ */
64
+ async function routeOf(request) {
65
+ const { name } = PluginNameParamSchema.parse(request.params);
66
+ const plugin = await findPlugin(name);
67
+ return plugin?.route ?? derivePluginRoute(name);
68
+ }
69
+
70
+ export default async function pluginWebSurfaceRoutes(fastify, _opts) {
71
+ fastify.get(
72
+ '/plugins/:name/web-surface',
73
+ { preHandler: fastify.requireRole(['admin']) },
74
+ async (request, reply) => {
75
+ const route = await routeOf(request);
76
+ try {
77
+ return { webSurface: await webSurfaceStatus(route) };
78
+ } catch (err) {
79
+ return sendFailure(request, reply, err, 'Failed to read the web surface');
80
+ }
81
+ },
82
+ );
83
+
84
+ fastify.post(
85
+ '/plugins/:name/web-surface',
86
+ {
87
+ preHandler: fastify.requireRole(['admin']),
88
+ // Issues a certificate and reloads nginx.
89
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
90
+ },
91
+ async (request, reply) => {
92
+ const route = await routeOf(request);
93
+ const body = CreateBodySchema.parse(request.body ?? {});
94
+ try {
95
+ const webSurface = await createWebSurface(route, body, {
96
+ actor: actorFromRequest(request),
97
+ logger: request.log,
98
+ });
99
+ return reply.code(201).send({ webSurface });
100
+ } catch (err) {
101
+ return sendFailure(request, reply, err, 'Failed to create the web surface');
102
+ }
103
+ },
104
+ );
105
+
106
+ fastify.post(
107
+ '/plugins/:name/web-surface/move',
108
+ {
109
+ preHandler: fastify.requireRole(['admin']),
110
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
111
+ },
112
+ async (request, reply) => {
113
+ const route = await routeOf(request);
114
+ const { tunnelId } = MoveBodySchema.parse(request.body);
115
+ try {
116
+ return {
117
+ webSurface: await moveWebSurfaceFromTunnel(route, tunnelId, {
118
+ actor: actorFromRequest(request),
119
+ logger: request.log,
120
+ }),
121
+ };
122
+ } catch (err) {
123
+ return sendFailure(
124
+ request,
125
+ reply,
126
+ err,
127
+ 'Failed to move the user surface to the web surface',
128
+ );
129
+ }
130
+ },
131
+ );
132
+
133
+ fastify.delete(
134
+ '/plugins/:name/web-surface',
135
+ {
136
+ preHandler: fastify.requireRole(['admin']),
137
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
138
+ },
139
+ async (request, reply) => {
140
+ const route = await routeOf(request);
141
+ try {
142
+ return await deleteWebSurface(route, {
143
+ actor: actorFromRequest(request),
144
+ logger: request.log,
145
+ });
146
+ } catch (err) {
147
+ return sendFailure(request, reply, err, 'Failed to delete the web surface');
148
+ }
149
+ },
150
+ );
151
+ }
@@ -42,6 +42,7 @@ import {
42
42
  } from '../../lib/plugin-settings.js';
43
43
  import { actorFromRequest, certSerialFromRequest } from '../../lib/actor.js';
44
44
  import { recordAudit } from '../../lib/audit.js';
45
+ import { withPluginDomains } from '../../lib/domains.js';
45
46
  import {
46
47
  readPushInstallConfig,
47
48
  readPushInstallSessions,
@@ -301,6 +302,8 @@ async function withHooks(plugin) {
301
302
  agent: status.agent !== null,
302
303
  // Lamaste installs the agent half (agent.package); false: none, or installed by hand.
303
304
  agentHalf: status.agentHalf !== null,
305
+ // The installed version declares a web surface (its tree is in the approval).
306
+ web: status.web.declared,
304
307
  approval: status.approval.state,
305
308
  autoApprove: status.autoApprove,
306
309
  failOpenCount: status.relay?.failOpenCount ?? 0,
@@ -335,7 +338,7 @@ export default async function pluginRoutes(fastify, _opts) {
335
338
  // (see GET /plugins/:name/settings).
336
339
  const plugins = await attachSettingsSummaries(await attachHostings(registry.plugins));
337
340
  return {
338
- plugins: await Promise.all(plugins.map(withHooks)),
341
+ plugins: await withPluginDomains(await Promise.all(plugins.map(withHooks))),
339
342
  // Every agent's retained data — of uninstalled plugins too, which
340
343
  // no entry of `plugins` lists any more.
341
344
  retainedData: await listRetainedData(),
@@ -361,7 +364,8 @@ export default async function pluginRoutes(fastify, _opts) {
361
364
  }
362
365
 
363
366
  const [withHosting] = await attachSettingsSummaries(await attachHostings([plugin]));
364
- return { plugin: await withHooks(withHosting) };
367
+ const [shown] = await withPluginDomains([await withHooks(withHosting)]);
368
+ return { plugin: shown };
365
369
  },
366
370
  );
367
371