@lamalibre/create-lamaste 4.0.0 → 4.2.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 (66) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +21 -2
  3. package/src/lib/oidc-issuer-key.js +124 -0
  4. package/src/lib/relay-services.js +4 -0
  5. package/src/lib/service-config.js +16 -0
  6. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  7. package/vendor/docs/00-introduction/quickstart.md +5 -5
  8. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  9. package/vendor/docs/01-concepts/authentication.md +45 -1
  10. package/vendor/docs/01-concepts/certificates.md +3 -3
  11. package/vendor/docs/01-concepts/mtls.md +2 -2
  12. package/vendor/docs/01-concepts/security-model.md +1 -1
  13. package/vendor/docs/01-concepts/tickets.md +2 -2
  14. package/vendor/docs/01-concepts/tunneling.md +4 -4
  15. package/vendor/docs/02-guides/agent-setup.md +3 -3
  16. package/vendor/docs/02-guides/certificate-management.md +2 -2
  17. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  18. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  19. package/vendor/docs/02-guides/installation.md +3 -3
  20. package/vendor/docs/02-guides/onboarding.md +2 -2
  21. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  22. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  23. package/vendor/docs/03-architecture/installer.md +6 -5
  24. package/vendor/docs/03-architecture/management-flow.md +1 -1
  25. package/vendor/docs/03-architecture/overview.md +5 -5
  26. package/vendor/docs/03-architecture/panel-client.md +6 -6
  27. package/vendor/docs/03-architecture/panel-server.md +2 -2
  28. package/vendor/docs/03-architecture/state-management.md +2 -2
  29. package/vendor/docs/03-architecture/system-overview.md +1 -1
  30. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  31. package/vendor/docs/04-api-reference/overview.md +111 -105
  32. package/vendor/docs/04-api-reference/registry-and-oidc.md +207 -0
  33. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  34. package/vendor/docs/05-operations/uninstalling.md +1 -1
  35. package/vendor/docs/05-operations/upgrades.md +6 -5
  36. package/vendor/docs/06-reference/config-files.md +103 -45
  37. package/vendor/docs/06-reference/glossary.md +3 -3
  38. package/vendor/docs/06-reference/installer-flags.md +13 -13
  39. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  40. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  41. package/vendor/docs/_index.json +5 -0
  42. package/vendor/docs/gatekeeper-design.md +8 -8
  43. package/vendor/docs/identity-directory-design.md +443 -0
  44. package/vendor/docs/package.json +1 -1
  45. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  46. package/vendor/gatekeeper/package.json +1 -1
  47. package/vendor/server/bin/lamaste-server.js +10 -1
  48. package/vendor/server/package.json +4 -3
  49. package/vendor/server/src/commands/plugins.js +200 -158
  50. package/vendor/server/src/commands/registry-credential.js +142 -0
  51. package/vendor/server/src/panel-api.js +86 -39
  52. package/vendor/server-ui/dist/assets/{index-B0nWuUyL.js → index-B5Wp8lu-.js} +26 -26
  53. package/vendor/server-ui/dist/index.html +1 -1
  54. package/vendor/serverd/package.json +5 -2
  55. package/vendor/serverd/src/index.js +11 -0
  56. package/vendor/serverd/src/lib/authelia.js +11 -0
  57. package/vendor/serverd/src/lib/migrations/state/0005_plugin_hosting.sql +3 -0
  58. package/vendor/serverd/src/lib/plugin-oidc.js +233 -0
  59. package/vendor/serverd/src/lib/plugins.js +37 -5
  60. package/vendor/serverd/src/lib/registry-credential.js +145 -0
  61. package/vendor/serverd/src/routes/management/agents.js +81 -0
  62. package/vendor/serverd/src/routes/management/plugins.js +65 -2
  63. package/vendor/serverd/src/routes/management/registry-credential.js +170 -0
  64. package/vendor/serverd/src/routes/management/storage.js +10 -2
  65. package/vendor/serverd/src/routes/management/users.js +31 -0
  66. package/vendor/serverd/src/routes/management.js +2 -0
