@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.
- package/package.json +2 -2
- package/scripts/lamaste-priv +234 -18
- 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/02-guides/static-sites.md +4 -0
- 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 +229 -24
- 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 +378 -34
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +78 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
- package/vendor/docs/04-api-reference/sites.md +4 -0
- package/vendor/docs/04-api-reference/tunnels.md +87 -47
- 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 +24 -6
- package/vendor/docs/06-reference/config-files.md +100 -202
- 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/cli/index.js +0 -0
- package/vendor/gatekeeper/dist/index.d.ts +4 -3
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +4 -2
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.js +31 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.js +5 -2
- package/vendor/gatekeeper/dist/lib/constants.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 +10 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +49 -56
- 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 +332 -104
- 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/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +156 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/sites.js +15 -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 +186 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server/src/plugin-web-surface.js +307 -0
- package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
- package/vendor/server-ui/dist/assets/index-BvEfCZYS.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 +34 -5
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- 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 +226 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
- 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/migrations/state/0015_plugin_web_surface.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +546 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +95 -7
- package/vendor/serverd/src/lib/registry-credential.js +9 -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 +36 -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 +754 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
- package/vendor/serverd/src/lib/web-surface.js +766 -0
- 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/plugin-web-surface.js +151 -0
- package/vendor/serverd/src/routes/management/plugins.js +2 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/sites.js +143 -65
- package/vendor/serverd/src/routes/management/tunnels.js +109 -22
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +6 -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-roDHz3dM.js +0 -84
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
RESERVED_NAV_LABELS,
|
|
20
20
|
derivePluginRoute,
|
|
21
21
|
hasAgentHalf,
|
|
22
|
+
normalizeIdentityBlock,
|
|
22
23
|
pluginHostCapability,
|
|
23
24
|
validateManifest as coreValidateManifest,
|
|
24
25
|
} from '@lamalibre/lamaste';
|
|
@@ -30,6 +31,7 @@ import { RegistryCredentialError } from '@lamalibre/lamaste/server';
|
|
|
30
31
|
import {
|
|
31
32
|
bareLamalibrePackageName,
|
|
32
33
|
installedHookEntrySha256,
|
|
34
|
+
installedWebTreeSha256,
|
|
33
35
|
pinAgentHalf,
|
|
34
36
|
} from './plugin-packages.js';
|
|
35
37
|
|
|
@@ -71,10 +73,13 @@ async function getStmts() {
|
|
|
71
73
|
INSERT INTO plugins
|
|
72
74
|
(name, route, display_name, package_name, version, description, capabilities,
|
|
73
75
|
packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
|
|
74
|
-
agent_half_pin, settings,
|
|
75
|
-
|
|
76
|
+
agent_half_pin, settings, identity, user_surface, web_tree_sha256, status,
|
|
77
|
+
installed_at, enabled_at)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
76
79
|
`),
|
|
77
80
|
deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
|
|
81
|
+
// The plugin's audience and the state of its identity feed
|
|
82
|
+
// (lib/audience.js, lib/identity-sync.js) go with its row.
|
|
78
83
|
// The relay's cache of the plugin's settings (lib/plugin-settings.js)
|
|
79
84
|
// goes with its row, and follows its settings schema.
|
|
80
85
|
deleteSettingsByName: db.prepare('DELETE FROM plugin_settings WHERE plugin = ?'),
|
|
@@ -137,6 +142,9 @@ function rowToPlugin(row) {
|
|
|
137
142
|
if (row.hooks_entry_sha256 != null) entry.hooksEntrySha256 = row.hooks_entry_sha256;
|
|
138
143
|
if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
|
|
139
144
|
if (row.settings != null) entry.settings = JSON.parse(row.settings);
|
|
145
|
+
if (row.identity != null) entry.identity = JSON.parse(row.identity);
|
|
146
|
+
if (row.user_surface != null) entry.userSurface = JSON.parse(row.user_surface);
|
|
147
|
+
if (row.web_tree_sha256 != null) entry.webTreeSha256 = row.web_tree_sha256;
|
|
140
148
|
if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
|
|
141
149
|
return entry;
|
|
142
150
|
}
|
|
@@ -149,6 +157,29 @@ function nullableJson(value) {
|
|
|
149
157
|
return value === undefined || value === null ? null : JSON.stringify(value);
|
|
150
158
|
}
|
|
151
159
|
|
|
160
|
+
/**
|
|
161
|
+
* A manifest's `identity` block as the registry stores it — normalized
|
|
162
|
+
* (core `normalizeIdentityBlock`: roles by id, attributes sorted, the
|
|
163
|
+
* default applied) — or undefined when it declares none. The stored block
|
|
164
|
+
* enters the approval digest.
|
|
165
|
+
*
|
|
166
|
+
* @param {{ identity?: import('@lamalibre/lamaste').IdentityBlockInput }} manifest
|
|
167
|
+
* @returns {import('@lamalibre/lamaste').NormalizedIdentityBlock | undefined}
|
|
168
|
+
*/
|
|
169
|
+
export function identityOf(manifest) {
|
|
170
|
+
return manifest.identity ? normalizeIdentityBlock(manifest.identity) : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Recompute the plugin audiences (lib/audience.js) beside other work — never throws. */
|
|
174
|
+
async function recomputeAudiencesInBackground(logger) {
|
|
175
|
+
try {
|
|
176
|
+
const { recomputeAudiences } = await import('./audience.js');
|
|
177
|
+
void recomputeAudiences({ logger });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
logger?.warn({ err: err.message }, 'Could not recompute the plugin audiences');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
152
183
|
// --- Plugin registry persistence ---
|
|
153
184
|
|
|
154
185
|
/**
|
|
@@ -196,6 +227,9 @@ export async function writePlugins(data) {
|
|
|
196
227
|
nullableString(p.hooksEntrySha256),
|
|
197
228
|
nullableJson(p.agentHalfPin),
|
|
198
229
|
nullableJson(p.settings),
|
|
230
|
+
nullableJson(p.identity),
|
|
231
|
+
nullableJson(p.userSurface),
|
|
232
|
+
nullableString(p.webTreeSha256),
|
|
199
233
|
p.status,
|
|
200
234
|
p.installedAt,
|
|
201
235
|
nullableString(p.enabledAt),
|
|
@@ -429,6 +463,17 @@ export function installPlugin(packageName, logger) {
|
|
|
429
463
|
throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
|
|
430
464
|
}
|
|
431
465
|
|
|
466
|
+
// A web surface: its tree must be in the package and keep every rule of
|
|
467
|
+
// a web tree; its digest goes into the approval digest, and the relay
|
|
468
|
+
// serves only a copy that matches it.
|
|
469
|
+
let webTreeSha256 = null;
|
|
470
|
+
try {
|
|
471
|
+
webTreeSha256 = await installedWebTreeSha256(packageName, manifest);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
await discardPackage();
|
|
474
|
+
throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
|
|
475
|
+
}
|
|
476
|
+
|
|
432
477
|
// An agent half Lamaste installs: its tarball's integrity is pinned now,
|
|
433
478
|
// from the registry this install used — the approval covers it and the
|
|
434
479
|
// agent installs only a tarball that matches it.
|
|
@@ -479,6 +524,9 @@ export function installPlugin(packageName, logger) {
|
|
|
479
524
|
hooksEntrySha256,
|
|
480
525
|
agentHalfPin,
|
|
481
526
|
settings: manifest.settings,
|
|
527
|
+
identity: identityOf(manifest),
|
|
528
|
+
userSurface: manifest.userSurface,
|
|
529
|
+
webTreeSha256,
|
|
482
530
|
status: 'disabled',
|
|
483
531
|
installedAt: new Date().toISOString(),
|
|
484
532
|
};
|
|
@@ -506,6 +554,9 @@ export function installPlugin(packageName, logger) {
|
|
|
506
554
|
nullableString(entry.hooksEntrySha256),
|
|
507
555
|
nullableJson(entry.agentHalfPin),
|
|
508
556
|
nullableJson(entry.settings),
|
|
557
|
+
nullableJson(entry.identity),
|
|
558
|
+
nullableJson(entry.userSurface),
|
|
559
|
+
nullableString(entry.webTreeSha256),
|
|
509
560
|
entry.status,
|
|
510
561
|
entry.installedAt,
|
|
511
562
|
null,
|
|
@@ -518,6 +569,13 @@ export function installPlugin(packageName, logger) {
|
|
|
518
569
|
|
|
519
570
|
logger.info({ name: manifest.name, packageName }, 'Plugin installed');
|
|
520
571
|
const oidcClients = await syncOidcClientsBestEffort(logger);
|
|
572
|
+
// Users already entitled to its route (grants that outlived an earlier
|
|
573
|
+
// installation) make up its audience from the start.
|
|
574
|
+
if (entry.identity) await recomputeAudiencesInBackground(logger);
|
|
575
|
+
// A new installation is served on no host (its version is not approved
|
|
576
|
+
// yet): a vhost still carrying the route from before goes in line.
|
|
577
|
+
const { refreshUserSurfaceVhosts } = await import('./user-surface.js');
|
|
578
|
+
await refreshUserSurfaceVhosts(entry.route, logger);
|
|
521
579
|
return { ...entry, oidcClients };
|
|
522
580
|
});
|
|
523
581
|
}
|
|
@@ -591,6 +649,17 @@ export function uninstallPlugin(name, logger) {
|
|
|
591
649
|
throw err;
|
|
592
650
|
}
|
|
593
651
|
|
|
652
|
+
// Its identity ledger — everyone its agent halves were sent, and the
|
|
653
|
+
// erasures it owes — stays while an agent keeps its data (an uninstall
|
|
654
|
+
// without purge); with no data left anywhere it goes. A later install
|
|
655
|
+
// replays whatever is left of it at its first hosting.
|
|
656
|
+
try {
|
|
657
|
+
const { dropIdentityLedgerIfNoData } = await import('./audience.js');
|
|
658
|
+
await dropIdentityLedgerIfNoData(name, logger);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
logger.warn({ name, err: err.message }, 'Could not settle the identity ledger on uninstall');
|
|
661
|
+
}
|
|
662
|
+
|
|
594
663
|
// Sweep this plugin's capabilities off every agent so re-installing the
|
|
595
664
|
// plugin later does NOT silently re-grant. Failure here is non-fatal
|
|
596
665
|
// (the plugin is already gone), but logged loudly. A plugin without a
|
|
@@ -619,6 +688,11 @@ export function uninstallPlugin(name, logger) {
|
|
|
619
688
|
);
|
|
620
689
|
}
|
|
621
690
|
|
|
691
|
+
// Its user surface is unbound from every host, so a later install
|
|
692
|
+
// starts unbound (a plugin sharing another's route never held one).
|
|
693
|
+
const { releasePluginUserSurface } = await import('./user-surface.js');
|
|
694
|
+
await releasePluginUserSurface(plugin.route, { plugin: name, logger });
|
|
695
|
+
|
|
622
696
|
logger.info({ name }, 'Plugin uninstalled');
|
|
623
697
|
await syncOidcClientsBestEffort(logger);
|
|
624
698
|
return { ok: true, name };
|
|
@@ -631,7 +705,7 @@ const updateRowSql = `
|
|
|
631
705
|
UPDATE plugins SET
|
|
632
706
|
display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
|
|
633
707
|
config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
|
|
634
|
-
agent_half_pin = ?, settings = ?
|
|
708
|
+
agent_half_pin = ?, settings = ?, identity = ?, user_surface = ?, web_tree_sha256 = ?
|
|
635
709
|
WHERE name = ?`;
|
|
636
710
|
|
|
637
711
|
/**
|
|
@@ -643,9 +717,18 @@ const updateRowSql = `
|
|
|
643
717
|
* (`prunedSettingsCache`) — so no read pairs the new schema with values
|
|
644
718
|
* stored for the old one: a value whose field became a secret never shows
|
|
645
719
|
* beside it, and a secret whose field became a plain value is no longer
|
|
646
|
-
* reported set.
|
|
720
|
+
* reported set. The identity block is stored normalized; the update's
|
|
721
|
+
* caller recomputes the audiences once the version is in place.
|
|
722
|
+
* `webTreeSha256` is the digest of the version's web tree, read from the
|
|
723
|
+
* installed package (null when it declares no web surface).
|
|
647
724
|
*/
|
|
648
|
-
export async function writePluginRow(
|
|
725
|
+
export async function writePluginRow(
|
|
726
|
+
name,
|
|
727
|
+
manifest,
|
|
728
|
+
hooksEntrySha256,
|
|
729
|
+
agentHalfPin,
|
|
730
|
+
webTreeSha256 = null,
|
|
731
|
+
) {
|
|
649
732
|
const s = await getStmts();
|
|
650
733
|
const { prunedSettingsCache } = await import('./plugin-settings.js');
|
|
651
734
|
s.begin.run();
|
|
@@ -668,6 +751,9 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
|
|
|
668
751
|
nullableString(hooksEntrySha256),
|
|
669
752
|
nullableJson(agentHalfPin),
|
|
670
753
|
nullableJson(manifest.settings),
|
|
754
|
+
nullableJson(identityOf(manifest)),
|
|
755
|
+
nullableJson(manifest.userSurface),
|
|
756
|
+
nullableString(webTreeSha256),
|
|
671
757
|
name,
|
|
672
758
|
);
|
|
673
759
|
const pruned = prunedSettingsCache(
|
|
@@ -695,7 +781,8 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
|
|
|
695
781
|
|
|
696
782
|
/**
|
|
697
783
|
* Install `<packageName>@<version>` over the installed copy and read it
|
|
698
|
-
* back: the validated manifest
|
|
784
|
+
* back: the validated manifest, its relay entry module's SHA-256 and its
|
|
785
|
+
* web tree's digest. The
|
|
699
786
|
* row is not touched — the caller compares the result with what was
|
|
700
787
|
* reviewed and then writes it. Refuses a package whose manifest names
|
|
701
788
|
* another plugin.
|
|
@@ -735,7 +822,8 @@ export function replacePluginPackage(name, packageName, version, logger) {
|
|
|
735
822
|
);
|
|
736
823
|
}
|
|
737
824
|
const hooksEntrySha256 = await installedHookEntrySha256(packageName, manifest);
|
|
738
|
-
|
|
825
|
+
const webTreeSha256 = await installedWebTreeSha256(packageName, manifest);
|
|
826
|
+
return { manifest, hooksEntrySha256, webTreeSha256 };
|
|
739
827
|
});
|
|
740
828
|
}
|
|
741
829
|
|
|
@@ -96,7 +96,7 @@ async function oidcDeps(config) {
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Verify a candidate credential end to end (for `oidc`: Authelia token
|
|
99
|
-
* endpoint → Feria login → whoami with the admin
|
|
99
|
+
* endpoint → Feria login → whoami with the read and admin scopes). The
|
|
100
100
|
* `lamaste-server` client must already be in Authelia — the route syncs it
|
|
101
101
|
* first.
|
|
102
102
|
* @param {{ registryUrl: string, auth: { kind: 'token', token: string } | { kind: 'oidc', clientId: string } }} candidate
|
|
@@ -158,14 +158,17 @@ export async function acquireAgentToken(agentLabel) {
|
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
160
|
* Revoke a user's registry tokens after their `feria-*` groups changed or
|
|
161
|
-
* they were deleted
|
|
162
|
-
*
|
|
161
|
+
* they were offboarded (`{ deleted: true }`: disabled or removed). Returns
|
|
162
|
+
* the number revoked, or `null` when nothing needed doing (no credential
|
|
163
|
+
* configured, or no feria group lost). The registry knows the user by
|
|
164
|
+
* username (`user:<username>`); the log line names them by `userId` alone.
|
|
163
165
|
*
|
|
164
166
|
* @param {string} username
|
|
165
167
|
* @param {{ before: string[], after: string[] } | { deleted: true }} change
|
|
166
|
-
* @param {import('pino').Logger} log
|
|
168
|
+
* @param {import('pino').Logger | undefined} log
|
|
169
|
+
* @param {{ userId?: string | null }} [who]
|
|
167
170
|
*/
|
|
168
|
-
export async function revokeUserRegistryTokens(username, change, log) {
|
|
171
|
+
export async function revokeUserRegistryTokens(username, change, log, { userId = null } = {}) {
|
|
169
172
|
const config = await readRegistryCredentialConfig();
|
|
170
173
|
if (!config) return null;
|
|
171
174
|
|
|
@@ -177,6 +180,6 @@ export async function revokeUserRegistryTokens(username, change, log) {
|
|
|
177
180
|
const revoked = await revokeRegistryTokensForOwner(config, owner, {
|
|
178
181
|
serviceToken: await serviceToken(config),
|
|
179
182
|
});
|
|
180
|
-
log
|
|
183
|
+
log?.info({ userId, lost, revoked }, 'Revoked a user’s registry tokens');
|
|
181
184
|
return revoked;
|
|
182
185
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the panel logs its requests: one metadata line per request (core
|
|
3
|
+
* `logRequestMetadata`) instead of Fastify's own lines, which carry every
|
|
4
|
+
* URL with its query and the client address. The line has the method, the
|
|
5
|
+
* route pattern (never the URL a username or a query may be in), the
|
|
6
|
+
* status, the duration and the caller's certificate role; the client
|
|
7
|
+
* address only for certificate holders — administrators and agents, not
|
|
8
|
+
* people: never for a user of a plugin's user surface, the desktop's
|
|
9
|
+
* user-access calls or the public routes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Callers whose address a request line may carry: certificate holders, not people. */
|
|
13
|
+
const CERT_ROLES_LOGGED_WITH_IP = new Set(['admin', 'agent', 'plugin-agent']);
|
|
14
|
+
|
|
15
|
+
/** The options the panel passes to `logRequestMetadata`. */
|
|
16
|
+
export const panelRequestLogOptions = Object.freeze({
|
|
17
|
+
fields: (request) => ({ certRole: request.certRole ?? null }),
|
|
18
|
+
logIp: (request) => CERT_ROLES_LOGGED_WITH_IP.has(request.certRole),
|
|
19
|
+
});
|
|
@@ -38,18 +38,64 @@ function rowToRecord(row) {
|
|
|
38
38
|
* agent package `pkg` of plugin `plugin`. A record that exists already keeps
|
|
39
39
|
* its `since` — the data has been there since then.
|
|
40
40
|
*
|
|
41
|
+
* With it goes how far the agent half's identity projection — part of that
|
|
42
|
+
* data — had applied the plugin's feed, as the half last answered (the
|
|
43
|
+
* plugin's feed state, read now, before the hosting's is forgotten): the
|
|
44
|
+
* erasures of the plugin's ledger after that cursor are pending on this
|
|
45
|
+
* agent (`pendingErasures` in `lib/identity-sync.js`). When the relay knows
|
|
46
|
+
* no position, a record that exists keeps the one it had — the projection
|
|
47
|
+
* did not move without a feed.
|
|
48
|
+
*
|
|
41
49
|
* @param {{ agentLabel: string, route: string, plugin: string, package: string, operation?: string | null }} record
|
|
42
50
|
*/
|
|
43
51
|
export async function recordRetainedData({ agentLabel, route, plugin, package: pkg, operation }) {
|
|
44
52
|
const db = await getStateDb();
|
|
53
|
+
const feed = db
|
|
54
|
+
.prepare('SELECT directory_id, cursor FROM plugin_identity_sync WHERE plugin = ?')
|
|
55
|
+
.get(plugin);
|
|
56
|
+
const known = feed && feed.directory_id !== null && feed.cursor !== null;
|
|
45
57
|
db.prepare(
|
|
46
|
-
`INSERT INTO retained_agent_data
|
|
47
|
-
|
|
58
|
+
`INSERT INTO retained_agent_data
|
|
59
|
+
(agent_label, route, plugin, package, since, operation, identity_directory_id, identity_cursor)
|
|
60
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
48
61
|
ON CONFLICT(agent_label, route) DO UPDATE SET
|
|
49
62
|
plugin = excluded.plugin,
|
|
50
63
|
package = excluded.package,
|
|
51
|
-
operation = excluded.operation
|
|
52
|
-
|
|
64
|
+
operation = excluded.operation,
|
|
65
|
+
identity_directory_id = CASE WHEN excluded.identity_cursor IS NULL
|
|
66
|
+
THEN identity_directory_id ELSE excluded.identity_directory_id END,
|
|
67
|
+
identity_cursor = COALESCE(excluded.identity_cursor, identity_cursor)`,
|
|
68
|
+
).run(
|
|
69
|
+
agentLabel,
|
|
70
|
+
route,
|
|
71
|
+
plugin,
|
|
72
|
+
pkg,
|
|
73
|
+
new Date().toISOString(),
|
|
74
|
+
operation ?? null,
|
|
75
|
+
known ? feed.directory_id : null,
|
|
76
|
+
known ? Number(feed.cursor) : null,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Where the identity projection in each record's data stands, for plugin
|
|
82
|
+
* `plugin`: `[{ agentLabel, directoryId, cursor }]` (both null when unknown).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} plugin
|
|
85
|
+
*/
|
|
86
|
+
export async function retainedIdentityPositions(plugin) {
|
|
87
|
+
const db = await getStateDb();
|
|
88
|
+
return db
|
|
89
|
+
.prepare(
|
|
90
|
+
`SELECT agent_label, identity_directory_id, identity_cursor FROM retained_agent_data
|
|
91
|
+
WHERE plugin = ? ORDER BY since, agent_label`,
|
|
92
|
+
)
|
|
93
|
+
.all(plugin)
|
|
94
|
+
.map((row) => ({
|
|
95
|
+
agentLabel: row.agent_label,
|
|
96
|
+
directoryId: row.identity_directory_id,
|
|
97
|
+
cursor: row.identity_cursor === null ? null : Number(row.identity_cursor),
|
|
98
|
+
}));
|
|
53
99
|
}
|
|
54
100
|
|
|
55
101
|
/** Drop the record of the data agent `agentLabel` keeps for route `route` (purged, or taken back). */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, writeFile, rename, open } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { PromiseChainMutex } from '@lamalibre/lamaste';
|
|
3
4
|
|
|
4
5
|
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
5
6
|
|
|
@@ -94,6 +95,24 @@ export async function writeSites(sites) {
|
|
|
94
95
|
await rename(tmpPath, filePath);
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/** Serializes every read-modify-write of sites.json in this process. */
|
|
99
|
+
const sitesMutex = new PromiseChainMutex();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run `fn` holding the sites lock: a read of sites.json, the decisions made
|
|
103
|
+
* on it and the write that follows must not interleave with another. A
|
|
104
|
+
* workflow that gives a site a hostname (creating it, adding an alias,
|
|
105
|
+
* taking a tunnel's host) takes core's tunnel lock first, then this one —
|
|
106
|
+
* always in that order — so a site and a tunnel never take one name at once.
|
|
107
|
+
*
|
|
108
|
+
* @template T
|
|
109
|
+
* @param {() => Promise<T>} fn
|
|
110
|
+
* @returns {Promise<T>}
|
|
111
|
+
*/
|
|
112
|
+
export function withSitesLock(fn) {
|
|
113
|
+
return sitesMutex.run(fn);
|
|
114
|
+
}
|
|
115
|
+
|
|
97
116
|
function invitationsPath() {
|
|
98
117
|
return path.join(STATE_DIR, 'invitations.json');
|
|
99
118
|
}
|
|
@@ -138,3 +157,20 @@ export async function writeInvitations(invitations) {
|
|
|
138
157
|
|
|
139
158
|
await rename(tmpPath, filePath);
|
|
140
159
|
}
|
|
160
|
+
|
|
161
|
+
/** Serializes every read-modify-write of invitations.json in this process. */
|
|
162
|
+
const invitationsMutex = new PromiseChainMutex();
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Run `fn` holding the invitations lock: a read of invitations.json, the
|
|
166
|
+
* decisions made on it and the write that follows must not interleave with
|
|
167
|
+
* another — creating, revoking, accepting an invitation, and purging a
|
|
168
|
+
* removed user's.
|
|
169
|
+
*
|
|
170
|
+
* @template T
|
|
171
|
+
* @param {() => Promise<T>} fn
|
|
172
|
+
* @returns {Promise<T>}
|
|
173
|
+
*/
|
|
174
|
+
export function withInvitationsLock(fn) {
|
|
175
|
+
return invitationsMutex.run(fn);
|
|
176
|
+
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* Whenever the plugin comes up — after a `down`, or first seen up — the
|
|
13
13
|
* relay also re-reads its settings from the agent half: they may have
|
|
14
14
|
* changed while the relay could not see it (a restore, a retyped field
|
|
15
|
-
* dropped by a new version)
|
|
15
|
+
* dropped by a new version) — and, for a plugin that declares identity,
|
|
16
|
+
* sends its agent half the audience changes it missed while it was down.
|
|
16
17
|
*
|
|
17
18
|
* Calls for one plugin are serialised and run beside the monitor, which
|
|
18
19
|
* does not wait for them.
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
import { runRelayHook } from './relay-hooks.js';
|
|
28
29
|
import { rememberTunnelFallback } from './request-hooks.js';
|
|
29
30
|
import { refreshPluginSettingsBestEffort } from './plugin-settings.js';
|
|
31
|
+
import { syncPluginIdentity } from './identity-sync.js';
|
|
30
32
|
|
|
31
33
|
const chains = new Map();
|
|
32
34
|
|
|
@@ -44,6 +46,7 @@ export function onHealthTransition(plugin, before, after, logger) {
|
|
|
44
46
|
.then(async () => {
|
|
45
47
|
if (after.state === 'up' && before?.state !== 'up') {
|
|
46
48
|
await refreshPluginSettingsBestEffort(plugin, logger);
|
|
49
|
+
await syncPluginIdentity(plugin, { logger });
|
|
47
50
|
}
|
|
48
51
|
});
|
|
49
52
|
chains.set(plugin, next);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import { isIdentityUuid } from '@lamalibre/lamaste';
|
|
2
3
|
|
|
3
4
|
const SESSION_MAX_AGE = 12 * 60 * 60; // 12 hours in seconds
|
|
4
5
|
const INACTIVITY_TIMEOUT = 2 * 60 * 60; // 2 hours in seconds
|
|
@@ -21,19 +22,23 @@ function signPayload(payloadB64, secretHex) {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
* Create a signed user-access session token
|
|
25
|
+
* Create a signed user-access session token, bound to the user's directory
|
|
26
|
+
* id as well as their username: a later user given the same username never
|
|
27
|
+
* holds this session (the middleware checks the username's current holder).
|
|
25
28
|
*
|
|
26
29
|
* @param {string} sessionSecret - Hex-encoded session secret
|
|
27
|
-
* @param {string}
|
|
30
|
+
* @param {{ username: string, userId: string }} user - the Authelia username and its directory id
|
|
28
31
|
* @returns {{ value: string, maxAge: number, expiresAt: string }}
|
|
29
32
|
*/
|
|
30
|
-
export function createUserSession(sessionSecret, username) {
|
|
33
|
+
export function createUserSession(sessionSecret, { username, userId }) {
|
|
34
|
+
if (!isIdentityUuid(userId)) throw new Error('A user session names a directory user id');
|
|
31
35
|
const now = Math.floor(Date.now() / 1000);
|
|
32
36
|
const payload = {
|
|
33
37
|
iat: now,
|
|
34
38
|
exp: now + SESSION_MAX_AGE,
|
|
35
39
|
lastActivity: now,
|
|
36
40
|
username,
|
|
41
|
+
userId,
|
|
37
42
|
type: 'user-access',
|
|
38
43
|
};
|
|
39
44
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
@@ -52,10 +57,12 @@ export function createUserSession(sessionSecret, username) {
|
|
|
52
57
|
* @param {string} tokenValue - The token (payload.sig)
|
|
53
58
|
* @param {string} sessionSecret - Hex-encoded session secret
|
|
54
59
|
* @param {{ userEpochs?: Record<string, number> }} [options]
|
|
55
|
-
* userEpochs maps
|
|
56
|
-
* is older than
|
|
57
|
-
* This is how
|
|
58
|
-
* HMAC signature is otherwise still valid.
|
|
60
|
+
* userEpochs maps a directory user id → Unix-second epoch. If the
|
|
61
|
+
* session's `iat` is older than its user's epoch, the session is rejected
|
|
62
|
+
* as revoked. This is how offboarding (disable, delete) invalidates live
|
|
63
|
+
* sessions whose HMAC signature is otherwise still valid. A session
|
|
64
|
+
* without a user id (issued before 4.5) is rejected: it could not be
|
|
65
|
+
* told from a later user's.
|
|
59
66
|
* @returns {{ valid: boolean, payload?: object, reason?: string }}
|
|
60
67
|
*/
|
|
61
68
|
export function validateUserSession(tokenValue, sessionSecret, options = {}) {
|
|
@@ -96,6 +103,10 @@ export function validateUserSession(tokenValue, sessionSecret, options = {}) {
|
|
|
96
103
|
return { valid: false, reason: 'missing_username' };
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
if (!isIdentityUuid(payload.userId)) {
|
|
107
|
+
return { valid: false, reason: 'missing_user_id' };
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
const now = Math.floor(Date.now() / 1000);
|
|
100
111
|
|
|
101
112
|
if (payload.exp && now > payload.exp) {
|
|
@@ -106,12 +117,11 @@ export function validateUserSession(tokenValue, sessionSecret, options = {}) {
|
|
|
106
117
|
return { valid: false, reason: 'inactive' };
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
// Per-user invalidation epoch (set when a user is
|
|
110
|
-
//
|
|
111
|
-
// longer exists, even if a same-named user is later re-created.
|
|
120
|
+
// Per-user invalidation epoch (set when a user is offboarded). Reject
|
|
121
|
+
// sessions issued before it.
|
|
112
122
|
const userEpochs = options.userEpochs;
|
|
113
123
|
if (userEpochs && typeof userEpochs === 'object') {
|
|
114
|
-
const epoch = userEpochs[payload.
|
|
124
|
+
const epoch = userEpochs[payload.userId];
|
|
115
125
|
if (typeof epoch === 'number' && typeof payload.iat === 'number' && payload.iat < epoch) {
|
|
116
126
|
return { valid: false, reason: 'revoked' };
|
|
117
127
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who a person on a plugin's user surface is to the plugin — what the
|
|
3
|
+
* panel's user-surface context (core `userSurfacePlugin`,
|
|
4
|
+
* `resolveUserCaller`) signs into the caller assertion it forwards to the
|
|
5
|
+
* agent half's `/app/*`: `{ type: 'user', userId, username, roles }`.
|
|
6
|
+
*
|
|
7
|
+
* Gatekeeper attests only the username; everything else comes from here:
|
|
8
|
+
* the directory's user holding that username now, and their roles from the
|
|
9
|
+
* plugin's audience as last computed (`lib/audience.js`) — restricted to
|
|
10
|
+
* the roles the installed version declares. A disabled user is refused at
|
|
11
|
+
* once (the directory is written before Authelia restarts); a user the
|
|
12
|
+
* audience does not hold as an active member is not entitled, and neither
|
|
13
|
+
* is one whose username's earlier holder still has a principal in
|
|
14
|
+
* Gatekeeper to clear.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isLamasteUsername } from '@lamalibre/lamaste';
|
|
18
|
+
import { readAudienceRow } from './audience.js';
|
|
19
|
+
import { findDirectoryUser, principalCleanupPending } from './directory.js';
|
|
20
|
+
import { declaredRoleIds } from './entitlements.js';
|
|
21
|
+
import { identityReadiness } from './identity-sync.js';
|
|
22
|
+
import { findPluginByRoute } from './plugins.js';
|
|
23
|
+
|
|
24
|
+
const NOT_ENTITLED = 'This user may not use this plugin';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The user caller for `username` on the plugin at `route`, or why there is
|
|
28
|
+
* none: 404 when no installed plugin holds the route or it declares no
|
|
29
|
+
* identity; 503 when its identity is not ready (not approved, not hosted,
|
|
30
|
+
* or its agent half has not answered protocol 1 for the installed
|
|
31
|
+
* version); 403 when the user is disabled, removed, unknown or not
|
|
32
|
+
* entitled.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} route
|
|
35
|
+
* @param {string} username the Authelia username Gatekeeper attested
|
|
36
|
+
* @returns {Promise<{ ok: true, caller: { type: 'user', userId: string, username: string, roles: string[] } } | { ok: false, status: 403 | 404 | 503, error: string }>}
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveUserCaller(route, username) {
|
|
39
|
+
const plugin = await findPluginByRoute(route);
|
|
40
|
+
if (!plugin || !plugin.identity) return { ok: false, status: 404, error: 'Not found' };
|
|
41
|
+
const readiness = await identityReadiness(route);
|
|
42
|
+
if (!readiness.ready) {
|
|
43
|
+
return { ok: false, status: 503, error: 'The plugin is not ready for its users yet' };
|
|
44
|
+
}
|
|
45
|
+
if (!isLamasteUsername(username)) return { ok: false, status: 403, error: NOT_ENTITLED };
|
|
46
|
+
const user = await findDirectoryUser(username);
|
|
47
|
+
if (!user) return { ok: false, status: 403, error: NOT_ENTITLED };
|
|
48
|
+
// An earlier holder's grants may still be in Gatekeeper under this name.
|
|
49
|
+
if (await principalCleanupPending(username)) {
|
|
50
|
+
return { ok: false, status: 403, error: NOT_ENTITLED };
|
|
51
|
+
}
|
|
52
|
+
if (user.status !== 'active') return { ok: false, status: 403, error: 'This user is disabled' };
|
|
53
|
+
const row = await readAudienceRow(plugin.name, user.userId);
|
|
54
|
+
const member = row?.change?.kind === 'upsert' ? row.change.member : null;
|
|
55
|
+
if (!member || member.status !== 'active' || member.userId !== user.userId) {
|
|
56
|
+
return { ok: false, status: 403, error: NOT_ENTITLED };
|
|
57
|
+
}
|
|
58
|
+
const declared = declaredRoleIds(plugin.identity);
|
|
59
|
+
const roles = (Array.isArray(member.roles) ? member.roles : []).filter((role) =>
|
|
60
|
+
declared.has(role),
|
|
61
|
+
);
|
|
62
|
+
if (roles.length === 0) return { ok: false, status: 403, error: NOT_ENTITLED };
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
caller: { type: 'user', userId: user.userId, username: user.username, roles },
|
|
66
|
+
};
|
|
67
|
+
}
|