@lamalibre/create-lamaste 4.4.1 → 4.6.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 (170) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +234 -18
  3. package/src/index.js +1 -0
  4. package/src/lib/backend-cookie.js +65 -0
  5. package/src/lib/ip-panel-vhost.js +208 -0
  6. package/src/lib/service-config.js +5 -3
  7. package/src/tasks/nginx.js +18 -135
  8. package/src/tasks/redeploy.js +39 -1
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +1 -1
  13. package/vendor/docs/01-concepts/authorization.md +66 -48
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/mtls.md +2 -2
  16. package/vendor/docs/01-concepts/security-model.md +70 -33
  17. package/vendor/docs/01-concepts/tickets.md +2 -2
  18. package/vendor/docs/01-concepts/tunneling.md +4 -4
  19. package/vendor/docs/02-guides/agent-setup.md +5 -5
  20. package/vendor/docs/02-guides/certificate-management.md +2 -2
  21. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  22. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  23. package/vendor/docs/02-guides/installation.md +3 -3
  24. package/vendor/docs/02-guides/managing-users.md +113 -25
  25. package/vendor/docs/02-guides/onboarding.md +2 -2
  26. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  27. package/vendor/docs/02-guides/static-sites.md +4 -0
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +3 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +229 -24
  32. package/vendor/docs/03-architecture/overview.md +13 -17
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +3 -2
  35. package/vendor/docs/03-architecture/state-management.md +4 -8
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +378 -34
  38. package/vendor/docs/04-api-reference/app-host.md +3 -3
  39. package/vendor/docs/04-api-reference/gatekeeper.md +78 -5
  40. package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
  41. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  42. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  43. package/vendor/docs/04-api-reference/sites.md +4 -0
  44. package/vendor/docs/04-api-reference/tunnels.md +87 -47
  45. package/vendor/docs/04-api-reference/users.md +241 -45
  46. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  47. package/vendor/docs/05-operations/monitoring.md +39 -0
  48. package/vendor/docs/05-operations/uninstalling.md +2 -1
  49. package/vendor/docs/05-operations/upgrades.md +24 -6
  50. package/vendor/docs/06-reference/config-files.md +100 -202
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  54. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  55. package/vendor/docs/gatekeeper-design.md +10 -8
  56. package/vendor/docs/identity-directory-design.md +48 -14
  57. package/vendor/docs/package.json +1 -1
  58. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  59. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  60. package/vendor/gatekeeper/dist/index.d.ts +4 -3
  61. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/index.js +4 -2
  63. package/vendor/gatekeeper/dist/index.js.map +1 -1
  64. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  65. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  67. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  69. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  70. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  71. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  72. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  73. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  74. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  75. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  76. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  77. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  79. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  81. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  82. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  83. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  84. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +10 -0
  85. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  86. package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
  87. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  88. package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
  89. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  90. package/vendor/gatekeeper/dist/server/index.js +49 -56
  91. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  92. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  93. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  94. package/vendor/gatekeeper/dist/server/routes/authz.js +332 -104
  95. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  96. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  98. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  100. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  101. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  102. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  103. package/vendor/gatekeeper/package.json +2 -2
  104. package/vendor/server/bin/lamaste-server.js +15 -1
  105. package/vendor/server/package.json +2 -2
  106. package/vendor/server/src/commands/plugin-identity.js +335 -0
  107. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  108. package/vendor/server/src/commands/plugins.js +156 -11
  109. package/vendor/server/src/commands/registry-credential.js +2 -1
  110. package/vendor/server/src/commands/sites.js +15 -1
  111. package/vendor/server/src/commands/tunnels.js +102 -2
  112. package/vendor/server/src/commands/uninstall.js +3 -1
  113. package/vendor/server/src/commands/users.js +358 -0
  114. package/vendor/server/src/plugin-approval.js +186 -5
  115. package/vendor/server/src/plugin-identity.js +382 -0
  116. package/vendor/server/src/plugin-web-surface.js +307 -0
  117. package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
  118. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
  119. package/vendor/server-ui/dist/index.html +2 -2
  120. package/vendor/serverd/package.json +2 -2
  121. package/vendor/serverd/src/index.js +34 -5
  122. package/vendor/serverd/src/lib/audience.js +576 -0
  123. package/vendor/serverd/src/lib/authelia.js +78 -7
  124. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  125. package/vendor/serverd/src/lib/chisel.js +10 -2
  126. package/vendor/serverd/src/lib/config.js +68 -43
  127. package/vendor/serverd/src/lib/directory.js +1113 -0
  128. package/vendor/serverd/src/lib/entitlements.js +517 -0
  129. package/vendor/serverd/src/lib/gatekeeper-client.js +226 -10
  130. package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
  131. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  132. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  133. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  134. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  135. package/vendor/serverd/src/lib/mtls.js +6 -2
  136. package/vendor/serverd/src/lib/nginx.js +546 -48
  137. package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
  138. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  139. package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
  140. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  141. package/vendor/serverd/src/lib/plugins.js +95 -7
  142. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  143. package/vendor/serverd/src/lib/request-logging.js +19 -0
  144. package/vendor/serverd/src/lib/retained-data.js +50 -4
  145. package/vendor/serverd/src/lib/state.js +36 -0
  146. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  147. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  148. package/vendor/serverd/src/lib/user-caller.js +67 -0
  149. package/vendor/serverd/src/lib/user-surface.js +754 -0
  150. package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
  151. package/vendor/serverd/src/lib/web-surface.js +766 -0
  152. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  153. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  154. package/vendor/serverd/src/routes/invite.js +53 -12
  155. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  156. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  157. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  158. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  159. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  160. package/vendor/serverd/src/routes/management/plugins.js +2 -0
  161. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  162. package/vendor/serverd/src/routes/management/sites.js +143 -65
  163. package/vendor/serverd/src/routes/management/tunnels.js +109 -22
  164. package/vendor/serverd/src/routes/management/users.js +290 -208
  165. package/vendor/serverd/src/routes/management.js +6 -0
  166. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  167. package/vendor/serverd/src/routes/user-access.js +17 -4
  168. package/vendor/serverd/src/routes/user-surface.js +69 -0
  169. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  170. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
