@lamalibre/create-lamaste 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/lamaste-priv +200 -16
- package/src/index.js +1 -0
- package/src/lib/backend-cookie.js +65 -0
- package/src/lib/ip-panel-vhost.js +208 -0
- package/src/lib/service-config.js +5 -3
- package/src/tasks/nginx.js +18 -135
- package/src/tasks/redeploy.js +39 -1
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +1 -1
- package/vendor/docs/01-concepts/authorization.md +66 -48
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +70 -33
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +5 -5
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/managing-users.md +113 -25
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +3 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
- package/vendor/docs/03-architecture/overview.md +13 -17
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +3 -2
- package/vendor/docs/03-architecture/state-management.md +4 -8
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +292 -23
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +15 -13
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +6 -4
- package/vendor/docs/04-api-reference/tunnels.md +67 -29
- package/vendor/docs/04-api-reference/users.md +241 -45
- package/vendor/docs/05-operations/backup-and-restore.md +35 -26
- package/vendor/docs/05-operations/monitoring.md +39 -0
- package/vendor/docs/05-operations/uninstalling.md +2 -1
- package/vendor/docs/05-operations/upgrades.md +15 -6
- package/vendor/docs/06-reference/config-files.md +83 -189
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +25 -26
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +10 -8
- package/vendor/docs/identity-directory-design.md +48 -14
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -0
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +6 -1
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.js +5 -1
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
- package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/principals.js +67 -0
- package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +22 -48
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +15 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-identity.js +335 -0
- package/vendor/server/src/commands/plugins.js +99 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/tunnels.js +102 -2
- package/vendor/server/src/commands/uninstall.js +3 -1
- package/vendor/server/src/commands/users.js +358 -0
- package/vendor/server/src/plugin-approval.js +107 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +25 -1
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/config.js +68 -43
- package/vendor/serverd/src/lib/directory.js +1113 -0
- package/vendor/serverd/src/lib/entitlements.js +517 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
- package/vendor/serverd/src/lib/identity-sync.js +905 -0
- package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
- package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +279 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
- package/vendor/serverd/src/lib/plugin-packages.js +30 -22
- package/vendor/serverd/src/lib/plugins.js +94 -15
- package/vendor/serverd/src/lib/registry-credential.js +46 -6
- package/vendor/serverd/src/lib/request-logging.js +19 -0
- package/vendor/serverd/src/lib/retained-data.js +50 -4
- package/vendor/serverd/src/lib/state.js +18 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
- package/vendor/serverd/src/lib/user-access-session.js +21 -11
- package/vendor/serverd/src/lib/user-caller.js +67 -0
- package/vendor/serverd/src/lib/user-surface.js +733 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
- package/vendor/serverd/src/middleware/user-access-session.js +9 -0
- package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
- package/vendor/serverd/src/routes/invite.js +53 -12
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
- package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
- package/vendor/serverd/src/routes/management/invitations.js +78 -68
- package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/tunnels.js +98 -2
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
- package/vendor/serverd/src/routes/user-access.js +17 -4
- package/vendor/serverd/src/routes/user-surface.js +69 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
- package/vendor/server-ui/dist/assets/index-CESyyK8V.js +0 -84
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
* Authelia-protected static site and of the auth portal named the client's
|
|
14
14
|
* `Host` header rather than the vhost's own hostname, so a request line for
|
|
15
15
|
* one hostname sent with the `Host` of another was authorized for the other
|
|
16
|
-
* (see `originalUrl` in nginx.js)
|
|
16
|
+
* (see `originalUrl` in nginx.js);
|
|
17
|
+
* - from 4.5 the panel serves plugins' user surfaces to a relay assertion
|
|
18
|
+
* Gatekeeper signs, so every vhost that proxies a browser to the panel —
|
|
19
|
+
* the panel's own and the auth portal — clears one a client sent, and so
|
|
20
|
+
* does every tunnel vhost that does not set Gatekeeper's.
|
|
17
21
|
*
|
|
18
22
|
* Each installed vhost is rendered again from state, on the certificate
|
|
19
23
|
* directory the installed file names (a wildcard certificate stays a wildcard
|
|
@@ -23,15 +27,28 @@
|
|
|
23
27
|
* A rewrite never adds or removes authentication: a vhost that disagrees with
|
|
24
28
|
* its state on that is reported and left alone. The one exception is the
|
|
25
29
|
* agent-panel vhost, whose mTLS check gains the panel's `auth_request`.
|
|
30
|
+
*
|
|
31
|
+
* A tunnel bound as plugins' user surface is rendered with the locations of
|
|
32
|
+
* the routes it serves now (`servedUserSurfaceRoutes`: identity declared and
|
|
33
|
+
* approved, and the tunnel's agent hosting the plugin); the same reconcile,
|
|
34
|
+
* limited to the tunnels a route is bound to, follows the plugin's identity
|
|
35
|
+
* through approval, update and uninstall, its hosting, and the tunnels'
|
|
36
|
+
* agents ({@link reconcileUserSurfaceVhosts}). Every location proxying to a
|
|
37
|
+
* tunnel or agent backend keeps Authelia's session cookie from it
|
|
38
|
+
* (`$lamaste_backend_cookie`), so an installed vhost from before that is
|
|
39
|
+
* rewritten too.
|
|
26
40
|
*/
|
|
27
41
|
|
|
42
|
+
import { tunnelUserSurfaceRoutes } from '@lamalibre/lamaste';
|
|
28
43
|
import { withTunnelLock } from '@lamalibre/lamaste/server';
|
|
29
44
|
import {
|
|
45
|
+
PANEL_DOMAIN_VHOST,
|
|
30
46
|
installVhost,
|
|
31
47
|
isSiteEnabled,
|
|
32
48
|
readInstalledVhost,
|
|
33
49
|
renderAgentPanelVhost,
|
|
34
50
|
renderAuthVhost,
|
|
51
|
+
renderPanelVhost,
|
|
35
52
|
renderStaticSiteVhost,
|
|
36
53
|
renderTunnelVhost,
|
|
37
54
|
vhostCertDir,
|
|
@@ -43,15 +60,30 @@ const SITE_NAME_PART_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/;
|
|
|
43
60
|
|
|
44
61
|
const AUTH_VHOST = 'lamalibre-lamaste-auth';
|
|
45
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Which of a tunnel's bound routes its vhost serves now — the panel's
|
|
65
|
+
* plugins and their hostings decide (`lib/user-surface.js`), loaded when
|
|
66
|
+
* first needed so this module stays free of the plugin registry.
|
|
67
|
+
*
|
|
68
|
+
* @param {readonly string[]} routes
|
|
69
|
+
* @param {{ agentLabel?: string }} tunnel
|
|
70
|
+
*/
|
|
71
|
+
async function servedUserSurface(routes, tunnel) {
|
|
72
|
+
const { servedUserSurfaceRoutes } = await import('./user-surface.js');
|
|
73
|
+
return servedUserSurfaceRoutes(routes, tunnel);
|
|
74
|
+
}
|
|
75
|
+
|
|
46
76
|
/**
|
|
47
77
|
* The vhost a tunnel should have, rendered on `certDir`; null for tunnels
|
|
48
78
|
* without one (internal plugin tunnels) or whose state cannot be rendered.
|
|
49
79
|
*
|
|
50
80
|
* @param {object} tunnel
|
|
51
81
|
* @param {string} certDir
|
|
82
|
+
* @param {ReadonlyArray<{ route: string, maxBodySizeMb: number }>} [userSurface] - the user
|
|
83
|
+
* surfaces its vhost serves (`servedUserSurfaceRoutes` of its bound routes)
|
|
52
84
|
* @returns {{ name: string, fqdn: string, config: string } | null}
|
|
53
85
|
*/
|
|
54
|
-
export function expectedTunnelVhost(tunnel, certDir) {
|
|
86
|
+
export function expectedTunnelVhost(tunnel, certDir, userSurface = []) {
|
|
55
87
|
if (!managedTunnelVhostName(tunnel) || typeof tunnel.fqdn !== 'string') return null;
|
|
56
88
|
if (!tunnel.fqdn.startsWith(`${tunnel.subdomain}.`)) return null;
|
|
57
89
|
const domain = tunnel.fqdn.slice(tunnel.subdomain.length + 1);
|
|
@@ -60,18 +92,16 @@ export function expectedTunnelVhost(tunnel, certDir) {
|
|
|
60
92
|
}
|
|
61
93
|
// As createTunnel and updateTunnel write it: the stored access mode
|
|
62
94
|
// (restricted when absent, as Gatekeeper reads it), the plugin route of a
|
|
63
|
-
// plugin tunnel,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
{
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
},
|
|
74
|
-
);
|
|
95
|
+
// plugin tunnel, a body limit only when the tunnel has one, and the user
|
|
96
|
+
// surfaces it serves (never on a public vhost: such state cannot be
|
|
97
|
+
// rendered, and a bound tunnel cannot be made public).
|
|
98
|
+
const accessMode = tunnel.accessMode ?? 'restricted';
|
|
99
|
+
if (accessMode === 'public' && userSurface.length > 0) return null;
|
|
100
|
+
return renderTunnelVhost(accessMode, tunnel.subdomain, domain, tunnel.port, certDir, {
|
|
101
|
+
...(tunnel.type === 'plugin' && tunnel.pluginRoute ? { pathPrefix: tunnel.pluginRoute } : {}),
|
|
102
|
+
...(tunnel.maxBodySizeMb !== undefined ? { maxBodySizeMb: tunnel.maxBodySizeMb } : {}),
|
|
103
|
+
...(userSurface.length > 0 ? { userSurface } : {}),
|
|
104
|
+
});
|
|
75
105
|
}
|
|
76
106
|
|
|
77
107
|
/** Site name of a tunnel's vhost, or null for a tunnel without one. */
|
|
@@ -168,7 +198,45 @@ function logResult(log, what, result) {
|
|
|
168
198
|
}
|
|
169
199
|
}
|
|
170
200
|
|
|
171
|
-
const defaultTunnelDeps = {
|
|
201
|
+
const defaultTunnelDeps = {
|
|
202
|
+
readTunnels,
|
|
203
|
+
readInstalledVhost,
|
|
204
|
+
installVhost,
|
|
205
|
+
isSiteEnabled,
|
|
206
|
+
servedUserSurface,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Reconcile one tunnel's vhost (see {@link reconcileOne}), with the user
|
|
211
|
+
* surfaces it serves now. The caller holds the tunnel lock.
|
|
212
|
+
*/
|
|
213
|
+
async function reconcileTunnel(tunnel, result, deps) {
|
|
214
|
+
const name = managedTunnelVhostName(tunnel);
|
|
215
|
+
if (!name) return;
|
|
216
|
+
const label = tunnel.fqdn ?? tunnel.subdomain;
|
|
217
|
+
const bound = tunnelUserSurfaceRoutes(tunnel);
|
|
218
|
+
let served;
|
|
219
|
+
try {
|
|
220
|
+
served = bound.length > 0 ? await deps.servedUserSurface(bound, tunnel) : [];
|
|
221
|
+
} catch (err) {
|
|
222
|
+
result.failed.push({ vhost: label, error: err instanceof Error ? err.message : String(err) });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
await reconcileOne(
|
|
226
|
+
{
|
|
227
|
+
name,
|
|
228
|
+
label,
|
|
229
|
+
render: (certDir) => expectedTunnelVhost(tunnel, certDir, served),
|
|
230
|
+
// Linked only when it is now and the tunnel is enabled: a rewrite of
|
|
231
|
+
// the text never publishes a vhost someone unlinked, and unlinks a
|
|
232
|
+
// disabled tunnel's.
|
|
233
|
+
enabled: async () => tunnel.enabled !== false && (await deps.isSiteEnabled(name)),
|
|
234
|
+
keepAuthentication: tunnel.type !== 'panel',
|
|
235
|
+
},
|
|
236
|
+
result,
|
|
237
|
+
deps,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
172
240
|
|
|
173
241
|
/**
|
|
174
242
|
* Reconcile the vhost of every tunnel that has one: agent-panel, app and
|
|
@@ -184,34 +252,57 @@ export async function reconcileTunnelVhosts(log, deps = defaultTunnelDeps) {
|
|
|
184
252
|
return withTunnelLock(async () => {
|
|
185
253
|
const result = newResult();
|
|
186
254
|
for (const tunnel of await deps.readTunnels()) {
|
|
187
|
-
|
|
188
|
-
if (!name) continue;
|
|
189
|
-
await reconcileOne(
|
|
190
|
-
{
|
|
191
|
-
name,
|
|
192
|
-
label: tunnel.fqdn ?? tunnel.subdomain,
|
|
193
|
-
render: (certDir) => expectedTunnelVhost(tunnel, certDir),
|
|
194
|
-
// Linked only when it is now and the tunnel is enabled: a rewrite of
|
|
195
|
-
// the text never publishes a vhost someone unlinked, and unlinks a
|
|
196
|
-
// disabled tunnel's.
|
|
197
|
-
enabled: async () => tunnel.enabled !== false && (await deps.isSiteEnabled(name)),
|
|
198
|
-
keepAuthentication: tunnel.type !== 'panel',
|
|
199
|
-
},
|
|
200
|
-
result,
|
|
201
|
-
deps,
|
|
202
|
-
);
|
|
255
|
+
await reconcileTunnel(tunnel, result, deps);
|
|
203
256
|
}
|
|
204
257
|
logResult(log, 'tunnel', result);
|
|
205
258
|
return result;
|
|
206
259
|
});
|
|
207
260
|
}
|
|
208
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Reconcile the vhosts of the tunnels `route` is bound to as a user
|
|
264
|
+
* surface — after a change that decides whether they serve it: the
|
|
265
|
+
* plugin's installation, the approval of its version, an update or its
|
|
266
|
+
* reversal, its hosting. `ids` names tunnels to reconcile besides (the
|
|
267
|
+
* route's binding was just released from them, or their agent was
|
|
268
|
+
* revoked — `route` null then). Under the tunnel lock.
|
|
269
|
+
*
|
|
270
|
+
* @param {Parameters<typeof logResult>[0]} log
|
|
271
|
+
* @param {string | null} route
|
|
272
|
+
* @param {{ ids?: readonly string[] }} [opts]
|
|
273
|
+
* @param {typeof defaultTunnelDeps} [deps]
|
|
274
|
+
* @returns {Promise<ReturnType<typeof newResult>>}
|
|
275
|
+
*/
|
|
276
|
+
export async function reconcileUserSurfaceVhosts(
|
|
277
|
+
log,
|
|
278
|
+
route,
|
|
279
|
+
{ ids = [] } = {},
|
|
280
|
+
deps = defaultTunnelDeps,
|
|
281
|
+
) {
|
|
282
|
+
return withTunnelLock(async () => {
|
|
283
|
+
const result = newResult();
|
|
284
|
+
for (const tunnel of await deps.readTunnels()) {
|
|
285
|
+
if (
|
|
286
|
+
!ids.includes(tunnel.id) &&
|
|
287
|
+
(route === null || !tunnelUserSurfaceRoutes(tunnel).includes(route))
|
|
288
|
+
) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
await reconcileTunnel(tunnel, result, deps);
|
|
292
|
+
}
|
|
293
|
+
logResult(log, 'user-surface tunnel', result);
|
|
294
|
+
return result;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
209
298
|
const defaultSiteDeps = { readSites, readInstalledVhost, installVhost, isSiteEnabled };
|
|
210
299
|
|
|
211
300
|
/**
|
|
212
|
-
* Reconcile the vhosts that
|
|
213
|
-
* (`
|
|
214
|
-
*
|
|
301
|
+
* Reconcile the vhosts that proxy a browser to the panel or ask Authelia
|
|
302
|
+
* directly: the panel's own (`panel.<domain>`), the auth portal
|
|
303
|
+
* (`auth.<domain>`) and every static site. Site changes take no lock, so
|
|
304
|
+
* call it before the panel listens — then nothing can change a site
|
|
305
|
+
* meanwhile.
|
|
215
306
|
*
|
|
216
307
|
* @param {Parameters<typeof logResult>[0]} log
|
|
217
308
|
* @param {string} domain
|
|
@@ -221,6 +312,17 @@ const defaultSiteDeps = { readSites, readInstalledVhost, installVhost, isSiteEna
|
|
|
221
312
|
export async function reconcileSiteVhosts(log, domain, deps = defaultSiteDeps) {
|
|
222
313
|
const result = newResult();
|
|
223
314
|
|
|
315
|
+
await reconcileOne(
|
|
316
|
+
{
|
|
317
|
+
name: PANEL_DOMAIN_VHOST,
|
|
318
|
+
label: `panel.${domain}`,
|
|
319
|
+
render: (certDir) => renderPanelVhost(domain, certDir),
|
|
320
|
+
enabled: () => deps.isSiteEnabled(PANEL_DOMAIN_VHOST),
|
|
321
|
+
},
|
|
322
|
+
result,
|
|
323
|
+
deps,
|
|
324
|
+
);
|
|
325
|
+
|
|
224
326
|
await reconcileOne(
|
|
225
327
|
{
|
|
226
328
|
name: AUTH_VHOST,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fp from 'fastify-plugin';
|
|
2
2
|
import { getConfig } from '../lib/config.js';
|
|
3
|
+
import { findDirectoryUser } from '../lib/directory.js';
|
|
3
4
|
import {
|
|
4
5
|
REFRESH_THRESHOLD,
|
|
5
6
|
validateUserSession,
|
|
@@ -37,6 +38,14 @@ async function userAccessSessionPlugin(fastify, _opts) {
|
|
|
37
38
|
return reply.code(401).send({ error: 'user_session_required' });
|
|
38
39
|
}
|
|
39
40
|
|
|
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.
|
|
44
|
+
const holder = await findDirectoryUser(result.payload.username);
|
|
45
|
+
if (!holder || holder.userId !== result.payload.userId || holder.status !== 'active') {
|
|
46
|
+
return reply.code(401).send({ error: 'user_session_required' });
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
// Set username on request for downstream route handlers
|
|
41
50
|
request.userAccessUsername = result.payload.username;
|
|
42
51
|
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* `/api/<route>/*` for agent-hosted plugins with an agent half — forwarded
|
|
3
3
|
* by the panel to the plugin's agent half over its internal tunnel, through
|
|
4
4
|
* the plugin's approved request hooks (`request:pre-forward` /
|
|
5
|
-
* `request:post-forward`)
|
|
6
|
-
*
|
|
5
|
+
* `request:post-forward`). While its hosting is down nothing is forwarded:
|
|
6
|
+
* its `tunnel:down` fallback answers, or `502` with the reason
|
|
7
|
+
* (`resolveForwardTarget`). See `createHookedForwarder` in
|
|
8
|
+
* `@lamalibre/lamaste/server`.
|
|
7
9
|
*
|
|
8
10
|
* Registered after every other `/api` route: static routes win over its
|
|
9
11
|
* `/:route/*`, so only what nothing else answers arrives here, and a route
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { readInvitations, writeInvitations } from '../lib/state.js';
|
|
3
|
+
import { readInvitations, withInvitationsLock, writeInvitations } from '../lib/state.js';
|
|
4
4
|
import {
|
|
5
5
|
createUserFromInvitation,
|
|
6
6
|
hashPassword,
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
writeTotpToDatabase,
|
|
10
10
|
} from '../lib/authelia.js';
|
|
11
11
|
import { getConfig } from '../lib/config.js';
|
|
12
|
+
import { httpError } from '../lib/app-error.js';
|
|
13
|
+
import { changeUsers, usersFileError } from '../lib/directory.js';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Find an invitation by token using a constant-time comparison.
|
|
@@ -117,15 +119,49 @@ export default async function inviteRoutes(fastify, _opts) {
|
|
|
117
119
|
return reply.code(500).send({ error: 'Failed to process password' });
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
// Through the users lock: the directory gives the user their id before
|
|
123
|
+
// Authelia restarts and lets them sign in.
|
|
120
124
|
try {
|
|
121
|
-
await
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
await changeUsers(
|
|
126
|
+
async () => {
|
|
127
|
+
// An unreadable users file is refused, never written over.
|
|
128
|
+
let current = { users: {} };
|
|
129
|
+
try {
|
|
130
|
+
current = await readUsersRaw();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err.code !== 'ENOENT') {
|
|
133
|
+
throw httpError('Failed to read user database', 500, { cause: usersFileError(err) });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (current.users[invitation.username]) {
|
|
137
|
+
throw httpError('This username already exists', 409);
|
|
138
|
+
}
|
|
139
|
+
await createUserFromInvitation(
|
|
140
|
+
invitation.username,
|
|
141
|
+
invitation.email,
|
|
142
|
+
invitation.groups,
|
|
143
|
+
hashedPassword,
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
actor: 'invitation',
|
|
148
|
+
logger: request.log,
|
|
149
|
+
// A removed user's grants, groups and second factors under the
|
|
150
|
+
// username go first; a username with an OIDC identity is refused.
|
|
151
|
+
creates: [invitation.username],
|
|
152
|
+
expect: (users) => users[invitation.username]?.password === hashedPassword,
|
|
153
|
+
},
|
|
126
154
|
);
|
|
127
155
|
} catch (err) {
|
|
128
|
-
|
|
156
|
+
if (err.statusCode === 409) {
|
|
157
|
+
return reply
|
|
158
|
+
.code(409)
|
|
159
|
+
.send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
|
160
|
+
}
|
|
161
|
+
if (err.statusCode === 503) {
|
|
162
|
+
return reply.code(503).send({ error: err.message, code: err.code });
|
|
163
|
+
}
|
|
164
|
+
request.log.error(err.cause ?? err, 'Failed to create user from invitation');
|
|
129
165
|
return reply.code(500).send({ error: 'Failed to create user account' });
|
|
130
166
|
}
|
|
131
167
|
|
|
@@ -140,12 +176,17 @@ export default async function inviteRoutes(fastify, _opts) {
|
|
|
140
176
|
// Non-fatal: user was created, they can have admin reset TOTP later
|
|
141
177
|
}
|
|
142
178
|
|
|
143
|
-
// Mark invitation as used
|
|
144
|
-
invitation.used = true;
|
|
145
|
-
invitation.acceptedAt = new Date().toISOString();
|
|
146
|
-
|
|
179
|
+
// Mark invitation as used — on the file as it is now, under its lock.
|
|
147
180
|
try {
|
|
148
|
-
await
|
|
181
|
+
await withInvitationsLock(async () => {
|
|
182
|
+
const current = await readInvitations();
|
|
183
|
+
// The token found it (checked above); it names this invitation alone.
|
|
184
|
+
const accepted = current.find((inv) => inv.token === invitation.token);
|
|
185
|
+
if (!accepted) return;
|
|
186
|
+
accepted.used = true;
|
|
187
|
+
accepted.acceptedAt = new Date().toISOString();
|
|
188
|
+
await writeInvitations(current);
|
|
189
|
+
});
|
|
149
190
|
} catch (err) {
|
|
150
191
|
request.log.warn(err, 'Failed to update invitation status (user was created)');
|
|
151
192
|
}
|
|
@@ -8,10 +8,33 @@
|
|
|
8
8
|
* forwarding needed.
|
|
9
9
|
*
|
|
10
10
|
* Admin-only (lamaste-serverd's mTLS + roleGuard protect these routes).
|
|
11
|
+
*
|
|
12
|
+
* Grants and groups decide who is in an agent-hosted plugin's audience
|
|
13
|
+
* (slice 6): a plugin grant whose context names `roles` is an entitlement,
|
|
14
|
+
* checked against the roles the plugin declares and passed on sorted
|
|
15
|
+
* (`checkProxiedGrant`), and every grant or group change that went through
|
|
16
|
+
* recomputes the audiences. A grant or a group membership is given to a
|
|
17
|
+
* username holding the users lock (`withUsernamesForGrants`), so no
|
|
18
|
+
* deletion races it: a username a removed user held and nobody holds now is
|
|
19
|
+
* refused (409 `username_deleted`), and whatever a removed user who held it
|
|
20
|
+
* left in Gatekeeper is cleared first (503 `gatekeeper_unavailable` when it
|
|
21
|
+
* cannot be) — a reused username inherits nothing. The access log is purged
|
|
22
|
+
* for one username by POST, its name in the body (never in a URL, which
|
|
23
|
+
* logs keep).
|
|
11
24
|
*/
|
|
12
25
|
|
|
26
|
+
import { recomputeAudiences } from '../../lib/audience.js';
|
|
27
|
+
import { withUsernamesForGrants } from '../../lib/directory.js';
|
|
28
|
+
import { checkProxiedGrant } from '../../lib/entitlements.js';
|
|
29
|
+
import { actorFromRequest } from '../../lib/actor.js';
|
|
13
30
|
import { gatekeeperRequest } from '../../lib/gatekeeper-client.js';
|
|
14
31
|
|
|
32
|
+
/** Gatekeeper's `path`, with the request's query string when it has one. */
|
|
33
|
+
function withQuery(path, request) {
|
|
34
|
+
const qs = new URLSearchParams(request.query).toString();
|
|
35
|
+
return qs ? `${path}?${qs}` : path;
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
16
39
|
// All gatekeeper management routes are admin-only
|
|
17
40
|
fastify.addHook('preHandler', fastify.requireRole(['admin']));
|
|
@@ -32,6 +55,39 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
32
55
|
};
|
|
33
56
|
};
|
|
34
57
|
|
|
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;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A grant or group change: forwarded, and when Gatekeeper took it, the
|
|
66
|
+
* plugin audiences follow it before the answer goes back.
|
|
67
|
+
*/
|
|
68
|
+
const proxyChange = async (request, method, gatekeeperPath, body) =>
|
|
69
|
+
followAudiences(request, await proxy(method, gatekeeperPath, body));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Give something to `usernames` in Gatekeeper: forwarded holding the
|
|
73
|
+
* users lock, after the usernames' checks (`withUsernamesForGrants`); a
|
|
74
|
+
* refusal of those checks is the answer. The audiences follow.
|
|
75
|
+
*/
|
|
76
|
+
const proxyGive = async (request, usernames, method, gatekeeperPath, body) => {
|
|
77
|
+
let answer;
|
|
78
|
+
try {
|
|
79
|
+
answer = await withUsernamesForGrants(
|
|
80
|
+
usernames,
|
|
81
|
+
{ actor: actorFromRequest(request), logger: request.log },
|
|
82
|
+
() => proxy(method, gatekeeperPath, body),
|
|
83
|
+
);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (!Number.isInteger(err.statusCode)) throw err;
|
|
86
|
+
return { statusCode: err.statusCode, data: { error: err.message, code: err.code } };
|
|
87
|
+
}
|
|
88
|
+
return followAudiences(request, answer);
|
|
89
|
+
};
|
|
90
|
+
|
|
35
91
|
// --- Groups ---
|
|
36
92
|
|
|
37
93
|
fastify.get('/gatekeeper/groups', async (_request, reply) => {
|
|
@@ -40,7 +96,7 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
40
96
|
});
|
|
41
97
|
|
|
42
98
|
fastify.post('/gatekeeper/groups', async (request, reply) => {
|
|
43
|
-
const { statusCode, data } = await
|
|
99
|
+
const { statusCode, data } = await proxyChange(request, 'POST', '/api/groups', request.body);
|
|
44
100
|
return reply.code(statusCode).send(data);
|
|
45
101
|
});
|
|
46
102
|
|
|
@@ -52,7 +108,8 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
52
108
|
|
|
53
109
|
fastify.patch('/gatekeeper/groups/:name', async (request, reply) => {
|
|
54
110
|
const { name } = request.params;
|
|
55
|
-
const { statusCode, data } = await
|
|
111
|
+
const { statusCode, data } = await proxyChange(
|
|
112
|
+
request,
|
|
56
113
|
'PATCH',
|
|
57
114
|
`/api/groups/${encodeURIComponent(name)}`,
|
|
58
115
|
request.body,
|
|
@@ -62,13 +119,22 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
62
119
|
|
|
63
120
|
fastify.delete('/gatekeeper/groups/:name', async (request, reply) => {
|
|
64
121
|
const { name } = request.params;
|
|
65
|
-
const { statusCode, data } = await
|
|
122
|
+
const { statusCode, data } = await proxyChange(
|
|
123
|
+
request,
|
|
124
|
+
'DELETE',
|
|
125
|
+
`/api/groups/${encodeURIComponent(name)}`,
|
|
126
|
+
);
|
|
66
127
|
return reply.code(statusCode).send(data);
|
|
67
128
|
});
|
|
68
129
|
|
|
69
130
|
fastify.post('/gatekeeper/groups/:name/members', async (request, reply) => {
|
|
70
131
|
const { name } = request.params;
|
|
71
|
-
const
|
|
132
|
+
const usernames = Array.isArray(request.body?.usernames)
|
|
133
|
+
? request.body.usernames.filter((u) => typeof u === 'string')
|
|
134
|
+
: [];
|
|
135
|
+
const { statusCode, data } = await proxyGive(
|
|
136
|
+
request,
|
|
137
|
+
usernames,
|
|
72
138
|
'POST',
|
|
73
139
|
`/api/groups/${encodeURIComponent(name)}/members`,
|
|
74
140
|
request.body,
|
|
@@ -78,7 +144,8 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
78
144
|
|
|
79
145
|
fastify.delete('/gatekeeper/groups/:name/members/:username', async (request, reply) => {
|
|
80
146
|
const { name, username } = request.params;
|
|
81
|
-
const { statusCode, data } = await
|
|
147
|
+
const { statusCode, data } = await proxyChange(
|
|
148
|
+
request,
|
|
82
149
|
'DELETE',
|
|
83
150
|
`/api/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(username)}`,
|
|
84
151
|
);
|
|
@@ -88,14 +155,29 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
88
155
|
// --- Grants ---
|
|
89
156
|
|
|
90
157
|
fastify.get('/gatekeeper/grants', async (request, reply) => {
|
|
91
|
-
const
|
|
92
|
-
const grantPath = qs ? `/api/grants?${qs}` : '/api/grants';
|
|
93
|
-
const { statusCode, data } = await proxy('GET', grantPath);
|
|
158
|
+
const { statusCode, data } = await proxy('GET', withQuery('/api/grants', request));
|
|
94
159
|
return reply.code(statusCode).send(data);
|
|
95
160
|
});
|
|
96
161
|
|
|
162
|
+
// A plugin grant with `context.roles` is an entitlement: its roles must be
|
|
163
|
+
// the plugin's (400 otherwise), and go on sorted, without repeats.
|
|
97
164
|
fastify.post('/gatekeeper/grants', async (request, reply) => {
|
|
98
|
-
|
|
165
|
+
let body;
|
|
166
|
+
try {
|
|
167
|
+
body = await checkProxiedGrant(request.body);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (!Number.isInteger(err.statusCode)) throw err;
|
|
170
|
+
return reply.code(err.statusCode).send({
|
|
171
|
+
error: err.message,
|
|
172
|
+
...(err.code ? { code: err.code } : {}),
|
|
173
|
+
...(Array.isArray(err.unknown) ? { unknown: err.unknown } : {}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const usernames =
|
|
177
|
+
body?.principalType === 'user' && typeof body.principalId === 'string'
|
|
178
|
+
? [body.principalId]
|
|
179
|
+
: [];
|
|
180
|
+
const { statusCode, data } = await proxyGive(request, usernames, 'POST', '/api/grants', body);
|
|
99
181
|
return reply.code(statusCode).send(data);
|
|
100
182
|
});
|
|
101
183
|
|
|
@@ -107,7 +189,8 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
107
189
|
|
|
108
190
|
fastify.delete('/gatekeeper/grants/:grantId', async (request, reply) => {
|
|
109
191
|
const { grantId } = request.params;
|
|
110
|
-
const { statusCode, data } = await
|
|
192
|
+
const { statusCode, data } = await proxyChange(
|
|
193
|
+
request,
|
|
111
194
|
'DELETE',
|
|
112
195
|
`/api/grants/${encodeURIComponent(grantId)}`,
|
|
113
196
|
);
|
|
@@ -117,9 +200,11 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
117
200
|
// Bulk-revoke every grant for a principal. Forwarded to gatekeeper as
|
|
118
201
|
// DELETE /api/grants?principalType=...&principalId=...
|
|
119
202
|
fastify.delete('/gatekeeper/grants', async (request, reply) => {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
203
|
+
const { statusCode, data } = await proxyChange(
|
|
204
|
+
request,
|
|
205
|
+
'DELETE',
|
|
206
|
+
withQuery('/api/grants', request),
|
|
207
|
+
);
|
|
123
208
|
return reply.code(statusCode).send(data);
|
|
124
209
|
});
|
|
125
210
|
|
|
@@ -147,14 +232,20 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
147
232
|
});
|
|
148
233
|
|
|
149
234
|
fastify.get('/gatekeeper/access-log', async (request, reply) => {
|
|
150
|
-
const
|
|
151
|
-
const logPath = qs ? `/api/access-log?${qs}` : '/api/access-log';
|
|
152
|
-
const { statusCode, data } = await proxy('GET', logPath);
|
|
235
|
+
const { statusCode, data } = await proxy('GET', withQuery('/api/access-log', request));
|
|
153
236
|
return reply.code(statusCode).send(data);
|
|
154
237
|
});
|
|
155
238
|
|
|
239
|
+
// DELETE clears the whole log. One username's rows (before a time, when
|
|
240
|
+
// given) are purged by POST with `{ username, before? }` in the body — a
|
|
241
|
+
// username in a query string would be in every access log on the way.
|
|
156
242
|
fastify.delete('/gatekeeper/access-log', async (_request, reply) => {
|
|
157
243
|
const { statusCode, data } = await proxy('DELETE', '/api/access-log');
|
|
158
244
|
return reply.code(statusCode).send(data);
|
|
159
245
|
});
|
|
246
|
+
|
|
247
|
+
fastify.post('/gatekeeper/access-log/purge', async (request, reply) => {
|
|
248
|
+
const { statusCode, data } = await proxy('POST', '/api/access-log/purge', request.body);
|
|
249
|
+
return reply.code(statusCode).send(data);
|
|
250
|
+
});
|
|
160
251
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user directory (slice 6), admin only:
|
|
3
|
+
*
|
|
4
|
+
* GET /identity/directory every user the directory knows —
|
|
5
|
+
* tombstones and erased ones too — with
|
|
6
|
+
* the plugins that have not yet
|
|
7
|
+
* acknowledged an erasure (those the
|
|
8
|
+
* feed does not reach now marked)
|
|
9
|
+
* POST /identity/directory/:userId/erase erase a removed user
|
|
10
|
+
*
|
|
11
|
+
* Business logic lives in `lib/directory.js` and `lib/identity-sync.js`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { IDENTITY_UUID_REGEX } from '@lamalibre/lamaste';
|
|
16
|
+
import { actorFromRequest } from '../../lib/actor.js';
|
|
17
|
+
import {
|
|
18
|
+
eraseDirectoryUser,
|
|
19
|
+
getDirectoryId,
|
|
20
|
+
listDirectoryUsers,
|
|
21
|
+
listErasures,
|
|
22
|
+
} from '../../lib/directory.js';
|
|
23
|
+
import { pendingErasuresByUser } from '../../lib/identity-sync.js';
|
|
24
|
+
|
|
25
|
+
const UserIdParamSchema = z.object({
|
|
26
|
+
userId: z.string().regex(IDENTITY_UUID_REGEX, 'userId is a directory user id'),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export default async function identityDirectoryRoutes(fastify, _opts) {
|
|
30
|
+
fastify.get('/identity/directory', { preHandler: fastify.requireRole(['admin']) }, async () => {
|
|
31
|
+
const [directoryId, users, erasures, pending] = await Promise.all([
|
|
32
|
+
getDirectoryId(),
|
|
33
|
+
listDirectoryUsers(),
|
|
34
|
+
listErasures(),
|
|
35
|
+
pendingErasuresByUser(),
|
|
36
|
+
]);
|
|
37
|
+
return {
|
|
38
|
+
directoryId,
|
|
39
|
+
users: users.map((user) => {
|
|
40
|
+
const waiting = pending.get(user.userId) ?? [];
|
|
41
|
+
return {
|
|
42
|
+
...user,
|
|
43
|
+
erasure: erasures.get(user.userId) ?? null,
|
|
44
|
+
pendingErasures: waiting.map((p) => p.plugin),
|
|
45
|
+
// Among them, the plugins the feed does not reach now (uninstalled,
|
|
46
|
+
// or no longer declaring identity) whose data an agent keeps.
|
|
47
|
+
pendingErasuresInactive: waiting.filter((p) => p.inactive).map((p) => p.plugin),
|
|
48
|
+
};
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
fastify.post(
|
|
54
|
+
'/identity/directory/:userId/erase',
|
|
55
|
+
{
|
|
56
|
+
preHandler: fastify.requireRole(['admin']),
|
|
57
|
+
// Moderate tier, as deleting a user: destructive, and it calls Gatekeeper.
|
|
58
|
+
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
|
59
|
+
},
|
|
60
|
+
async (request, reply) => {
|
|
61
|
+
const { userId } = UserIdParamSchema.parse(request.params);
|
|
62
|
+
try {
|
|
63
|
+
const erased = await eraseDirectoryUser(userId, {
|
|
64
|
+
actor: actorFromRequest(request),
|
|
65
|
+
logger: request.log,
|
|
66
|
+
});
|
|
67
|
+
return { ok: true, ...erased };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (Number.isInteger(err.statusCode)) {
|
|
70
|
+
return reply
|
|
71
|
+
.code(err.statusCode)
|
|
72
|
+
.send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
|
73
|
+
}
|
|
74
|
+
request.log.error(err, 'Failed to erase the user');
|
|
75
|
+
return reply.code(500).send({ error: 'Failed to erase the user' });
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
}
|