@lamalibre/create-lamaste 4.6.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/bundle-vendor.js +20 -3
- package/scripts/lamaste-priv +22 -3
- package/scripts/pin-release.js +98 -0
- package/src/index.js +8 -1
- package/src/lib/env.js +19 -0
- package/src/lib/service-config.js +7 -0
- package/src/tasks/redeploy.js +8 -0
- 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 +6 -2
- package/vendor/docs/01-concepts/authorization.md +4 -0
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +28 -3
- 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 +5 -11
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/second-domain.md +82 -0
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +15 -5
- package/vendor/docs/03-architecture/overview.md +5 -5
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +3 -3
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +17 -13
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/domains.md +192 -0
- package/vendor/docs/04-api-reference/gatekeeper.md +35 -7
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +9 -0
- package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
- package/vendor/docs/04-api-reference/sites.md +5 -0
- package/vendor/docs/04-api-reference/tunnels.md +15 -10
- package/vendor/docs/04-api-reference/users.md +41 -29
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +6 -6
- package/vendor/docs/06-reference/config-files.md +1 -1
- 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 +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/_index.json +105 -19
- package/vendor/docs/external-members-design.md +816 -0
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +3 -3
- package/vendor/docs/multi-domain-relay-design.md +836 -0
- 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/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +20 -13
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/secret.js +12 -0
- package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/templates.js +80 -14
- package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +5 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +28 -2
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +225 -36
- 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 +5 -2
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +7 -0
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/domains.js +214 -0
- package/vendor/server/src/commands/plugin-identity.js +2 -16
- package/vendor/server/src/commands/plugins.js +112 -8
- package/vendor/server/src/commands/registry-credential.js +10 -3
- package/vendor/server/src/commands/sites.js +3 -0
- package/vendor/server/src/commands/status.js +19 -0
- package/vendor/server/src/commands/tunnels.js +4 -1
- package/vendor/server/src/commands/users.js +8 -19
- package/vendor/server/src/domains.js +92 -0
- package/vendor/server/src/fail.js +21 -0
- package/vendor/server/src/oidc-client-grants.js +63 -0
- package/vendor/server/src/panel-api.js +15 -0
- package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
- package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +42 -16
- package/vendor/serverd/src/lib/app-error.js +24 -0
- package/vendor/serverd/src/lib/audience.js +12 -3
- package/vendor/serverd/src/lib/authelia.js +341 -35
- package/vendor/serverd/src/lib/directory.js +67 -52
- package/vendor/serverd/src/lib/domains.js +1792 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
- package/vendor/serverd/src/lib/identity-sync.js +35 -0
- package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
- package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
- package/vendor/serverd/src/lib/nginx.js +318 -140
- package/vendor/serverd/src/lib/owned-groups.js +326 -0
- package/vendor/serverd/src/lib/plugin-domain.js +86 -0
- package/vendor/serverd/src/lib/plugin-lifecycle.js +1 -0
- package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
- package/vendor/serverd/src/lib/plugins.js +45 -5
- package/vendor/serverd/src/lib/registry-credential.js +39 -6
- package/vendor/serverd/src/lib/services.js +9 -1
- package/vendor/serverd/src/lib/state.js +11 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
- package/vendor/serverd/src/lib/user-surface.js +29 -4
- package/vendor/serverd/src/lib/users-lock.js +23 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
- package/vendor/serverd/src/lib/web-surface.js +49 -32
- package/vendor/serverd/src/middleware/user-access-session.js +11 -4
- package/vendor/serverd/src/routes/invite.js +15 -5
- package/vendor/serverd/src/routes/management/domains.js +154 -0
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
- package/vendor/serverd/src/routes/management/identity.js +21 -7
- package/vendor/serverd/src/routes/management/invitations.js +33 -4
- package/vendor/serverd/src/routes/management/plugins.js +4 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
- package/vendor/serverd/src/routes/management/sites.js +50 -11
- package/vendor/serverd/src/routes/management/tunnels.js +29 -5
- package/vendor/serverd/src/routes/management/users.js +184 -64
- package/vendor/serverd/src/routes/management.js +2 -0
- package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
- package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
- package/vendor/serverd/src/routes/user-access.js +13 -1
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
- package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +0 -84
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
* `identity_providers.oidc` section is written; Authelia is restarted only
|
|
20
20
|
* when the section actually changed.
|
|
21
21
|
*
|
|
22
|
+
* Every client belongs to a domain — its plugin's (the panel's own: the
|
|
23
|
+
* registry credential's issuer domain): `{domain}` in its audience and
|
|
24
|
+
* redirect URIs is that domain, and a user-facing client has its own
|
|
25
|
+
* authorization policy (core `renderOidcClientPolicies`): that domain's
|
|
26
|
+
* members, and its external members granted the client (a Gatekeeper grant
|
|
27
|
+
* `resourceType: 'oidc-client'`, rendered by lib/domains.js into users.yml's
|
|
28
|
+
* owned groups). The provider is written under lib/domains.js
|
|
29
|
+
* `withDomainRendering` — the one writer of what members may do — with the
|
|
30
|
+
* domains read under it.
|
|
31
|
+
*
|
|
22
32
|
* Best effort by design where a plugin lifecycle calls it: a failure here
|
|
23
33
|
* must not fail the plugin operation — the caller logs it and the next
|
|
24
34
|
* lifecycle change retries.
|
|
@@ -26,9 +36,11 @@
|
|
|
26
36
|
|
|
27
37
|
import { readFile } from 'node:fs/promises';
|
|
28
38
|
import { createRequire } from 'node:module';
|
|
29
|
-
import {
|
|
39
|
+
import { PromiseChainMutex, validateInstalledManifest } from '@lamalibre/lamaste';
|
|
30
40
|
import {
|
|
31
41
|
LAMASTE_REGISTRY_OIDC_SCOPES,
|
|
42
|
+
LAMASTE_SERVER_OIDC_CLIENT_ID,
|
|
43
|
+
registryIssuerDomain,
|
|
32
44
|
lamasteServerOidcClient,
|
|
33
45
|
listOidcClientSecrets,
|
|
34
46
|
reconcileOidcClientSecrets,
|
|
@@ -36,32 +48,43 @@ import {
|
|
|
36
48
|
rotateOidcClientSecret,
|
|
37
49
|
} from '@lamalibre/lamaste/server';
|
|
38
50
|
|
|
39
|
-
import { getConfig } from './config.js';
|
|
40
51
|
import { updateOidcProvider } from './authelia.js';
|
|
52
|
+
import { listGrantsOfType } from './gatekeeper-client.js';
|
|
53
|
+
import { OIDC_CLIENT_RESOURCE, isUserFacingOidcClient } from './owned-groups.js';
|
|
54
|
+
import { pluginDomainOr, primaryDomainName, withDomainRendering } from './domains.js';
|
|
41
55
|
import { readPlugins } from './plugins.js';
|
|
42
56
|
import { readRegistryCredentialConfig } from './registry-credential.js';
|
|
43
57
|
|
|
44
58
|
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
45
59
|
|
|
60
|
+
/** The domains a rendering read, as the OIDC provider's policies name them: name and group key. */
|
|
61
|
+
const keyed = (domains) => domains.map(({ name, groupKey }) => ({ name, groupKey }));
|
|
62
|
+
|
|
46
63
|
/**
|
|
47
64
|
* The OIDC clients every installed plugin declares, tagged with the plugin
|
|
48
|
-
* name and whether the plugin is enabled. Manifests are re-read
|
|
49
|
-
* installed packages — the plugin registry does not store them.
|
|
65
|
+
* name, its domain and whether the plugin is enabled. Manifests are re-read
|
|
66
|
+
* from the installed packages — the plugin registry does not store them.
|
|
50
67
|
* @returns {Promise<Array<import('@lamalibre/lamaste/server').AutheliaOidcClient & { enabled: boolean }>>}
|
|
51
68
|
*/
|
|
52
69
|
export async function collectPluginOidcClients() {
|
|
53
70
|
const require = createRequire(`${STATE_DIR}/`);
|
|
71
|
+
const primary = await primaryDomainName();
|
|
54
72
|
const clients = [];
|
|
55
73
|
for (const plugin of (await readPlugins()).plugins) {
|
|
56
74
|
let manifest;
|
|
57
75
|
try {
|
|
58
76
|
const manifestPath = require.resolve(`${plugin.packageName}/lamaste-plugin.json`);
|
|
59
|
-
manifest =
|
|
77
|
+
manifest = validateInstalledManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
|
|
60
78
|
} catch {
|
|
61
79
|
continue; // an unreadable manifest contributes no clients
|
|
62
80
|
}
|
|
63
81
|
for (const client of manifest.oidcClients) {
|
|
64
|
-
clients.push({
|
|
82
|
+
clients.push({
|
|
83
|
+
...client,
|
|
84
|
+
plugin: manifest.name,
|
|
85
|
+
domain: pluginDomainOr(plugin, primary),
|
|
86
|
+
enabled: plugin.status === 'enabled',
|
|
87
|
+
});
|
|
65
88
|
}
|
|
66
89
|
}
|
|
67
90
|
return clients;
|
|
@@ -69,31 +92,77 @@ export async function collectPluginOidcClients() {
|
|
|
69
92
|
|
|
70
93
|
/**
|
|
71
94
|
* The panel's own client, when the registry credential (stored, or the
|
|
72
|
-
* candidate being verified) uses it.
|
|
73
|
-
* @param {{ registryUrl: string } | null | undefined} registryAuth
|
|
95
|
+
* candidate being verified) uses it — of the credential's issuer domain.
|
|
96
|
+
* @param {{ registryUrl: string, issuerDomain?: string } | null | undefined} registryAuth
|
|
74
97
|
*/
|
|
75
|
-
function lamasteOwnClient(registryAuth) {
|
|
98
|
+
async function lamasteOwnClient(registryAuth) {
|
|
76
99
|
if (!registryAuth) return [];
|
|
77
|
-
|
|
100
|
+
const domain = registryIssuerDomain(
|
|
101
|
+
{ kind: 'oidc', clientId: LAMASTE_SERVER_OIDC_CLIENT_ID, ...registryAuth },
|
|
102
|
+
await primaryDomainName(),
|
|
103
|
+
);
|
|
104
|
+
return [{ ...lamasteServerOidcClient(registryAuth.registryUrl, domain), enabled: true }];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A registry credential being set (`PUT /api/registry-credential`): its
|
|
109
|
+
* `registryAuth` while the panel's client is synced, the chain verified and
|
|
110
|
+
* the credential stored. Every sync that names no override serves it — the
|
|
111
|
+
* identity tick's every-minute sync included, which otherwise reads the
|
|
112
|
+
* stored credential, removes the client the candidate needs and restarts
|
|
113
|
+
* Authelia under the verification's token request. Every sync reads it under
|
|
114
|
+
* the one writer ({@link syncOidcClients}), so none can write a plan made
|
|
115
|
+
* before it was set.
|
|
116
|
+
* @type {{ registryUrl: string, issuerDomain?: string } | undefined}
|
|
117
|
+
*/
|
|
118
|
+
let registryCandidate;
|
|
119
|
+
const registryCandidateMutex = new PromiseChainMutex();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Run `fn` with `registryAuth` as the candidate registry credential
|
|
123
|
+
* (see {@link registryCandidate}); one candidate at a time. Without one
|
|
124
|
+
* (a pasted token), `fn` just runs.
|
|
125
|
+
* @template T
|
|
126
|
+
* @param {{ registryUrl: string, issuerDomain?: string } | undefined} registryAuth
|
|
127
|
+
* @param {() => Promise<T>} fn
|
|
128
|
+
* @returns {Promise<T>}
|
|
129
|
+
*/
|
|
130
|
+
export function withRegistryCandidate(registryAuth, fn) {
|
|
131
|
+
if (registryAuth === undefined) return fn();
|
|
132
|
+
return registryCandidateMutex.run(async () => {
|
|
133
|
+
registryCandidate = registryAuth;
|
|
134
|
+
try {
|
|
135
|
+
return await fn();
|
|
136
|
+
} finally {
|
|
137
|
+
registryCandidate = undefined;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
78
140
|
}
|
|
79
141
|
|
|
80
142
|
/**
|
|
81
143
|
* Everything Authelia should know, from the stored state.
|
|
82
|
-
* @param {{ registryAuth?: { registryUrl: string } | null }} [overrides]
|
|
144
|
+
* @param {{ registryAuth?: { registryUrl: string, issuerDomain?: string } | null }} [overrides]
|
|
83
145
|
* `registryAuth` replaces the stored registry credential's say in the
|
|
84
|
-
* matter: `{ registryUrl }` while a candidate oidc
|
|
85
|
-
* verified, `null` to leave the panel's client out.
|
|
146
|
+
* matter: `{ registryUrl, issuerDomain }` while a candidate oidc
|
|
147
|
+
* credential is being verified, `null` to leave the panel's client out.
|
|
86
148
|
*/
|
|
87
149
|
async function collectAllClients(overrides = {}) {
|
|
88
150
|
let registryAuth;
|
|
89
151
|
if (overrides.registryAuth !== undefined) {
|
|
90
152
|
registryAuth = overrides.registryAuth;
|
|
153
|
+
} else if (registryCandidate !== undefined) {
|
|
154
|
+
registryAuth = registryCandidate;
|
|
91
155
|
} else {
|
|
92
156
|
const stored = await readRegistryCredentialConfig().catch(() => null);
|
|
93
157
|
registryAuth =
|
|
94
|
-
stored && stored.auth.kind === 'oidc'
|
|
158
|
+
stored && stored.auth.kind === 'oidc'
|
|
159
|
+
? {
|
|
160
|
+
registryUrl: stored.registryUrl,
|
|
161
|
+
...(stored.auth.issuerDomain ? { issuerDomain: stored.auth.issuerDomain } : {}),
|
|
162
|
+
}
|
|
163
|
+
: null;
|
|
95
164
|
}
|
|
96
|
-
return [...(await collectPluginOidcClients()), ...lamasteOwnClient(registryAuth)];
|
|
165
|
+
return [...(await collectPluginOidcClients()), ...(await lamasteOwnClient(registryAuth))];
|
|
97
166
|
}
|
|
98
167
|
|
|
99
168
|
/**
|
|
@@ -103,7 +172,7 @@ async function collectAllClients(overrides = {}) {
|
|
|
103
172
|
* enabled plugins served, and the panel's own scopes when its client is.
|
|
104
173
|
* Pure with respect to Authelia — nothing is written.
|
|
105
174
|
*
|
|
106
|
-
* @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
|
|
175
|
+
* @param {{ registryAuth?: { registryUrl: string, issuerDomain?: string } | null }} [overrides] see collectAllClients
|
|
107
176
|
* @returns {Promise<{
|
|
108
177
|
* confidential: Array<{ id: string, plugin: string }>,
|
|
109
178
|
* served: Array<import('@lamalibre/lamaste/server').AutheliaOidcClient>,
|
|
@@ -123,37 +192,41 @@ export async function planOidcClients(overrides = {}) {
|
|
|
123
192
|
/**
|
|
124
193
|
* Sync Authelia's OIDC clients and the secret store with the plugins and
|
|
125
194
|
* the registry credential. Returns what was done, or `null` when the panel
|
|
126
|
-
* has no domain yet (onboarding).
|
|
195
|
+
* has no domain yet (onboarding). Plan, secrets and write all happen under
|
|
196
|
+
* the one writer: a sync that waited for it plans from what is true then,
|
|
197
|
+
* never from before another sync's change (a registry credential being
|
|
198
|
+
* set, a plugin moved).
|
|
127
199
|
*
|
|
128
200
|
* @param {import('pino').Logger} logger
|
|
129
|
-
* @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
|
|
130
201
|
* @returns {Promise<{ changed: boolean, clients: string[], createdSecrets: Array<{ id: string, plugin: string, secret: string }> } | null>}
|
|
131
202
|
*/
|
|
132
|
-
export async function syncOidcClients(logger
|
|
133
|
-
|
|
134
|
-
try {
|
|
135
|
-
domain = getConfig().domain;
|
|
136
|
-
} catch {
|
|
137
|
-
domain = null;
|
|
138
|
-
}
|
|
139
|
-
if (!domain) {
|
|
203
|
+
export async function syncOidcClients(logger) {
|
|
204
|
+
if (!(await primaryDomainName())) {
|
|
140
205
|
logger.debug('OIDC client sync skipped — no domain configured yet');
|
|
141
206
|
return null;
|
|
142
207
|
}
|
|
143
208
|
|
|
144
|
-
const {
|
|
145
|
-
|
|
209
|
+
const { served, reconciled, result } = await withDomainRendering(
|
|
210
|
+
async (domains) => {
|
|
211
|
+
const plan = await planOidcClients();
|
|
212
|
+
const secrets = await reconcileOidcClientSecrets(STATE_DIR, plan.confidential);
|
|
213
|
+
return {
|
|
214
|
+
served: plan.served,
|
|
215
|
+
reconciled: secrets,
|
|
216
|
+
result: await updateOidcProvider(keyed(domains), plan.served, {
|
|
217
|
+
secretDigests: secrets.digests,
|
|
218
|
+
scopes: plan.scopes,
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
{ logger },
|
|
223
|
+
);
|
|
146
224
|
if (reconciled.created.length > 0 || reconciled.removed.length > 0) {
|
|
147
225
|
logger.info(
|
|
148
226
|
{ created: reconciled.created.map((c) => c.id), removed: reconciled.removed },
|
|
149
227
|
'OIDC client secrets reconciled',
|
|
150
228
|
);
|
|
151
229
|
}
|
|
152
|
-
|
|
153
|
-
const result = await updateOidcProvider(domain, served, {
|
|
154
|
-
secretDigests: reconciled.digests,
|
|
155
|
-
scopes,
|
|
156
|
-
});
|
|
157
230
|
if (result.changed) {
|
|
158
231
|
logger.info({ clients: served.map((c) => c.id) }, 'Authelia OIDC provider updated');
|
|
159
232
|
}
|
|
@@ -166,16 +239,37 @@ export async function syncOidcClients(logger, overrides = {}) {
|
|
|
166
239
|
|
|
167
240
|
/**
|
|
168
241
|
* Metadata of a plugin's OIDC clients: what the manifest declares, whether
|
|
169
|
-
* it is served,
|
|
170
|
-
*
|
|
242
|
+
* it is served, whether it signs users in (`userFacing`) and — for those —
|
|
243
|
+
* the Gatekeeper grants on it (`grants`: who among the external members of
|
|
244
|
+
* its domain may sign in with it; members of the domain always may; null
|
|
245
|
+
* when Gatekeeper cannot be read), and — for confidential clients — when
|
|
246
|
+
* the secret was created and last rotated. Never the secret.
|
|
171
247
|
* @param {string} pluginName
|
|
172
248
|
*/
|
|
173
249
|
export async function listPluginOidcClients(pluginName) {
|
|
174
250
|
const declared = (await collectPluginOidcClients()).filter((c) => c.plugin === pluginName);
|
|
175
251
|
const secrets = new Map((await listOidcClientSecrets(STATE_DIR)).map((m) => [m.id, m]));
|
|
252
|
+
const grants = declared.some(isUserFacingOidcClient)
|
|
253
|
+
? await listGrantsOfType(OIDC_CLIENT_RESOURCE)
|
|
254
|
+
: null;
|
|
176
255
|
return declared.map((client) => {
|
|
177
256
|
const secret = client.confidential ? (secrets.get(client.id) ?? null) : null;
|
|
178
257
|
return {
|
|
258
|
+
userFacing: isUserFacingOidcClient(client),
|
|
259
|
+
...(isUserFacingOidcClient(client)
|
|
260
|
+
? {
|
|
261
|
+
grants: grants?.ok
|
|
262
|
+
? grants.grants
|
|
263
|
+
.filter((g) => g.resourceId === client.id)
|
|
264
|
+
.map((g) => ({
|
|
265
|
+
grantId: g.grantId,
|
|
266
|
+
principalType: g.principalType,
|
|
267
|
+
principalId: g.principalId,
|
|
268
|
+
createdAt: g.createdAt,
|
|
269
|
+
}))
|
|
270
|
+
: null,
|
|
271
|
+
}
|
|
272
|
+
: {}),
|
|
179
273
|
id: client.id,
|
|
180
274
|
name: client.name ?? null,
|
|
181
275
|
confidential: client.confidential,
|
|
@@ -216,13 +310,21 @@ export async function rotatePluginOidcClientSecret(pluginName, clientId, logger)
|
|
|
216
310
|
// The store is reconciled first so a client that was never synced (an
|
|
217
311
|
// earlier Authelia failure) gets its record now.
|
|
218
312
|
await syncOidcClients(logger);
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
313
|
+
// Rotate, plan and write under the one writer (see syncOidcClients).
|
|
314
|
+
const rotated = await withDomainRendering(
|
|
315
|
+
async (domains) => {
|
|
316
|
+
const secret = await rotateOidcClientSecret(STATE_DIR, clientId);
|
|
317
|
+
if (client.enabled) {
|
|
318
|
+
const { served, scopes } = await planOidcClients();
|
|
319
|
+
await updateOidcProvider(keyed(domains), served, {
|
|
320
|
+
secretDigests: await readOidcClientSecretDigests(STATE_DIR),
|
|
321
|
+
scopes,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return secret;
|
|
325
|
+
},
|
|
326
|
+
{ logger },
|
|
327
|
+
);
|
|
226
328
|
logger.info({ plugin: pluginName, clientId }, 'OIDC client secret rotated');
|
|
227
329
|
return {
|
|
228
330
|
id: rotated.id,
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
normalizeIdentityBlock,
|
|
23
23
|
pluginHostCapability,
|
|
24
24
|
validateManifest as coreValidateManifest,
|
|
25
|
+
validateInstalledManifest,
|
|
25
26
|
} from '@lamalibre/lamaste';
|
|
26
27
|
import { listRegisteredSubScopeNames } from './tickets.js';
|
|
27
28
|
import { getStateDb } from './state-db.js';
|
|
@@ -74,8 +75,8 @@ async function getStmts() {
|
|
|
74
75
|
(name, route, display_name, package_name, version, description, capabilities,
|
|
75
76
|
packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
|
|
76
77
|
agent_half_pin, settings, identity, user_surface, web_tree_sha256, status,
|
|
77
|
-
installed_at, enabled_at)
|
|
78
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
78
|
+
installed_at, enabled_at, domain)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
79
80
|
`),
|
|
80
81
|
deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
|
|
81
82
|
// The plugin's audience and the state of its identity feed
|
|
@@ -146,6 +147,8 @@ function rowToPlugin(row) {
|
|
|
146
147
|
if (row.user_surface != null) entry.userSurface = JSON.parse(row.user_surface);
|
|
147
148
|
if (row.web_tree_sha256 != null) entry.webTreeSha256 = row.web_tree_sha256;
|
|
148
149
|
if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
|
|
150
|
+
// The domain the plugin belongs to; null: the primary (lib/domains.js).
|
|
151
|
+
entry.domain = row.domain ?? null;
|
|
149
152
|
return entry;
|
|
150
153
|
}
|
|
151
154
|
|
|
@@ -233,6 +236,7 @@ export async function writePlugins(data) {
|
|
|
233
236
|
p.status,
|
|
234
237
|
p.installedAt,
|
|
235
238
|
nullableString(p.enabledAt),
|
|
239
|
+
nullableString(p.domain),
|
|
236
240
|
);
|
|
237
241
|
}
|
|
238
242
|
s.commit.run();
|
|
@@ -560,6 +564,8 @@ export function installPlugin(packageName, logger) {
|
|
|
560
564
|
entry.status,
|
|
561
565
|
entry.installedAt,
|
|
562
566
|
null,
|
|
567
|
+
// A plugin installed now belongs to the primary domain (lib/plugin-domain.js moves it).
|
|
568
|
+
null,
|
|
563
569
|
);
|
|
564
570
|
s.commit.run();
|
|
565
571
|
} catch (err) {
|
|
@@ -622,6 +628,13 @@ export function uninstallPlugin(name, logger) {
|
|
|
622
628
|
const { removeRelayHooks } = await import('./plugin-hooks.js');
|
|
623
629
|
await removeRelayHooks(name, logger);
|
|
624
630
|
|
|
631
|
+
// The OIDC clients it declares, read while its manifest is installed:
|
|
632
|
+
// their grants go with it below.
|
|
633
|
+
const { collectPluginOidcClients } = await import('./plugin-oidc.js');
|
|
634
|
+
const clientIds = (await collectPluginOidcClients().catch(() => []))
|
|
635
|
+
.filter((c) => c.plugin === name)
|
|
636
|
+
.map((c) => c.id);
|
|
637
|
+
|
|
625
638
|
// Uninstall the npm package
|
|
626
639
|
logger.info({ name, packageName: plugin.packageName }, 'Uninstalling plugin package');
|
|
627
640
|
await execa('npm', ['uninstall', plugin.packageName], {
|
|
@@ -695,6 +708,24 @@ export function uninstallPlugin(name, logger) {
|
|
|
695
708
|
|
|
696
709
|
logger.info({ name }, 'Plugin uninstalled');
|
|
697
710
|
await syncOidcClientsBestEffort(logger);
|
|
711
|
+
|
|
712
|
+
// Grants on its OIDC clients end with it: client ids are chosen, not
|
|
713
|
+
// random, so a later install of the plugin (or another declaring the
|
|
714
|
+
// same id) would otherwise admit whoever was granted before.
|
|
715
|
+
const { revokeOidcClientGrants } = await import('./owned-groups.js');
|
|
716
|
+
const revoked = await revokeOidcClientGrants(clientIds);
|
|
717
|
+
if (!revoked.ok) {
|
|
718
|
+
logger.warn(
|
|
719
|
+
{ name, err: revoked.error },
|
|
720
|
+
"Grants on the uninstalled plugin's OIDC clients stayed — revoke them in Gatekeeper",
|
|
721
|
+
);
|
|
722
|
+
} else if (revoked.revoked > 0) {
|
|
723
|
+
logger.info({ name, revoked: revoked.revoked }, 'Grants on its OIDC clients revoked');
|
|
724
|
+
const { syncDomainAccess } = await import('./domains.js');
|
|
725
|
+
await syncDomainAccess({ logger }).catch((err) => {
|
|
726
|
+
logger.warn({ err: err.message }, 'Rendering domain access after an uninstall failed');
|
|
727
|
+
});
|
|
728
|
+
}
|
|
698
729
|
return { ok: true, name };
|
|
699
730
|
});
|
|
700
731
|
}
|
|
@@ -785,9 +816,17 @@ export async function writePluginRow(
|
|
|
785
816
|
* web tree's digest. The
|
|
786
817
|
* row is not touched — the caller compares the result with what was
|
|
787
818
|
* reviewed and then writes it. Refuses a package whose manifest names
|
|
788
|
-
* another plugin.
|
|
819
|
+
* another plugin. `restoring` puts back the version that was installed (an
|
|
820
|
+
* update's revert): its manifest is read as an installed one, so a rule
|
|
821
|
+
* tightened since it was installed does not leave the revert half done.
|
|
789
822
|
*/
|
|
790
|
-
export function replacePluginPackage(
|
|
823
|
+
export function replacePluginPackage(
|
|
824
|
+
name,
|
|
825
|
+
packageName,
|
|
826
|
+
version,
|
|
827
|
+
logger,
|
|
828
|
+
{ restoring = false } = {},
|
|
829
|
+
) {
|
|
791
830
|
return withPluginLock(async () => {
|
|
792
831
|
const bare = bareLamalibrePackageName(packageName);
|
|
793
832
|
const spec = `${bare}@${version}`;
|
|
@@ -809,7 +848,8 @@ export function replacePluginPackage(name, packageName, version, logger) {
|
|
|
809
848
|
}
|
|
810
849
|
const require = createRequire(`${STATE_DIR}/`);
|
|
811
850
|
const manifestPath = require.resolve(`${bare}/lamaste-plugin.json`);
|
|
812
|
-
const
|
|
851
|
+
const raw = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
852
|
+
const manifest = restoring ? validateInstalledManifest(raw) : validateManifest(raw);
|
|
813
853
|
if (manifest.name !== name) {
|
|
814
854
|
throw Object.assign(new Error(`${spec} is plugin "${manifest.name}", not "${name}"`), {
|
|
815
855
|
statusCode: 400,
|
|
@@ -57,6 +57,33 @@ export async function writeRegistryCredentialConfig(config) {
|
|
|
57
57
|
resetRegistryTokenState();
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Store `config` as the registry credential: written while its issuer
|
|
62
|
+
* domain is still an active domain (a domain's removal checks the
|
|
63
|
+
* credential under the same lock — 400 `unknown_domain` otherwise). When the
|
|
64
|
+
* registry's domains change (its issuer domain, the domain its host is
|
|
65
|
+
* under), the tokens of every user who is a member of none of the new ones
|
|
66
|
+
* are revoked: who may hold a token follows them. Returns the warnings.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} config
|
|
69
|
+
* @param {{ logger?: import('pino').Logger }} [opts]
|
|
70
|
+
* @returns {Promise<Array<{ step: string, message: string }>>}
|
|
71
|
+
*/
|
|
72
|
+
export async function setRegistryCredential(config, { logger } = {}) {
|
|
73
|
+
const { registryDomainsOf, revokeRegistryTokensOutside, withActiveDomain } =
|
|
74
|
+
await import('./domains.js');
|
|
75
|
+
const issuerDomain = config.auth?.kind === 'oidc' ? config.auth.issuerDomain : undefined;
|
|
76
|
+
const change = await withActiveDomain(issuerDomain, async () => {
|
|
77
|
+
const before = await registryDomainsOf(await readRegistryCredentialConfig().catch(() => null));
|
|
78
|
+
await writeRegistryCredentialConfig(config);
|
|
79
|
+
const after = await registryDomainsOf(config);
|
|
80
|
+
const same = after.length === before.length && after.every((d) => before.includes(d));
|
|
81
|
+
return { changed: !same, after };
|
|
82
|
+
});
|
|
83
|
+
if (!change.changed) return [];
|
|
84
|
+
return revokeRegistryTokensOutside(change.after, config, logger);
|
|
85
|
+
}
|
|
86
|
+
|
|
60
87
|
export async function clearRegistryCredentialConfig() {
|
|
61
88
|
const removed = await clearCore(STATE_DIR);
|
|
62
89
|
resetRegistryTokenState();
|
|
@@ -70,9 +97,9 @@ export function resetRegistryTokenState() {
|
|
|
70
97
|
}
|
|
71
98
|
|
|
72
99
|
/**
|
|
73
|
-
* What the core needs to run the OIDC chain for a credential: the
|
|
74
|
-
* domain
|
|
75
|
-
* need nothing.
|
|
100
|
+
* What the core needs to run the OIDC chain for a credential: the primary
|
|
101
|
+
* domain (the issuer when the credential names no `issuerDomain`) and the
|
|
102
|
+
* `lamaste-server` client secret. Pasted-token credentials need nothing.
|
|
76
103
|
* @param {{ auth: { kind: string } }} config
|
|
77
104
|
*/
|
|
78
105
|
async function oidcDeps(config) {
|
|
@@ -166,10 +193,16 @@ export async function acquireAgentToken(agentLabel) {
|
|
|
166
193
|
* @param {string} username
|
|
167
194
|
* @param {{ before: string[], after: string[] } | { deleted: true }} change
|
|
168
195
|
* @param {import('pino').Logger | undefined} log
|
|
169
|
-
* @param {{ userId?: string | null }} [who]
|
|
196
|
+
* @param {{ userId?: string | null, config?: object }} [who] `config`: the credential
|
|
197
|
+
* already read (a batch of revocations reads it once)
|
|
170
198
|
*/
|
|
171
|
-
export async function revokeUserRegistryTokens(
|
|
172
|
-
|
|
199
|
+
export async function revokeUserRegistryTokens(
|
|
200
|
+
username,
|
|
201
|
+
change,
|
|
202
|
+
log,
|
|
203
|
+
{ userId = null, config: given } = {},
|
|
204
|
+
) {
|
|
205
|
+
const config = given ?? (await readRegistryCredentialConfig());
|
|
173
206
|
if (!config) return null;
|
|
174
207
|
|
|
175
208
|
const lost =
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
|
+
import { autheliaReady } from '@lamalibre/lamaste/server';
|
|
2
3
|
|
|
3
4
|
export const ALLOWED_SERVICES = ['nginx', 'chisel', 'authelia', 'lamalibre-lamaste-serverd'];
|
|
4
5
|
|
|
@@ -120,7 +121,6 @@ export async function executeServiceAction(name, action) {
|
|
|
120
121
|
|
|
121
122
|
try {
|
|
122
123
|
await execa('sudo', ['systemctl', action, name], { timeout: 30_000 });
|
|
123
|
-
return { ok: true, name, action };
|
|
124
124
|
} catch (err) {
|
|
125
125
|
const details = err.stderr || err.message;
|
|
126
126
|
const error = new Error(`Failed to ${action} ${name}`);
|
|
@@ -128,4 +128,12 @@ export async function executeServiceAction(name, action) {
|
|
|
128
128
|
error.details = details;
|
|
129
129
|
throw error;
|
|
130
130
|
}
|
|
131
|
+
// systemctl returns when Authelia's process is spawned, before it serves
|
|
132
|
+
// requests: the action is done once it does.
|
|
133
|
+
if (name === 'authelia' && action !== 'stop' && !(await autheliaReady(execa))) {
|
|
134
|
+
const error = new Error(`Authelia is not serving requests after ${action}`);
|
|
135
|
+
error.statusCode = 500;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, name, action };
|
|
131
139
|
}
|
|
@@ -48,6 +48,16 @@ export async function writeTunnels(tunnels) {
|
|
|
48
48
|
await fd.close();
|
|
49
49
|
|
|
50
50
|
await rename(tmpPath, filePath);
|
|
51
|
+
await hostsChanged();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A tunnel or a site changed: the hosts that name external members are
|
|
56
|
+
* rendered again shortly (lib/domains.js `scheduleHostRender`).
|
|
57
|
+
*/
|
|
58
|
+
async function hostsChanged() {
|
|
59
|
+
const { scheduleHostRender } = await import('./domains.js');
|
|
60
|
+
scheduleHostRender();
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
function sitesPath() {
|
|
@@ -93,6 +103,7 @@ export async function writeSites(sites) {
|
|
|
93
103
|
await fd.close();
|
|
94
104
|
|
|
95
105
|
await rename(tmpPath, filePath);
|
|
106
|
+
await hostsChanged();
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
/** Serializes every read-modify-write of sites.json in this process. */
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
writePublicVhost,
|
|
10
|
-
|
|
11
|
-
writeRestrictedVhost,
|
|
10
|
+
writeGatekeeperVhost,
|
|
12
11
|
removeAppVhost,
|
|
13
12
|
enableAppVhost,
|
|
14
13
|
disableAppVhost,
|
|
@@ -23,8 +22,7 @@ import { readTunnels, writeTunnels } from './state.js';
|
|
|
23
22
|
|
|
24
23
|
export const tunnelNginxDeps = Object.freeze({
|
|
25
24
|
writePublicVhost,
|
|
26
|
-
|
|
27
|
-
writeRestrictedVhost,
|
|
25
|
+
writeGatekeeperVhost,
|
|
28
26
|
writeAgentPanelVhost,
|
|
29
27
|
removeAppVhost,
|
|
30
28
|
removeAgentPanelVhost,
|
|
@@ -57,11 +57,13 @@ import {
|
|
|
57
57
|
TunnelError,
|
|
58
58
|
bindUserSurface,
|
|
59
59
|
releaseUserSurfaceRoute,
|
|
60
|
+
tunnelHost,
|
|
60
61
|
unbindUserSurface,
|
|
61
62
|
} from '@lamalibre/lamaste/server';
|
|
62
63
|
import { httpError } from './app-error.js';
|
|
63
64
|
import { recordAuditBestEffort } from './audit.js';
|
|
64
65
|
import { getConfig } from './config.js';
|
|
66
|
+
import { pluginDomainName } from './domains.js';
|
|
65
67
|
import { installedVersionApproved } from './plugin-hooks.js';
|
|
66
68
|
import { findPluginByRoute } from './plugins.js';
|
|
67
69
|
import { relaySigner } from './relay-signing.js';
|
|
@@ -225,8 +227,8 @@ export async function withUserSurfaceStatus(tunnel) {
|
|
|
225
227
|
// ---------------------------------------------------------------------------
|
|
226
228
|
|
|
227
229
|
/**
|
|
228
|
-
* What core's binding workflows need from the panel — among them its
|
|
229
|
-
*
|
|
230
|
+
* What core's binding workflows need from the panel — among them its Let's
|
|
231
|
+
* Encrypt email, which a vhost rewrite needs.
|
|
230
232
|
*/
|
|
231
233
|
function bindingOptions(id, route, logger) {
|
|
232
234
|
const config = getConfig();
|
|
@@ -236,7 +238,6 @@ function bindingOptions(id, route, logger) {
|
|
|
236
238
|
return {
|
|
237
239
|
id,
|
|
238
240
|
route,
|
|
239
|
-
domain: config.domain,
|
|
240
241
|
email: config.email,
|
|
241
242
|
nginx: tunnelNginxDeps,
|
|
242
243
|
certbot: tunnelCertbotDeps,
|
|
@@ -300,6 +301,26 @@ async function assertBindableRoute(route) {
|
|
|
300
301
|
}
|
|
301
302
|
}
|
|
302
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Refuse binding tunnel `id` as the user surface of the plugin at `route`
|
|
306
|
+
* when the tunnel's hostname is not on the plugin's domain (400
|
|
307
|
+
* `not_plugin_domain`): a plugin's users are its domain's members.
|
|
308
|
+
*/
|
|
309
|
+
async function assertOnPluginDomain(id, route) {
|
|
310
|
+
const tunnel = (await readTunnels()).find((t) => t.id === id);
|
|
311
|
+
const host = tunnel ? tunnelHost(tunnel) : null;
|
|
312
|
+
if (!host) return; // core refuses a tunnel without a hostname
|
|
313
|
+
const plugin = await findPluginByRoute(route);
|
|
314
|
+
const domain = await pluginDomainName(plugin);
|
|
315
|
+
if (host.domain !== domain) {
|
|
316
|
+
throw httpError(
|
|
317
|
+
`The tunnel is on ${host.domain}; the plugin holding '${route}' belongs to ${domain}`,
|
|
318
|
+
400,
|
|
319
|
+
{ code: 'not_plugin_domain' },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
303
324
|
/** Whether an installed agent-hosted plugin holds `route`. */
|
|
304
325
|
async function isBindableRoute(route) {
|
|
305
326
|
return assertBindableRoute(route).then(
|
|
@@ -329,7 +350,11 @@ async function isBindableRoute(route) {
|
|
|
329
350
|
export async function bindTunnelUserSurface(id, route, { actor, logger }) {
|
|
330
351
|
const result = await bindUserSurface({
|
|
331
352
|
...bindingOptions(id, route, logger),
|
|
332
|
-
|
|
353
|
+
// Under the tunnel lock, as the plugin's domain changes (lib/domains.js).
|
|
354
|
+
assertRoute: async (r) => {
|
|
355
|
+
await assertBindableRoute(r);
|
|
356
|
+
await assertOnPluginDomain(id, r);
|
|
357
|
+
},
|
|
333
358
|
});
|
|
334
359
|
if (result.changed && !(await isBindableRoute(route))) {
|
|
335
360
|
await pruneUserSurfaceBindings(route, { plugin: null, logger });
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The users lock — the one lock around every read-modify-write of users.yml
|
|
3
|
+
* and every change of the user directory in this process (core's users
|
|
4
|
+
* functions take none), and around the changes that must agree with
|
|
5
|
+
* users.yml's groups: a membership's kind (an external member holds no
|
|
6
|
+
* Authelia group — lib/domains.js). Taken before the domains lock, never
|
|
7
|
+
* after it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { PromiseChainMutex } from '@lamalibre/lamaste';
|
|
11
|
+
|
|
12
|
+
const usersMutex = new PromiseChainMutex();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run `fn` holding the users lock.
|
|
16
|
+
*
|
|
17
|
+
* @template T
|
|
18
|
+
* @param {() => Promise<T>} fn
|
|
19
|
+
* @returns {Promise<T>}
|
|
20
|
+
*/
|
|
21
|
+
export function withUsersLock(fn) {
|
|
22
|
+
return usersMutex.run(fn);
|
|
23
|
+
}
|