@@ -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`) and, while it is down, its `tunnel:down` fallback.
6
- * See `createHookedForwarder` in `@lamalibre/lamaste/server`.
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 createUserFromInvitation(
122
- invitation.username,
123
- invitation.email,
124
- invitation.groups,
125
- hashedPassword,
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
- request.log.error(err, 'Failed to create user from invitation');
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 writeInvitations(invitations);
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 proxy('POST', '/api/groups', request.body);
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 proxy(
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 proxy('DELETE', `/api/groups/${encodeURIComponent(name)}`);
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 { statusCode, data } = await proxy(
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 proxy(
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 qs = new URLSearchParams(request.query).toString();
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
- const { statusCode, data } = await proxy('POST', '/api/grants', request.body);
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 proxy(
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 qs = new URLSearchParams(request.query).toString();
121
- const grantPath = qs ? `/api/grants?${qs}` : '/api/grants';
122
- const { statusCode, data } = await proxy('DELETE', grantPath);
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 qs = new URLSearchParams(request.query).toString();
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
+ }
@@ -1,20 +1,21 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { z } from 'zod';
3
- import { readInvitations, writeInvitations } from '../../lib/state.js';
3
+ import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
4
+ import { readInvitations, withInvitationsLock, writeInvitations } from '../../lib/state.js';
4
5
  import { readUsersRaw } from '../../lib/authelia.js';
5
6
  import { getConfig } from '../../lib/config.js';
6
7
 
7
8
  const IdParamSchema = z.object({ id: z.string().uuid() });
8
9
 
9
10
  const CreateInvitationSchema = z.object({
11
+ // Same rule as the users API (core's USERNAME_REGEX). A leading "-" would
12
+ // read as a flag to Authelia's CLI when a TOTP secret is stored for the user.
10
13
  username: z
11
14
  .string()
12
- .min(2)
13
- .max(32)
15
+ .min(USERNAME_MIN_LENGTH)
16
+ .max(USERNAME_MAX_LENGTH)
14
17
  .regex(
15
- // Same rule as the users API. A leading "-" would read as a flag to
16
- // Authelia's CLI when a TOTP secret is stored for the user.
17
- /^[a-z0-9][a-z0-9_-]*$/,
18
+ USERNAME_REGEX,
18
19
  'Username must start with a lowercase letter or digit and may contain underscores or hyphens',
19
20
  ),
20
21
  email: z.string().email(),
@@ -67,50 +68,55 @@ export default async function invitationRoutes(fastify, _opts) {
67
68
  return reply.code(409).send({ error: 'Username already exists as an Authelia user' });
68
69
  }
69
70
 
70
- // Check username doesn't already have a pending invitation
71
- let invitations;
72
- try {
73
- invitations = await readInvitations();
74
- } catch (err) {
75
- request.log.error(err, 'Failed to read invitations');
76
- return reply.code(500).send({ error: 'Failed to read invitations' });
77
- }
71
+ // The pending-invitation check and the write hold the invitations
72
+ // lock: nothing else may rewrite the file between them.
73
+ const outcome = await withInvitationsLock(async () => {
74
+ let invitations;
75
+ try {
76
+ invitations = await readInvitations();
77
+ } catch (err) {
78
+ request.log.error(err, 'Failed to read invitations');
79
+ return { status: 500, body: { error: 'Failed to read invitations' } };
80
+ }
78
81
 
79
- const now = new Date();
80
- const existingPending = invitations.find(
81
- (inv) => inv.username === body.username && !inv.used && new Date(inv.expiresAt) > now,
82
- );
83
- if (existingPending) {
84
- return reply
85
- .code(409)
86
- .send({ error: 'A pending invitation already exists for this username' });
87
- }
82
+ const now = new Date();
83
+ const existingPending = invitations.find(
84
+ (inv) => inv.username === body.username && !inv.used && new Date(inv.expiresAt) > now,
85
+ );
86
+ if (existingPending) {
87
+ return {
88
+ status: 409,
89
+ body: { error: 'A pending invitation already exists for this username' },
90
+ };
91
+ }
92
+
93
+ const expiresAt = new Date(now.getTime() + body.expiresInDays * 24 * 60 * 60 * 1000);
94
+ const invitation = {
95
+ id: crypto.randomUUID(),
96
+ token: crypto.randomBytes(32).toString('hex'),
97
+ username: body.username,
98
+ email: body.email,
99
+ groups: body.groups,
100
+ createdAt: now.toISOString(),
101
+ expiresAt: expiresAt.toISOString(),
102
+ used: false,
103
+ acceptedAt: null,
104
+ };
105
+ invitations.push(invitation);
106
+
107
+ try {
108
+ await writeInvitations(invitations);
109
+ } catch (err) {
110
+ request.log.error(err, 'Failed to write invitations');
111
+ return { status: 500, body: { error: 'Failed to save invitation' } };
112
+ }
113
+ return { status: 201, invitation };
114
+ });
115
+ if (outcome.status !== 201) return reply.code(outcome.status).send(outcome.body);
88
116
 
117
+ const { invitation } = outcome;
118
+ const { token } = invitation;
89
119
  const config = getConfig();
90
- const token = crypto.randomBytes(32).toString('hex');
91
- const expiresAt = new Date(now.getTime() + body.expiresInDays * 24 * 60 * 60 * 1000);
92
-
93
- const invitation = {
94
- id: crypto.randomUUID(),
95
- token,
96
- username: body.username,
97
- email: body.email,
98
- groups: body.groups,
99
- createdAt: now.toISOString(),
100
- expiresAt: expiresAt.toISOString(),
101
- used: false,
102
- acceptedAt: null,
103
- };
104
-
105
- invitations.push(invitation);
106
-
107
- try {
108
- await writeInvitations(invitations);
109
- } catch (err) {
110
- request.log.error(err, 'Failed to write invitations');
111
- return reply.code(500).send({ error: 'Failed to save invitation' });
112
- }
113
-
114
120
  const inviteUrl = config.domain ? `https://auth.${config.domain}/invite/${token}` : null;