@@ -8,6 +8,7 @@ import {
8
8
  getPluginCapabilities,
9
9
  } from '../../lib/plugins.js';
10
10
  import { setPluginCapabilities, loadAgentRegistry } from '../../lib/mtls.js';
11
+ import { listPluginOidcClients, rotatePluginOidcClientSecret } from '../../lib/plugin-oidc.js';
11
12
  import {
12
13
  readPushInstallConfig,
13
14
  readPushInstallSessions,
@@ -31,6 +32,14 @@ const PluginNameParamSchema = z.object({
31
32
  .regex(/^[a-z0-9-]+$/, 'Plugin name must contain only lowercase letters, numbers, and hyphens'),
32
33
  });
33
34
 
35
+ const OidcClientIdParamSchema = z.object({
36
+ clientId: z
37
+ .string()
38
+ .min(1)
39
+ .max(100)
40
+ .regex(/^[a-z0-9][a-z0-9-]*$/, 'Client id must be lowercase letters, numbers and hyphens'),
41
+ });
42
+
34
43
  const InstallBodySchema = z.object({
35
44
  packageName: z
36
45
  .string()
@@ -188,10 +197,12 @@ export default async function pluginRoutes(fastify, _opts) {
188
197
  const body = InstallBodySchema.parse(request.body);
189
198
 
190
199
  try {
191
- const plugin = await installPlugin(body.packageName, request.log);
200
+ // `oidcClients` carries the secrets of confidential OIDC clients this
201
+ // install created — shown once, never stored in the registry row.
202
+ const { oidcClients, ...plugin } = await installPlugin(body.packageName, request.log);
192
203
  // Refresh in-memory capability list so new plugin caps are valid immediately
193
204
  setPluginCapabilities(await getPluginCapabilities());
194
- return { ok: true, plugin };
205
+ return { ok: true, plugin, oidcClients };
195
206
  } catch (err) {
196
207
  const statusCode = err.statusCode || 500;
197
208
  return reply.code(statusCode).send({
@@ -273,6 +284,58 @@ export default async function pluginRoutes(fastify, _opts) {
273
284
  },
274
285
  );
275
286
 
287
+ // ------------------------------------------------------------------
288
+ // GET /plugins/:name/oidc-clients — the OIDC clients a plugin declares
289
+ //
290
+ // Metadata only: a confidential client's secret is shown by the install
291
+ // or enable response that created it, and by rotate-secret — never here.
292
+ // ------------------------------------------------------------------
293
+ fastify.get(
294
+ '/plugins/:name/oidc-clients',
295
+ {
296
+ preHandler: fastify.requireRole(['admin']),
297
+ },
298
+ async (request, reply) => {
299
+ const params = PluginNameParamSchema.parse(request.params);
300
+ const registry = await readPlugins();
301
+ if (!registry.plugins.some((p) => p.name === params.name)) {
302
+ return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
303
+ }
304
+ return { clients: await listPluginOidcClients(params.name) };
305
+ },
306
+ );
307
+
308
+ // ------------------------------------------------------------------
309
+ // POST /plugins/:name/oidc-clients/:clientId/rotate-secret
310
+ //
311
+ // Replaces a confidential client's secret in the store and in Authelia.
312
+ // The new secret is in the response and nowhere else.
313
+ // ------------------------------------------------------------------
314
+ fastify.post(
315
+ '/plugins/:name/oidc-clients/:clientId/rotate-secret',
316
+ {
317
+ preHandler: fastify.requireRole(['admin']),
318
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
319
+ },
320
+ async (request, reply) => {
321
+ const params = PluginNameParamSchema.parse(request.params);
322
+ const { clientId } = OidcClientIdParamSchema.parse(request.params);
323
+ const registry = await readPlugins();
324
+ if (!registry.plugins.some((p) => p.name === params.name)) {
325
+ return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
326
+ }
327
+ try {
328
+ const rotated = await rotatePluginOidcClientSecret(params.name, clientId, request.log);
329
+ return { ok: true, ...rotated };
330
+ } catch (err) {
331
+ const statusCode = err.statusCode || 500;
332
+ return reply.code(statusCode).send({
333
+ error: err.message || 'Failed to rotate the client secret',
334
+ });
335
+ }
336
+ },
337
+ );
338
+
276
339
  // ===========================================================================
277
340
  // Push install routes
278
341
  // ===========================================================================
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Admin management of the panel's registry (Feria) service credential.
3
+ *
4
+ * GET /api/registry-credential what is configured — never a token
5
+ * PUT /api/registry-credential store a credential after verifying the
6
+ * whole chain against the registry
7
+ * (admin scope required)
8
+ * DELETE /api/registry-credential remove it
9
+ *
10
+ * `auth.kind` is `token` (a pasted long-lived Feria token) or `oidc` (the
11
+ * panel's own `lamaste-server` client on Authelia: client-credentials grant,
12
+ * Feria login). For `oidc` the client is written to Authelia before the
13
+ * chain is verified; if verification fails it is removed again, and the
14
+ * error says what Feria's `auth.json` needs.
15
+ *
16
+ * With a credential in place, agents obtain short-lived read tokens through
17
+ * `GET /api/agents/me/registry-credential` for plugin installs.
18
+ */
19
+
20
+ import { z } from 'zod';
21
+ import {
22
+ LAMASTE_SERVER_OIDC_CLIENT_ID,
23
+ RegistryCredentialError,
24
+ summariseRegistryCredential,
25
+ } from '@lamalibre/lamaste/server';
26
+
27
+ import {
28
+ clearRegistryCredentialConfig,
29
+ readRegistryCredentialConfig,
30
+ verifyRegistryCredential,
31
+ writeRegistryCredentialConfig,
32
+ } from '../../lib/registry-credential.js';
33
+ import { syncOidcClients } from '../../lib/plugin-oidc.js';
34
+
35
+ const FeriaTokenSchema = z
36
+ .string()
37
+ .min(20)
38
+ .max(512)
39
+ .regex(/^feria_[A-Za-z0-9_-]+$/, 'token must be a Feria registry token');
40
+
41
+ const AuthSchema = z.discriminatedUnion('kind', [
42
+ z.object({ kind: z.literal('token'), token: FeriaTokenSchema }),
43
+ z.object({ kind: z.literal('oidc') }),
44
+ ]);
45
+
46
+ const PutSchema = z
47
+ .object({
48
+ registryUrl: z.string().url(),
49
+ scope: z
50
+ .string()
51
+ .regex(/^@[a-z0-9][a-z0-9-]*$/)
52
+ .optional(),
53
+ auth: AuthSchema.optional(),
54
+ /** The first release's shape — a token at the top level. */
55
+ token: FeriaTokenSchema.optional(),
56
+ })
57
+ .superRefine((body, ctx) => {
58
+ if (!body.auth && !body.token) {
59
+ ctx.addIssue({
60
+ code: z.ZodIssueCode.custom,
61
+ path: ['auth'],
62
+ message: 'auth is required: { kind: "token", token } or { kind: "oidc" }',
63
+ });
64
+ }
65
+ });
66
+
67
+ /** The stored `auth` for a request body. */
68
+ function authOf(body) {
69
+ if (body.auth?.kind === 'oidc') return { kind: 'oidc', clientId: LAMASTE_SERVER_OIDC_CLIENT_ID };
70
+ if (body.auth?.kind === 'token') return { kind: 'token', token: body.auth.token };
71
+ return { kind: 'token', token: body.token };
72
+ }
73
+
74
+ export default async function registryCredentialRoutes(fastify) {
75
+ fastify.get(
76
+ '/registry-credential',
77
+ { preHandler: fastify.requireRole(['admin']), config: { rateLimit: {} } },
78
+ async () => {
79
+ const config = await readRegistryCredentialConfig();
80
+ if (!config) return { configured: false };
81
+ return { configured: true, ...summariseRegistryCredential(config) };
82
+ },
83
+ );
84
+
85
+ fastify.put(
86
+ '/registry-credential',
87
+ {
88
+ preHandler: fastify.requireRole(['admin']),
89
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
90
+ },
91
+ async (request, reply) => {
92
+ const body = PutSchema.parse(request.body);
93
+ const auth = authOf(body);
94
+ const candidate = { registryUrl: body.registryUrl, auth };
95
+
96
+ // The panel's own client must exist in Authelia (with a secret in the
97
+ // store) before the chain can be exercised.
98
+ if (auth.kind === 'oidc') {
99
+ try {
100
+ const synced = await syncOidcClients(request.log, {
101
+ registryAuth: { registryUrl: body.registryUrl },
102
+ });
103
+ if (!synced) {
104
+ return reply.code(503).send({ error: 'Complete onboarding (set a domain) first' });
105
+ }
106
+ } catch (err) {
107
+ return reply.code(500).send({
108
+ error: `Could not register the ${LAMASTE_SERVER_OIDC_CLIENT_ID} client in Authelia: ${err.message}`,
109
+ });
110
+ }
111
+ }
112
+
113
+ let verified;
114
+ try {
115
+ verified = await verifyRegistryCredential(candidate);
116
+ } catch (err) {
117
+ if (auth.kind === 'oidc') {
118
+ // Leave Authelia as the stored credential says — the candidate is
119
+ // not kept.
120
+ await syncOidcClients(request.log).catch((syncErr) =>
121
+ request.log.warn({ err: syncErr.message }, 'Failed to roll back the OIDC client'),
122
+ );
123
+ }
124
+ const status = err instanceof RegistryCredentialError ? err.statusCode : 502;
125
+ return reply.code(status).send({ error: err.message });
126
+ }
127
+
128
+ const config = {
129
+ provider: 'feria',
130
+ registryUrl: body.registryUrl,
131
+ scope: body.scope ?? '@lamalibre',
132
+ auth,
133
+ owner: verified.owner,
134
+ configuredAt: new Date().toISOString(),
135
+ };
136
+ await writeRegistryCredentialConfig(config);
137
+ // The stored credential now decides whether Authelia carries the client.
138
+ await syncOidcClients(request.log).catch((err) =>
139
+ request.log.warn(
140
+ { err: err.message },
141
+ 'Failed to sync OIDC clients after credential change',
142
+ ),
143
+ );
144
+ request.log.info(
145
+ { registryUrl: body.registryUrl, owner: verified.owner, auth: auth.kind },
146
+ 'Registry credential configured',
147
+ );
148
+ return { configured: true, ...summariseRegistryCredential(config) };
149
+ },
150
+ );
151
+
152
+ fastify.delete(
153
+ '/registry-credential',
154
+ { preHandler: fastify.requireRole(['admin']), config: { rateLimit: {} } },
155
+ async (request) => {
156
+ const removed = await clearRegistryCredentialConfig();
157
+ if (removed) {
158
+ request.log.info('Registry credential removed');
159
+ // Removes the lamaste-server client and its secret if it was in use.
160
+ await syncOidcClients(request.log).catch((err) =>
161
+ request.log.warn(
162
+ { err: err.message },
163
+ 'Failed to sync OIDC clients after credential removal',
164
+ ),
165
+ );
166
+ }
167
+ return { configured: false, removed };
168
+ },
169
+ );
170
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { isAgentHostedPlugin } from '@lamalibre/lamaste';
2
3
  import {
3
4
  registerStorageServer,
4
5
  removeStorageServer,
@@ -118,12 +119,19 @@ export default async function storageRoutes(fastify, _opts) {
118
119
 
119
120
  // Validate plugin exists in registry
120
121
  const registry = await readPlugins();
121
- const pluginExists = registry.plugins.some((p) => p.name === body.pluginName);
122
- if (!pluginExists) {
122
+ const plugin = registry.plugins.find((p) => p.name === body.pluginName);
123
+ if (!plugin) {
123
124
  return reply.code(404).send({
124
125
  error: `Plugin "${body.pluginName}" not found in registry`,
125
126
  });
126
127
  }
128
+ // A binding is handed to the plugin's server code when the panel
129
+ // mounts it; an agent-hosted plugin has none to receive it.
130
+ if (isAgentHostedPlugin(plugin)) {
131
+ return reply.code(400).send({
132
+ error: `Plugin "${body.pluginName}" is agent-hosted — storage binds to server-hosted plugins only`,
133
+ });
134
+ }
127
135
 
128
136
  try {
129
137
  const binding = await bindPluginStorage(body.pluginName, body.storageServerId);
@@ -9,6 +9,7 @@ import {
9
9
  writeTotpToDatabase,
10
10
  } from '../../lib/authelia.js';
11
11
  import { removeGrantsForUser } from '../../lib/user-access.js';
12
+ import { revokeUserRegistryTokens } from '../../lib/registry-credential.js';
12
13
  import { bumpUserEpoch } from '../../lib/config.js';
13
14
  import { gatekeeperRequest } from '../../lib/gatekeeper-client.js';
14
15
 
@@ -156,6 +157,7 @@ export default async function usersRoutes(fastify, _opts) {
156
157
  }
157
158
 
158
159
  const user = usersData.users[username];
160
+ const groupsBefore = Array.isArray(user.groups) ? [...user.groups] : [];
159
161
 
160
162
  if (body.displayname !== undefined) {
161
163
  user.displayname = body.displayname;
@@ -188,6 +190,23 @@ export default async function usersRoutes(fastify, _opts) {
188
190
  request.log.warn(err, 'Failed to reload Authelia after user update');
189
191
  }
190
192
 
193
+ // Registry scopes are granted at login from feria-* groups; losing a
194
+ // group must not leave a valid token behind. Best effort — the user
195
+ // update itself is already done.
196
+ const warnings = [];
197
+ if (body.groups !== undefined) {
198
+ try {
199
+ await revokeUserRegistryTokens(
200
+ username,
201
+ { before: groupsBefore, after: user.groups || [] },
202
+ request.log,
203
+ );
204
+ } catch (err) {
205
+ request.log.warn({ username, err: err.message }, 'Failed to revoke registry tokens');
206
+ warnings.push(`registry tokens were not revoked: ${err.message}`);
207
+ }
208
+ }
209
+
191
210
  return {
192
211
  ok: true,
193
212
  user: {
@@ -196,6 +215,7 @@ export default async function usersRoutes(fastify, _opts) {
196
215
  email: user.email,
197
216
  groups: user.groups || [],
198
217
  },
218
+ ...(warnings.length > 0 ? { warnings } : {}),
199
219
  };
200
220
  },
201
221
  );
@@ -252,6 +272,17 @@ export default async function usersRoutes(fastify, _opts) {
252
272
  // ---------------------------------------------------------------
253
273
  const warnings = [];
254
274
 
275
+ // 0. Registry (Feria) tokens the user holds — their groups are gone.
276
+ try {
277
+ await revokeUserRegistryTokens(username, { deleted: true }, request.log);
278
+ } catch (err) {
279
+ request.log.warn(
280
+ { username, err: err.message },
281
+ 'Cascade: registry token revocation failed',
282
+ );
283
+ warnings.push(`registry tokens were not revoked: ${err.message}`);
284
+ }
285
+
255
286
  // 1. Legacy user-plugin-access.json grants (pre-gatekeeper migration)
256
287
  try {
257
288
  const removed = await removeGrantsForUser(username, request.log);
@@ -14,6 +14,7 @@ import identityRoutes from './management/identity.js';
14
14
  import storageRoutes from './management/storage.js';
15
15
  import gatekeeperProxyRoutes from './management/gatekeeper-proxy.js';
16
16
  import agentsRoutes from './management/agents.js';
17
+ import registryCredentialRoutes from './management/registry-credential.js';
17
18
 
18
19
  export default async function managementRoutes(fastify, _opts) {
19
20
  fastify.addHook('onRequest', managementOnly());
@@ -33,4 +34,5 @@ export default async function managementRoutes(fastify, _opts) {
33
34
  await fastify.register(storageRoutes);
34
35
  await fastify.register(gatekeeperProxyRoutes);
35
36
  await fastify.register(agentsRoutes);
37
+ await fastify.register(registryCredentialRoutes);
36
38
  }