@lamalibre/create-lamaste 4.5.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 +56 -5
- 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/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 +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +80 -12
- 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 +116 -37
- 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 +54 -8
- 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 +9 -0
- package/vendor/docs/04-api-reference/tunnels.md +35 -28
- 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 +15 -6
- package/vendor/docs/06-reference/config-files.md +19 -15
- 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/index.d.ts +3 -3
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -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.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/lib/tunnels.d.ts +3 -6
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +6 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +56 -11
- 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 +325 -55
- 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/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +175 -14
- package/vendor/server/src/commands/registry-credential.js +10 -3
- package/vendor/server/src/commands/sites.js +18 -1
- 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/src/plugin-approval.js +80 -1
- package/vendor/server/src/plugin-web-surface.js +307 -0
- package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
- package/vendor/server-ui/dist/assets/index-Cb-ADsbB.css +1 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +50 -19
- 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/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- 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 +34 -7
- package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
- package/vendor/serverd/src/lib/identity-sync.js +35 -0
- package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -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 +576 -131
- 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-hooks.js +40 -11
- package/vendor/serverd/src/lib/plugin-lifecycle.js +129 -5
- package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +82 -9
- 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 +29 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
- package/vendor/serverd/src/lib/user-surface.js +77 -31
- package/vendor/serverd/src/lib/users-lock.js +23 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +151 -41
- package/vendor/serverd/src/lib/web-surface.js +783 -0
- 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/plugin-web-surface.js +151 -0
- package/vendor/serverd/src/routes/management/plugins.js +6 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
- package/vendor/serverd/src/routes/management/sites.js +188 -71
- package/vendor/serverd/src/routes/management/tunnels.js +37 -22
- package/vendor/serverd/src/routes/management/users.js +184 -64
- package/vendor/serverd/src/routes/management.js +4 -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-D-fer5ef.css +0 -1
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Authelia groups Lamaste owns, as the relay's state says they should
|
|
3
|
+
* be (packages/core/docs/external-members-design.md; core owned-groups.ts) —
|
|
4
|
+
* what lib/domains.js `syncDomainAccess` writes into users.yml:
|
|
5
|
+
*
|
|
6
|
+
* - each domain's members and external members (`lamaste:m:<key>`,
|
|
7
|
+
* `lamaste:x:<key>`, the key a random one of the domain's record);
|
|
8
|
+
* - a protected static site's listed users (`lamaste:s:<site id>`);
|
|
9
|
+
* - the external members granted a Gatekeeper-gated `restricted` host — a
|
|
10
|
+
* restricted tunnel (a user surface's too) or a restricted web surface —
|
|
11
|
+
* by a grant in Gatekeeper, direct or through a Lamaste group (the host's
|
|
12
|
+
* group, named by its hostname);
|
|
13
|
+
* - the external members granted a user-facing OIDC client
|
|
14
|
+
* (`lamaste:oidc@<id>`, a Gatekeeper grant `resourceType: 'oidc-client'`).
|
|
15
|
+
*
|
|
16
|
+
* Grants are read from Gatekeeper only while some domain has external
|
|
17
|
+
* members (members reach those hosts and clients through their domain's
|
|
18
|
+
* group). When they cannot be read — Gatekeeper, the tunnels or the plugin
|
|
19
|
+
* manifests — external members hold no host or client group until they
|
|
20
|
+
* can (fail closed): the memberships, from state.db, are rendered all the
|
|
21
|
+
* same.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { OWNED_GROUP_PREFIX, renderOwnedGroups } from '@lamalibre/lamaste';
|
|
25
|
+
import { httpError } from './app-error.js';
|
|
26
|
+
import {
|
|
27
|
+
listGatekeeperGroups,
|
|
28
|
+
listGrantsOfType,
|
|
29
|
+
revokeGatekeeperGrant,
|
|
30
|
+
} from './gatekeeper-client.js';
|
|
31
|
+
import { readTunnels } from './state.js';
|
|
32
|
+
|
|
33
|
+
/** The Gatekeeper resource type of a grant on an OIDC client (`resourceId`: the client id). */
|
|
34
|
+
export const OIDC_CLIENT_RESOURCE = 'oidc-client';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether an OIDC client signs users in — a grant can name it — rather than
|
|
38
|
+
* a service (`client_credentials`, never mixed with a user grant).
|
|
39
|
+
*
|
|
40
|
+
* @param {{ grantTypes: readonly string[] }} client
|
|
41
|
+
*/
|
|
42
|
+
export function isUserFacingOidcClient(client) {
|
|
43
|
+
return !client.grantTypes.includes('client_credentials');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An access mode as Gatekeeper reads it: restricted when absent or unknown. */
|
|
47
|
+
function accessModeOf(value) {
|
|
48
|
+
return value === 'public' || value === 'authenticated' ? value : 'restricted';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Gatekeeper-gated restricted hosts: enabled restricted tunnels with a
|
|
53
|
+
* hostname (an internal plugin tunnel has none; an agent panel is mTLS,
|
|
54
|
+
* not Gatekeeper's; a tunnel without an access mode is restricted, as
|
|
55
|
+
* Gatekeeper reads it) and restricted web surfaces (plugin-owned sites),
|
|
56
|
+
* each with the resource its grants name. A disabled tunnel serves nobody —
|
|
57
|
+
* a web surface moved off a tunnel leaves it disabled on the same hostname,
|
|
58
|
+
* and its grants must not name anyone there.
|
|
59
|
+
*
|
|
60
|
+
* @param {ReadonlyArray<Record<string, unknown>>} tunnels
|
|
61
|
+
* @param {ReadonlyArray<Record<string, unknown>>} sites
|
|
62
|
+
* @returns {Array<{ fqdn: string, resourceType: 'tunnel' | 'site', resourceId: string }>}
|
|
63
|
+
*/
|
|
64
|
+
export function restrictedHosts(tunnels, sites) {
|
|
65
|
+
const hosts = [];
|
|
66
|
+
for (const t of tunnels) {
|
|
67
|
+
if (
|
|
68
|
+
typeof t?.fqdn === 'string' &&
|
|
69
|
+
typeof t.id === 'string' &&
|
|
70
|
+
t.enabled !== false &&
|
|
71
|
+
t.type !== 'panel' &&
|
|
72
|
+
t.exposure !== 'internal' &&
|
|
73
|
+
accessModeOf(t.accessMode) === 'restricted'
|
|
74
|
+
) {
|
|
75
|
+
hosts.push({ fqdn: t.fqdn.toLowerCase(), resourceType: 'tunnel', resourceId: t.id });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const s of sites) {
|
|
79
|
+
if (s?.owner && accessModeOf(s.accessMode) === 'restricted' && typeof s.fqdn === 'string') {
|
|
80
|
+
hosts.push({ fqdn: s.fqdn.toLowerCase(), resourceType: 'site', resourceId: s.id });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return hosts;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The usernames each grant of `grants` reaches — a user grant its user, a
|
|
88
|
+
* group grant the members of the Lamaste group — keyed by resource id.
|
|
89
|
+
*
|
|
90
|
+
* @param {ReadonlyArray<{ principalType: 'user' | 'group', principalId: string, resourceId: string }>} grants
|
|
91
|
+
* @param {ReadonlyMap<string, { members: readonly string[] }>} groups by name
|
|
92
|
+
* @returns {Map<string, Set<string>>}
|
|
93
|
+
*/
|
|
94
|
+
export function grantedUsernames(grants, groups) {
|
|
95
|
+
const out = new Map();
|
|
96
|
+
for (const grant of grants) {
|
|
97
|
+
let set = out.get(grant.resourceId);
|
|
98
|
+
if (!set) out.set(grant.resourceId, (set = new Set()));
|
|
99
|
+
if (grant.principalType === 'user') set.add(grant.principalId);
|
|
100
|
+
else for (const member of groups.get(grant.principalId)?.members ?? []) set.add(member);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every grant on tunnels, sites and OIDC clients, with the Lamaste groups
|
|
107
|
+
* expanded — the usernames granted each resource, by type and id — or the
|
|
108
|
+
* reason Gatekeeper could not say.
|
|
109
|
+
*
|
|
110
|
+
* @returns {Promise<{ ok: true, granted: Record<'tunnel' | 'site' | 'oidc-client', Map<string, Set<string>>> } | { ok: false, error: string }>}
|
|
111
|
+
*/
|
|
112
|
+
export async function readGrantedResources() {
|
|
113
|
+
const [tunnel, site, client, groups] = await Promise.all([
|
|
114
|
+
listGrantsOfType('tunnel'),
|
|
115
|
+
listGrantsOfType('site'),
|
|
116
|
+
listGrantsOfType(OIDC_CLIENT_RESOURCE),
|
|
117
|
+
listGatekeeperGroups(),
|
|
118
|
+
]);
|
|
119
|
+
const failed = [tunnel, site, client, groups].find((r) => !r.ok);
|
|
120
|
+
if (failed) return { ok: false, error: failed.error };
|
|
121
|
+
const byName = new Map(groups.groups.map((g) => [g.name, g]));
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
granted: {
|
|
125
|
+
tunnel: grantedUsernames(tunnel.grants, byName),
|
|
126
|
+
site: grantedUsernames(site.grants, byName),
|
|
127
|
+
[OIDC_CLIENT_RESOURCE]: grantedUsernames(client.grants, byName),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The user-facing OIDC clients Authelia serves, each with its domain — the
|
|
134
|
+
* clients a grant can name (a `client_credentials` client signs in no one).
|
|
135
|
+
*
|
|
136
|
+
* @returns {Promise<Array<{ id: string, domain: string }>>}
|
|
137
|
+
*/
|
|
138
|
+
export async function userFacingOidcClients() {
|
|
139
|
+
const { planOidcClients } = await import('./plugin-oidc.js');
|
|
140
|
+
const { served } = await planOidcClients();
|
|
141
|
+
return served.filter(isUserFacingOidcClient).map((c) => ({ id: c.id, domain: c.domain }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The owned groups every user should hold (core `renderOwnedGroups`):
|
|
146
|
+
* `complete` false — with a `warning` — when the grants could not be read,
|
|
147
|
+
* and the hosts and OIDC clients external members are granted were then
|
|
148
|
+
* left out (fail closed).
|
|
149
|
+
*
|
|
150
|
+
* @param {{
|
|
151
|
+
* domains: ReadonlyArray<{ name: string, groupKey: string, members: readonly string[], externals: readonly string[] }>,
|
|
152
|
+
* sites: ReadonlyArray<Record<string, unknown>>,
|
|
153
|
+
* }} input `domains` the active ones with their people, `sites` sites.json
|
|
154
|
+
* (or the sites being saved)
|
|
155
|
+
* @returns {Promise<{ desired: Map<string, string[]>, complete: boolean, warning?: string }>}
|
|
156
|
+
*/
|
|
157
|
+
export async function desiredOwnedGroups({ domains, sites }) {
|
|
158
|
+
const siteGroups = sites
|
|
159
|
+
.filter(
|
|
160
|
+
(s) =>
|
|
161
|
+
s?.autheliaProtected === true &&
|
|
162
|
+
typeof s.id === 'string' &&
|
|
163
|
+
typeof s.fqdn === 'string' &&
|
|
164
|
+
Array.isArray(s.allowedUsers) &&
|
|
165
|
+
s.allowedUsers.length > 0,
|
|
166
|
+
)
|
|
167
|
+
.map((s) => ({ id: s.id, fqdn: s.fqdn.toLowerCase(), users: s.allowedUsers }));
|
|
168
|
+
|
|
169
|
+
let hostGrants = [];
|
|
170
|
+
let clientGrants = [];
|
|
171
|
+
let warning;
|
|
172
|
+
if (domains.some((d) => d.externals.length > 0)) {
|
|
173
|
+
try {
|
|
174
|
+
const [hosts, clients, read] = await Promise.all([
|
|
175
|
+
readTunnels().then((tunnels) => restrictedHosts(tunnels, sites)),
|
|
176
|
+
userFacingOidcClients(),
|
|
177
|
+
readGrantedResources(),
|
|
178
|
+
]);
|
|
179
|
+
if (!read.ok) throw new Error(`Gatekeeper: ${read.error}`);
|
|
180
|
+
hostGrants = hosts.map((h) => ({
|
|
181
|
+
fqdn: h.fqdn,
|
|
182
|
+
users: [...(read.granted[h.resourceType].get(h.resourceId) ?? [])],
|
|
183
|
+
}));
|
|
184
|
+
clientGrants = clients.map((c) => ({
|
|
185
|
+
clientId: c.id,
|
|
186
|
+
domain: c.domain,
|
|
187
|
+
users: [...(read.granted[OIDC_CLIENT_RESOURCE].get(c.id) ?? [])],
|
|
188
|
+
}));
|
|
189
|
+
} catch (err) {
|
|
190
|
+
hostGrants = [];
|
|
191
|
+
clientGrants = [];
|
|
192
|
+
warning = `What external members are granted could not be read (${err.message}): they hold no granted host or OIDC client until it can — the panel renders again within a minute`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
desired: renderOwnedGroups({ domains, sites: siteGroups, hostGrants, clientGrants }),
|
|
198
|
+
complete: warning === undefined,
|
|
199
|
+
...(warning ? { warning } : {}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Whether an owned group names a grant (a host group, an OIDC client group) rather than a membership or a site's list. */
|
|
204
|
+
function isGrantGroup(group) {
|
|
205
|
+
return !group.startsWith(OWNED_GROUP_PREFIX) || group.startsWith(`${OWNED_GROUP_PREFIX}oidc@`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The owned groups each user held as the last rendering from complete
|
|
210
|
+
* inputs decided (membership groups as the last rendering of any kind
|
|
211
|
+
* did) — what a narrowing is measured against. Null until the first
|
|
212
|
+
* rendering, which takes what users.yml holds then.
|
|
213
|
+
*
|
|
214
|
+
* @type {Map<string, readonly string[]> | null}
|
|
215
|
+
*/
|
|
216
|
+
let baseline = null;
|
|
217
|
+
|
|
218
|
+
/** Forget the baseline (tests). */
|
|
219
|
+
export function resetOwnedGroupsBaselineForTests() {
|
|
220
|
+
baseline = null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Who lost owned groups since the baseline, as `desired` decides — each
|
|
225
|
+
* enabled user of `held` (users.yml now) with the groups they lose — and
|
|
226
|
+
* the baseline moved on. When `complete` is false the grants were not read
|
|
227
|
+
* and `desired` holds none: grant groups are then neither counted as lost
|
|
228
|
+
* nor forgotten — a grant revoked meanwhile is found lost by the next
|
|
229
|
+
* complete rendering. A user who is gone or disabled is no narrowing to
|
|
230
|
+
* follow (Authelia refuses them; offboarding revokes their tokens).
|
|
231
|
+
*
|
|
232
|
+
* @param {ReadonlyMap<string, readonly string[]>} desired
|
|
233
|
+
* @param {boolean} complete
|
|
234
|
+
* @param {ReadonlyMap<string, { groups: readonly string[], active: boolean }>} held
|
|
235
|
+
* @returns {Array<{ username: string, lost: string[], before: readonly string[], after: string[] }>}
|
|
236
|
+
*/
|
|
237
|
+
export function narrowingSince(desired, complete, held) {
|
|
238
|
+
if (baseline === null) {
|
|
239
|
+
baseline = new Map([...held].map(([u, h]) => [u, [...h.groups]]));
|
|
240
|
+
}
|
|
241
|
+
const previous = baseline;
|
|
242
|
+
const next = new Map();
|
|
243
|
+
const narrowed = [];
|
|
244
|
+
for (const username of new Set([...previous.keys(), ...desired.keys()])) {
|
|
245
|
+
const before = previous.get(username) ?? [];
|
|
246
|
+
const kept = complete ? [] : before.filter(isGrantGroup);
|
|
247
|
+
const after = [...new Set([...(desired.get(username) ?? []), ...kept])];
|
|
248
|
+
if (after.length > 0) next.set(username, after);
|
|
249
|
+
const lost = before.filter((g) => !after.includes(g));
|
|
250
|
+
if (lost.length > 0 && held.get(username)?.active) {
|
|
251
|
+
narrowed.push({ username, lost, before, after });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
baseline = next;
|
|
255
|
+
return narrowed;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The user-facing OIDC clients that sign users in with the device-code
|
|
260
|
+
* flow, each with its domain's group key (core `reachedOidcClients`) —
|
|
261
|
+
* those Authelia checks against the groups a session last refreshed.
|
|
262
|
+
*
|
|
263
|
+
* @param {ReadonlyArray<{ name: string, groupKey: string }>} domains the active domains
|
|
264
|
+
* @returns {Promise<Array<{ id: string, groupKey: string }>>}
|
|
265
|
+
*/
|
|
266
|
+
export async function deviceCodeClients(domains) {
|
|
267
|
+
const keys = new Map(domains.map((d) => [d.name, d.groupKey]));
|
|
268
|
+
const { planOidcClients } = await import('./plugin-oidc.js');
|
|
269
|
+
const { served } = await planOidcClients();
|
|
270
|
+
return served
|
|
271
|
+
.filter((c) => isUserFacingOidcClient(c) && c.grantTypes.includes(DEVICE_CODE_GRANT))
|
|
272
|
+
.filter((c) => keys.has(c.domain))
|
|
273
|
+
.map((c) => ({ id: c.id, groupKey: keys.get(c.domain) }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The OAuth grant type of the device-code flow. */
|
|
277
|
+
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Revoke every Gatekeeper grant on the OIDC clients `clientIds` — a plugin
|
|
281
|
+
* uninstalled takes the grants on its clients with it.
|
|
282
|
+
*
|
|
283
|
+
* @param {readonly string[]} clientIds
|
|
284
|
+
* @returns {Promise<{ ok: true, revoked: number } | { ok: false, error: string }>}
|
|
285
|
+
*/
|
|
286
|
+
export async function revokeOidcClientGrants(clientIds) {
|
|
287
|
+
if (clientIds.length === 0) return { ok: true, revoked: 0 };
|
|
288
|
+
const list = await listGrantsOfType(OIDC_CLIENT_RESOURCE);
|
|
289
|
+
if (!list.ok) return { ok: false, error: list.error };
|
|
290
|
+
let revoked = 0;
|
|
291
|
+
for (const grant of list.grants.filter((g) => clientIds.includes(g.resourceId))) {
|
|
292
|
+
const result = await revokeGatekeeperGrant(grant.grantId);
|
|
293
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
294
|
+
revoked += 1;
|
|
295
|
+
}
|
|
296
|
+
return { ok: true, revoked };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A grant the Gatekeeper proxy is about to create, checked when it names an
|
|
301
|
+
* OIDC client: the client must be one an installed plugin declares (or the
|
|
302
|
+
* panel's own) and sign users in — a `client_credentials` client has no
|
|
303
|
+
* user to grant (400 `unknown_oidc_client` / `service_oidc_client`). Any
|
|
304
|
+
* other body goes on as it came.
|
|
305
|
+
*
|
|
306
|
+
* @param {unknown} body
|
|
307
|
+
* @returns {Promise<unknown>}
|
|
308
|
+
*/
|
|
309
|
+
export async function checkOidcClientGrant(body) {
|
|
310
|
+
if (typeof body !== 'object' || body === null || body.resourceType !== OIDC_CLIENT_RESOURCE) {
|
|
311
|
+
return body;
|
|
312
|
+
}
|
|
313
|
+
const { collectPluginOidcClients } = await import('./plugin-oidc.js');
|
|
314
|
+
const client = (await collectPluginOidcClients()).find((c) => c.id === body.resourceId);
|
|
315
|
+
if (!client) {
|
|
316
|
+
throw httpError('No installed plugin declares this OIDC client', 400, {
|
|
317
|
+
code: 'unknown_oidc_client',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (!isUserFacingOidcClient(client)) {
|
|
321
|
+
throw httpError('This OIDC client signs in no user: there is nothing to grant', 400, {
|
|
322
|
+
code: 'service_oidc_client',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return body;
|
|
326
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The domain a plugin belongs to (the multi-domain relay): its OIDC
|
|
3
|
+
* clients' `{domain}` and sign-in, the tunnels that may be bound as its user
|
|
4
|
+
* surface, its web surface's hostname and its audience — the members of the
|
|
5
|
+
* domain. `plugins.domain` (state.db 0016); NULL is the primary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { withTunnelLock } from '@lamalibre/lamaste/server';
|
|
9
|
+
import { tunnelUserSurfaceRoutes } from '@lamalibre/lamaste';
|
|
10
|
+
import { httpError } from './app-error.js';
|
|
11
|
+
import { recordAuditBestEffort } from './audit.js';
|
|
12
|
+
import { pluginDomainName, resolveActiveDomain } from './domains.js';
|
|
13
|
+
import { findPlugin } from './plugins.js';
|
|
14
|
+
import { getStateDb } from './state-db.js';
|
|
15
|
+
import { readSites, readTunnels, withSitesLock } from './state.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Move plugin `name` to the active domain `domain`. Refused (409
|
|
19
|
+
* `surface_bound`) while a tunnel is bound as its user surface or it owns a
|
|
20
|
+
* web surface — both are hostnames of its present domain: unbind or delete
|
|
21
|
+
* them first. Under the tunnel and sites locks, which every binding and web
|
|
22
|
+
* surface change holds too. Then its OIDC clients are written again and the
|
|
23
|
+
* audiences recomputed.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} name
|
|
26
|
+
* @param {string} domain
|
|
27
|
+
* @param {{ actor: string, logger: import('pino').Logger }} opts
|
|
28
|
+
* @returns {Promise<{ name: string, domain: string, changed: boolean }>}
|
|
29
|
+
*/
|
|
30
|
+
export async function setPluginDomain(name, domain, { actor, logger }) {
|
|
31
|
+
const result = await withTunnelLock(() =>
|
|
32
|
+
withSitesLock(async () => {
|
|
33
|
+
const plugin = await findPlugin(name);
|
|
34
|
+
if (!plugin) throw httpError(`Plugin "${name}" is not installed`, 404, { code: 'not_found' });
|
|
35
|
+
const target = await resolveActiveDomain(domain);
|
|
36
|
+
const current = await pluginDomainName(plugin);
|
|
37
|
+
if (current === target) return { name, domain: target, changed: false };
|
|
38
|
+
if (plugin.route) {
|
|
39
|
+
const [tunnels, sites] = await Promise.all([readTunnels(), readSites()]);
|
|
40
|
+
const bound = tunnels.find((t) => tunnelUserSurfaceRoutes(t).includes(plugin.route));
|
|
41
|
+
const owned = sites.find((s) => s.owner?.plugin === plugin.route);
|
|
42
|
+
if (bound || owned) {
|
|
43
|
+
throw httpError(
|
|
44
|
+
`Plugin "${name}" is served on ${(bound ?? owned).fqdn ?? 'a host'} of ${current} — unbind its user surface and delete its web surface first`,
|
|
45
|
+
409,
|
|
46
|
+
{ code: 'surface_bound' },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const db = await getStateDb();
|
|
51
|
+
db.prepare('UPDATE plugins SET domain = ? WHERE name = ?').run(target, name);
|
|
52
|
+
await recordAuditBestEffort(
|
|
53
|
+
{
|
|
54
|
+
actor,
|
|
55
|
+
action: 'plugin.domain.changed',
|
|
56
|
+
plugin: name,
|
|
57
|
+
details: { from: current, to: target },
|
|
58
|
+
},
|
|
59
|
+
logger,
|
|
60
|
+
);
|
|
61
|
+
return { name, domain: target, changed: true };
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
if (result.changed) {
|
|
65
|
+
const { syncOidcClients } = await import('./plugin-oidc.js');
|
|
66
|
+
try {
|
|
67
|
+
await syncOidcClients(logger);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
logger.warn(
|
|
70
|
+
{ plugin: name, err: err.message },
|
|
71
|
+
'OIDC clients not rewritten for a new domain',
|
|
72
|
+
);
|
|
73
|
+
// The identity tick syncs them again within a minute; until then the
|
|
74
|
+
// old domain's members still sign in to its clients — say so.
|
|
75
|
+
result.warnings = [
|
|
76
|
+
{
|
|
77
|
+
step: 'oidc',
|
|
78
|
+
message: `The plugin's OIDC clients still admit the previous domain's members (${err.message}); the panel retries within a minute`,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
const { recomputeAudiences } = await import('./audience.js');
|
|
83
|
+
await recomputeAudiences({ logger });
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
@@ -218,28 +218,45 @@ export function installedAgentHalf(plugin) {
|
|
|
218
218
|
return { ...declaration, version: pin.version, integrity: pin.integrity };
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The web tree a plugin version's approval covers: `{ treeSha256 }` when it
|
|
223
|
+
* declares a web surface (`userSurface.web`), null otherwise. A declared
|
|
224
|
+
* surface without the tree's digest cannot be approved.
|
|
225
|
+
*/
|
|
226
|
+
function installedWebTree(plugin) {
|
|
227
|
+
if (!plugin.userSurface?.web) return null;
|
|
228
|
+
if (typeof plugin.webTreeSha256 !== 'string') {
|
|
229
|
+
throw new Error(`Plugin "${plugin.name}" declares a web surface but its web tree is unknown`);
|
|
230
|
+
}
|
|
231
|
+
return { treeSha256: plugin.webTreeSha256 };
|
|
232
|
+
}
|
|
233
|
+
|
|
221
234
|
/**
|
|
222
235
|
* What approving a plugin version covers — its relay hook set, the agent
|
|
223
|
-
* half Lamaste installs
|
|
224
|
-
* stores it) — and the digest the approval names.
|
|
225
|
-
* there is nothing to approve. A plugin without an
|
|
226
|
-
* digest it had before
|
|
236
|
+
* half Lamaste installs, its identity block (normalized, as the registry
|
|
237
|
+
* stores it) and its web tree — and the digest the approval names.
|
|
238
|
+
* `digest` is null when there is nothing to approve. A plugin without an
|
|
239
|
+
* identity block or a web surface has the digest it had before they
|
|
240
|
+
* existed.
|
|
227
241
|
*/
|
|
228
242
|
export function installedApproval(plugin) {
|
|
229
243
|
const relay = installedRelayHooks(plugin);
|
|
230
244
|
const agentHalf = installedAgentHalf(plugin);
|
|
231
245
|
const identity = plugin.identity ?? null;
|
|
232
|
-
const
|
|
246
|
+
const web = installedWebTree(plugin);
|
|
247
|
+
const required = relay !== null || agentHalf !== null || identity !== null || web !== null;
|
|
233
248
|
return {
|
|
234
249
|
relay,
|
|
235
250
|
agentHalf,
|
|
236
251
|
identity,
|
|
252
|
+
web,
|
|
237
253
|
required,
|
|
238
254
|
digest: required
|
|
239
255
|
? approvalDigest({
|
|
240
256
|
relay: relay ? { set: relay.set, entrySha256: relay.entrySha256 } : null,
|
|
241
257
|
agentHalf,
|
|
242
258
|
identity,
|
|
259
|
+
web,
|
|
243
260
|
})
|
|
244
261
|
: null,
|
|
245
262
|
};
|
|
@@ -269,7 +286,7 @@ export async function installedVersionApproved(plugin) {
|
|
|
269
286
|
*/
|
|
270
287
|
export async function hookStatus(plugin) {
|
|
271
288
|
const approval = installedApproval(plugin);
|
|
272
|
-
const { relay, agentHalf, identity } = approval;
|
|
289
|
+
const { relay, agentHalf, identity, web } = approval;
|
|
273
290
|
const agent = agentHookSetOf({ hooks: plugin.hooks });
|
|
274
291
|
const state = await readHookState(plugin.name);
|
|
275
292
|
const approved = isApproved(approval, state);
|
|
@@ -290,6 +307,12 @@ export async function hookStatus(plugin) {
|
|
|
290
307
|
approved: identity !== null && approved,
|
|
291
308
|
block: identity,
|
|
292
309
|
},
|
|
310
|
+
web: {
|
|
311
|
+
declared: web !== null,
|
|
312
|
+
approved: web !== null && approved,
|
|
313
|
+
treeSha256: web?.treeSha256 ?? null,
|
|
314
|
+
surface: plugin.userSurface?.web ?? null,
|
|
315
|
+
},
|
|
293
316
|
approval: {
|
|
294
317
|
required: approval.required,
|
|
295
318
|
state: !approval.required ? 'not-required' : approved ? 'approved' : 'pending',
|
|
@@ -319,16 +342,17 @@ export const IDENTITY_CHANGED = 'identity.changed';
|
|
|
319
342
|
/**
|
|
320
343
|
* The reason a staged version waits because what it brings — its relay
|
|
321
344
|
* hooks (`relay`), the agent half Lamaste installs (`agentHalf`), its
|
|
322
|
-
* identity block (`identity`); each truthy when it
|
|
323
|
-
* was approved.
|
|
345
|
+
* identity block (`identity`), its web tree (`web`); each truthy when it
|
|
346
|
+
* brings one — is not what was approved.
|
|
324
347
|
*
|
|
325
348
|
* @returns {{ code: string, reason: string }}
|
|
326
349
|
*/
|
|
327
|
-
export function digestUnapprovedReason({ relay, agentHalf, identity = null }) {
|
|
350
|
+
export function digestUnapprovedReason({ relay, agentHalf, identity = null, web = null }) {
|
|
328
351
|
const parts = [
|
|
329
|
-
...(relay || (!agentHalf && !identity) ? ['relay hooks'] : []),
|
|
352
|
+
...(relay || (!agentHalf && !identity && !web) ? ['relay hooks'] : []),
|
|
330
353
|
...(agentHalf ? ['agent half'] : []),
|
|
331
354
|
...(identity ? ['identity'] : []),
|
|
355
|
+
...(web ? ['web surface'] : []),
|
|
332
356
|
];
|
|
333
357
|
const named =
|
|
334
358
|
parts.length === 1
|
|
@@ -351,6 +375,7 @@ function pendingApprovalRequired(pending) {
|
|
|
351
375
|
relay: pending.preview,
|
|
352
376
|
agentHalf: pending.agentHalf,
|
|
353
377
|
identity: pending.identity?.after ?? null,
|
|
378
|
+
web: pending.web?.after ?? null,
|
|
354
379
|
}),
|
|
355
380
|
]
|
|
356
381
|
);
|
|
@@ -369,7 +394,10 @@ function pendingApprovalRequired(pending) {
|
|
|
369
394
|
* how it changes the identity block (`identity`: `{ before, after,
|
|
370
395
|
* rolesAdded, rolesRemoved, attributesAdded, attributesRemoved,
|
|
371
396
|
* labelsChanged }` with the normalized blocks, null when neither version
|
|
372
|
-
* declares one — part of the digest),
|
|
397
|
+
* declares one — part of the digest), its web tree (`web`: `{ before,
|
|
398
|
+
* after }`, the installed version's tree digest and the staged one's
|
|
399
|
+
* `{ sha256, files, totalBytes }`, null when neither declares a web
|
|
400
|
+
* surface — part of the digest), why it waits for an administrator
|
|
373
401
|
* (`approvalRequired`: `[{ code, reason }]`, `digest.unapproved`,
|
|
374
402
|
* `settings.secret-to-plain` and `identity.changed`; never empty), and —
|
|
375
403
|
* when the plugin has auto-approve on — why auto-approve did not cover it
|
|
@@ -385,6 +413,7 @@ export function pendingUpdateView(pending) {
|
|
|
385
413
|
agentHalf: pending.agentHalf ?? null,
|
|
386
414
|
settings: pending.settings ?? null,
|
|
387
415
|
identity: pending.identity ?? null,
|
|
416
|
+
web: pending.web ?? null,
|
|
388
417
|
approvalRequired: pendingApprovalRequired(pending),
|
|
389
418
|
autoApproveWithheld: pending.autoApproveWithheld ?? null,
|
|
390
419
|
};
|