115
121
 
116
122
  return reply.code(201).send({
@@ -138,27 +144,31 @@ export default async function invitationRoutes(fastify, _opts) {
138
144
  async (request, reply) => {
139
145
  const { id } = IdParamSchema.parse(request.params);
140
146
 
141
- let invitations;
142
- try {
143
- invitations = await readInvitations();
144
- } catch (err) {
145
- request.log.error(err, 'Failed to read invitations');
146
- return reply.code(500).send({ error: 'Failed to read invitations' });
147
- }
148
-
149
- const index = invitations.findIndex((inv) => inv.id === id);
150
- if (index === -1) {
151
- return reply.code(404).send({ error: 'Invitation not found' });
152
- }
153
-
154
- invitations.splice(index, 1);
155
-
156
- try {
157
- await writeInvitations(invitations);
158
- } catch (err) {
159
- request.log.error(err, 'Failed to write invitations');
160
- return reply.code(500).send({ error: 'Failed to revoke invitation' });
161
- }
147
+ const outcome = await withInvitationsLock(async () => {
148
+ let invitations;
149
+ try {
150
+ invitations = await readInvitations();
151
+ } catch (err) {
152
+ request.log.error(err, 'Failed to read invitations');
153
+ return { status: 500, body: { error: 'Failed to read invitations' } };
154
+ }
155
+
156
+ const index = invitations.findIndex((inv) => inv.id === id);
157
+ if (index === -1) {
158
+ return { status: 404, body: { error: 'Invitation not found' } };
159
+ }
160
+
161
+ invitations.splice(index, 1);
162
+
163
+ try {
164
+ await writeInvitations(invitations);
165
+ } catch (err) {
166
+ request.log.error(err, 'Failed to write invitations');
167
+ return { status: 500, body: { error: 'Failed to revoke invitation' } };
168
+ }
169
+ return { status: 200 };
170
+ });
171
+ if (outcome.status !== 200) return reply.code(outcome.status).send(outcome.body);
162
172
 
163
173
  return { ok: true };
164
174
  },