@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
|
@@ -0,0 +1,1792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay's base domains and who is a member of each — the multi-domain
|
|
3
|
+
* relay (packages/core/docs/multi-domain-relay-design.md).
|
|
4
|
+
*
|
|
5
|
+
* One domain is the primary: the panel's (`panel.json` `domain`, `panel.`,
|
|
6
|
+
* `tunnel.`, agent panels). Others are added (`pending`), then activated —
|
|
7
|
+
* `auth.<domain>` issued and its vhost written, its session cookie rendered
|
|
8
|
+
* into Authelia — and serve tunnels, sites and plugins of their own. Domains
|
|
9
|
+
* never overlap and never share a registrable domain (core
|
|
10
|
+
* `assertDomainRegistrable`).
|
|
11
|
+
*
|
|
12
|
+
* **Membership** (`domain_members`, state.db 0016) is explicit, by user id:
|
|
13
|
+
* a person reaches a domain only as its member. A membership has a kind
|
|
14
|
+
* (state.db 0017, packages/core/docs/external-members-design.md): a
|
|
15
|
+
* `member` reaches what the domain gives its members, an `external` member
|
|
16
|
+
* only what names them — a grant on a restricted host or an OIDC client, a
|
|
17
|
+
* static site's listed users — and holds no Authelia group
|
|
18
|
+
* ({@link assertNoGroupsForExternal}).
|
|
19
|
+
*
|
|
20
|
+
* Who is a member of what is rendered — active users of active domains
|
|
21
|
+
* only — by {@link syncDomainAccess}: as groups Lamaste owns into
|
|
22
|
+
* users.yml (lib/owned-groups.js; Authelia watches the file and applies
|
|
23
|
+
* them at each user's next request, without a restart), into Gatekeeper's
|
|
24
|
+
* projection (`domains.json`, with the kinds), and as the rules naming
|
|
25
|
+
* those groups into Authelia's configuration (session cookies,
|
|
26
|
+
* `access_control` — core `updateDomainAccess`), which change, and restart
|
|
27
|
+
* Authelia, only with the set of domains and protected sites. It renders
|
|
28
|
+
* after every membership, user, domain, site, tunnel and grant change, and
|
|
29
|
+
* every minute (the identity tick). Everything that writes what members may
|
|
30
|
+
* do — that rendering and the OIDC provider (lib/plugin-oidc.js) — runs
|
|
31
|
+
* inside one lock ({@link withDomainRendering}) and reads the members there,
|
|
32
|
+
* so a membership that ended is never written back from an older read. A
|
|
33
|
+
* disabled user keeps their memberships and is rendered nowhere; a deleted
|
|
34
|
+
* user loses them (lib/directory.js). While the relay has one domain every
|
|
35
|
+
* new user becomes its member (decision 1); with two or more, a new user is
|
|
36
|
+
* given the domains they are created with.
|
|
37
|
+
*
|
|
38
|
+
* Audit entries name domains and user ids, never a username.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import crypto from 'node:crypto';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import { z } from 'zod';
|
|
44
|
+
import {
|
|
45
|
+
DNS_WILDCARD_PROBE_LABEL,
|
|
46
|
+
DOMAINS_PROJECTION_FILE,
|
|
47
|
+
DomainNameSchema,
|
|
48
|
+
MEMBERSHIP_KINDS,
|
|
49
|
+
PromiseChainMutex,
|
|
50
|
+
atomicWriteJSONIfChanged,
|
|
51
|
+
domainOfHost,
|
|
52
|
+
isAssignableGroupName,
|
|
53
|
+
isLamasteOwnedGroup,
|
|
54
|
+
isRelayHost,
|
|
55
|
+
isIdentityUuid,
|
|
56
|
+
isLamasteUsername,
|
|
57
|
+
reachedOidcClients,
|
|
58
|
+
} from '@lamalibre/lamaste';
|
|
59
|
+
import {
|
|
60
|
+
assertDomainRegistrable,
|
|
61
|
+
registryAudience,
|
|
62
|
+
registryIssuerDomain,
|
|
63
|
+
resolveHostA,
|
|
64
|
+
withTunnelLock,
|
|
65
|
+
} from '@lamalibre/lamaste/server';
|
|
66
|
+
import { httpError } from './app-error.js';
|
|
67
|
+
import { recordAuditBestEffort } from './audit.js';
|
|
68
|
+
import {
|
|
69
|
+
autheliaWatchesUsers,
|
|
70
|
+
heldOwnedGroups,
|
|
71
|
+
narrowOwnedGroupsOnly,
|
|
72
|
+
readUsersRaw,
|
|
73
|
+
reloadAuthelia,
|
|
74
|
+
takeUsersFileDirty,
|
|
75
|
+
updateDomainAccess,
|
|
76
|
+
writeOwnedGroups,
|
|
77
|
+
} from './authelia.js';
|
|
78
|
+
import { getConfig } from './config.js';
|
|
79
|
+
import { deviceCodeClients, desiredOwnedGroups, narrowingSince } from './owned-groups.js';
|
|
80
|
+
import { getStateDb } from './state-db.js';
|
|
81
|
+
import { readSites, readTunnels, withSitesLock } from './state.js';
|
|
82
|
+
import { withUsersLock } from './users-lock.js';
|
|
83
|
+
|
|
84
|
+
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
85
|
+
|
|
86
|
+
/** One change of the domains or their members at a time. */
|
|
87
|
+
const domainsMutex = new PromiseChainMutex();
|
|
88
|
+
/**
|
|
89
|
+
* One writer of what members may do at a time — Authelia's domain access,
|
|
90
|
+
* the projection, the OIDC provider — each reading the members under it.
|
|
91
|
+
*/
|
|
92
|
+
const renderMutex = new PromiseChainMutex();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @typedef {object} Domain
|
|
96
|
+
* @property {string} name
|
|
97
|
+
* @property {boolean} primary
|
|
98
|
+
* @property {'pending' | 'active'} status
|
|
99
|
+
* @property {string} createdAt
|
|
100
|
+
* @property {string | null} activatedAt
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/** @returns {Domain} */
|
|
104
|
+
function rowToDomain(row) {
|
|
105
|
+
return {
|
|
106
|
+
name: row.name,
|
|
107
|
+
primary: row.is_primary === 1,
|
|
108
|
+
status: row.status,
|
|
109
|
+
createdAt: row.created_at,
|
|
110
|
+
activatedAt: row.activated_at,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `fn(db)` in one `BEGIN IMMEDIATE` transaction, rolled back when it throws. */
|
|
115
|
+
function inTransaction(db, fn) {
|
|
116
|
+
db.exec('BEGIN IMMEDIATE');
|
|
117
|
+
try {
|
|
118
|
+
const result = fn(db);
|
|
119
|
+
db.exec('COMMIT');
|
|
120
|
+
return result;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
db.exec('ROLLBACK');
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `panel.json`'s domain — the primary's name — or null before onboarding set one (or loaded it). */
|
|
128
|
+
function configDomain() {
|
|
129
|
+
try {
|
|
130
|
+
return getConfig().domain ?? null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Reading
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/** One domain's row, or null. */
|
|
141
|
+
function domainRow(db, name) {
|
|
142
|
+
const row = db.prepare('SELECT * FROM domains WHERE name = ?').get(name);
|
|
143
|
+
return row ? rowToDomain(row) : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every domain: the primary first, then by name.
|
|
148
|
+
*
|
|
149
|
+
* @returns {Promise<Domain[]>}
|
|
150
|
+
*/
|
|
151
|
+
export async function listDomains() {
|
|
152
|
+
const db = await getStateDb();
|
|
153
|
+
return db.prepare('SELECT * FROM domains ORDER BY is_primary DESC, name').all().map(rowToDomain);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One domain by name, or null. */
|
|
157
|
+
export async function getDomain(name) {
|
|
158
|
+
return domainRow(await getStateDb(), name);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Every domain's name, pending ones too — what the relay's own hostnames are kept free on. */
|
|
162
|
+
export async function domainNames() {
|
|
163
|
+
return (await listDomains()).map((d) => d.name);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every domain whose hostnames are Lamaste's groups in users.yml (core
|
|
168
|
+
* `isLamasteOwnedGroup`): every domain record, pending ones too, and every
|
|
169
|
+
* domain removed from the relay — a host group it left on a user is
|
|
170
|
+
* stripped by the next rendering, never taken for an administrator's group.
|
|
171
|
+
*/
|
|
172
|
+
export async function ownedGroupDomains() {
|
|
173
|
+
const db = await getStateDb();
|
|
174
|
+
const retired = db.prepare('SELECT name FROM retired_domains').all();
|
|
175
|
+
return [...new Set([...(await domainNames()), ...retired.map((r) => r.name)])];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The group key of domain `name` (core owned-groups.ts), or null when there is no such domain. */
|
|
179
|
+
export async function domainGroupKey(name) {
|
|
180
|
+
const db = await getStateDb();
|
|
181
|
+
return db.prepare('SELECT group_key FROM domains WHERE name = ?').get(name)?.group_key ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A new domain's group key: 8 random bytes, hex (core `DOMAIN_GROUP_KEY_RE`). */
|
|
185
|
+
function newGroupKey() {
|
|
186
|
+
return crypto.randomBytes(8).toString('hex');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A tunnel, site or alias that holds one of the relay's own hostnames of
|
|
191
|
+
* `name` (`auth.`, `panel.`, `tunnel.`, `agent-*`): what was created there
|
|
192
|
+
* before the domain was added — its hostname, or null. A domain is added
|
|
193
|
+
* and activated only when none does: its sign-in vhost would share a
|
|
194
|
+
* `server_name` with it.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} name
|
|
197
|
+
*/
|
|
198
|
+
async function relayHostTaken(name) {
|
|
199
|
+
const [tunnels, sites] = await Promise.all([readTunnels(), readSites()]);
|
|
200
|
+
const hosts = [
|
|
201
|
+
...tunnels.flatMap((t) => (t.fqdn ? [t.fqdn] : [])),
|
|
202
|
+
...sites.flatMap((s) => [s.fqdn, ...(s.aliases ?? [])]),
|
|
203
|
+
];
|
|
204
|
+
return hosts.find((host) => isRelayHost(host, [name])) ?? null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Refuse (409 `relay_host_taken`) while something holds one of `name`'s relay hostnames. */
|
|
208
|
+
async function assertRelayHostsFree(name) {
|
|
209
|
+
const taken = await relayHostTaken(name);
|
|
210
|
+
if (taken !== null) {
|
|
211
|
+
throw httpError(
|
|
212
|
+
`'${taken}' is served by a tunnel or a site — the relay's own name on ${name}; remove it first`,
|
|
213
|
+
409,
|
|
214
|
+
{ code: 'relay_host_taken' },
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The active domains (the primary first). */
|
|
220
|
+
export async function activeDomains() {
|
|
221
|
+
return (await listDomains()).filter((d) => d.status === 'active');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The names of the active domains — what hostnames are checked against. */
|
|
225
|
+
export async function activeDomainNames() {
|
|
226
|
+
return (await activeDomains()).map((d) => d.name);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The primary domain's name: its record, or — before the first start that
|
|
231
|
+
* made it ({@link ensurePrimaryDomain}) — `panel.json`'s `domain`. Null
|
|
232
|
+
* before onboarding set one.
|
|
233
|
+
*/
|
|
234
|
+
export async function primaryDomainName() {
|
|
235
|
+
const db = await getStateDb();
|
|
236
|
+
return db.prepare('SELECT name FROM domains WHERE is_primary = 1').get()?.name ?? configDomain();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Whether user `userId` is a member — of kind `member`, not external — of
|
|
241
|
+
* the primary domain: who desktop user access (`/api/user-access/*`,
|
|
242
|
+
* primary only) is for. An external member reaches only what names them;
|
|
243
|
+
* the desktop's plugin grants are no such thing.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} userId
|
|
246
|
+
*/
|
|
247
|
+
export async function isPrimaryMember(userId) {
|
|
248
|
+
const primary = await primaryDomainName();
|
|
249
|
+
if (!primary) return false;
|
|
250
|
+
const db = await getStateDb();
|
|
251
|
+
const row = db
|
|
252
|
+
.prepare('SELECT kind FROM domain_members WHERE domain = ? AND user_id = ?')
|
|
253
|
+
.get(primary, userId);
|
|
254
|
+
return row?.kind === 'member';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The active domain hostname `fqdn` is under, or null when it is under none
|
|
259
|
+
* of them.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} fqdn
|
|
262
|
+
*/
|
|
263
|
+
export async function domainOfHostname(fqdn) {
|
|
264
|
+
return domainOfHost(fqdn, await activeDomainNames());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The domain a plugin belongs to: its own (`plugins.domain`), else `primary`. Pure. */
|
|
268
|
+
export function pluginDomainOr(plugin, primary) {
|
|
269
|
+
return plugin?.domain ?? primary;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Plugins as the API shows them: `domain` the name of the domain each
|
|
274
|
+
* belongs to (stored NULL means the primary — clients never interpret it).
|
|
275
|
+
*
|
|
276
|
+
* @template {{ domain?: string | null }} T
|
|
277
|
+
* @param {T[]} plugins
|
|
278
|
+
* @returns {Promise<T[]>}
|
|
279
|
+
*/
|
|
280
|
+
export async function withPluginDomains(plugins) {
|
|
281
|
+
const primary = await primaryDomainName();
|
|
282
|
+
return plugins.map((p) => ({ ...p, domain: pluginDomainOr(p, primary) }));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The domain a plugin belongs to: its own, else the primary.
|
|
287
|
+
*
|
|
288
|
+
* @param {{ domain?: string | null } | null | undefined} plugin
|
|
289
|
+
*/
|
|
290
|
+
export async function pluginDomainName(plugin) {
|
|
291
|
+
return pluginDomainOr(plugin, await primaryDomainName());
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The domain whose sign-in an invitation sends its user to: the first active
|
|
296
|
+
* one it names, else the primary (an invitation from before the multi-domain
|
|
297
|
+
* relay names none).
|
|
298
|
+
*/
|
|
299
|
+
export async function invitationSignInDomain(invitation) {
|
|
300
|
+
// The first that serves: a pending domain has no sign-in page yet.
|
|
301
|
+
const active = await activeDomainNames();
|
|
302
|
+
return (
|
|
303
|
+
invitationMemberships(invitation).find((m) => active.includes(m.name))?.name ??
|
|
304
|
+
(await primaryDomainName())
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The memberships an invitation names, `{ name, kind }` (an invitation
|
|
310
|
+
* made before kinds names bare domains: members).
|
|
311
|
+
*
|
|
312
|
+
* @param {{ domains?: unknown } | null | undefined} invitation
|
|
313
|
+
* @returns {Array<{ name: string, kind: 'member' | 'external' }>}
|
|
314
|
+
*/
|
|
315
|
+
export function invitationMemberships(invitation) {
|
|
316
|
+
const named = Array.isArray(invitation?.domains) ? invitation.domains : [];
|
|
317
|
+
return named.flatMap((d) => {
|
|
318
|
+
if (typeof d === 'string') return [{ name: d, kind: 'member' }];
|
|
319
|
+
if (d && typeof d.name === 'string' && (d.kind === 'member' || d.kind === 'external')) {
|
|
320
|
+
return [{ name: d.name, kind: d.kind }];
|
|
321
|
+
}
|
|
322
|
+
return [];
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The memberships an invitation still grants when it is accepted: those it
|
|
328
|
+
* names on domains that exist now **and existed when it was created** — a
|
|
329
|
+
* domain removed since and added again (possibly for someone else) is
|
|
330
|
+
* another domain, and the invitation grants it nothing. Decided at
|
|
331
|
+
* acceptance, so no removal has to chase invitations. Each keeps the kind
|
|
332
|
+
* the invitation named.
|
|
333
|
+
*
|
|
334
|
+
* @param {{ domains?: unknown, createdAt?: string }} invitation
|
|
335
|
+
* @returns {Promise<Array<{ name: string, kind: 'member' | 'external' }>>}
|
|
336
|
+
*/
|
|
337
|
+
export async function invitationDomainsNow(invitation) {
|
|
338
|
+
const named = invitationMemberships(invitation);
|
|
339
|
+
if (named.length === 0 || typeof invitation.createdAt !== 'string') return [];
|
|
340
|
+
const db = await getStateDb();
|
|
341
|
+
const stmt = db.prepare('SELECT 1 FROM domains WHERE name = ? AND created_at <= ?');
|
|
342
|
+
return named.filter((m) => stmt.get(m.name, invitation.createdAt) !== undefined);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The registry's domains for `config` (a stored registry credential, or
|
|
347
|
+
* null): the issuer domain its tokens carry (`issuerDomain`, else the
|
|
348
|
+
* primary) and the domain its own host is under — Feria's, whose members
|
|
349
|
+
* `feria login` admits. Pure apart from the domain lists.
|
|
350
|
+
*
|
|
351
|
+
* @param {object | null} config
|
|
352
|
+
* @returns {Promise<string[]>}
|
|
353
|
+
*/
|
|
354
|
+
export async function registryDomainsOf(config) {
|
|
355
|
+
const primary = await primaryDomainName();
|
|
356
|
+
if (!config) return primary ? [primary] : [];
|
|
357
|
+
const names = await activeDomainNames();
|
|
358
|
+
let host = null;
|
|
359
|
+
try {
|
|
360
|
+
host = domainOfHost(new URL(config.registryUrl).hostname, names);
|
|
361
|
+
} catch {
|
|
362
|
+
// An unparseable URL names no domain.
|
|
363
|
+
}
|
|
364
|
+
return [...new Set([registryIssuerDomain(config.auth, primary), host].filter(Boolean))];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The stored registry credential's domains ({@link registryDomainsOf}). */
|
|
368
|
+
export async function registryDomains() {
|
|
369
|
+
const { readRegistryCredentialConfig } = await import('./registry-credential.js');
|
|
370
|
+
return registryDomainsOf(await readRegistryCredentialConfig().catch(() => null));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The active domain `requested` names — the primary when it names none — or
|
|
375
|
+
* a 400 the route answers with: a tunnel, a site or a plugin is put only on
|
|
376
|
+
* a domain that serves.
|
|
377
|
+
*
|
|
378
|
+
* @param {string | undefined | null} requested
|
|
379
|
+
* @returns {Promise<string>}
|
|
380
|
+
*/
|
|
381
|
+
export async function resolveActiveDomain(requested) {
|
|
382
|
+
if (requested === undefined || requested === null) {
|
|
383
|
+
const primary = await primaryDomainName();
|
|
384
|
+
if (!primary) throw httpError('The panel has no domain yet', 400, { code: 'no_domain' });
|
|
385
|
+
return primary;
|
|
386
|
+
}
|
|
387
|
+
const wanted = String(requested).toLowerCase();
|
|
388
|
+
if (!(await activeDomainNames()).includes(wanted)) {
|
|
389
|
+
throw httpError(`'${wanted}' is not an active domain of this relay`, 400, {
|
|
390
|
+
code: 'unknown_domain',
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return wanted;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Run `fn` while `name` (when given) is an active domain and cannot be
|
|
398
|
+
* removed: under the domains lock, which a domain's removal holds for its
|
|
399
|
+
* usage check. The same 400 `unknown_domain` as {@link resolveActiveDomain}
|
|
400
|
+
* when it is no longer active — for writes that name a domain outside the
|
|
401
|
+
* tunnel and sites locks (the registry credential). Workflows under those
|
|
402
|
+
* locks call {@link resolveActiveDomain} there instead.
|
|
403
|
+
*
|
|
404
|
+
* @template T
|
|
405
|
+
* @param {string | undefined | null} name
|
|
406
|
+
* @param {() => Promise<T>} fn
|
|
407
|
+
* @returns {Promise<T>}
|
|
408
|
+
*/
|
|
409
|
+
export function withActiveDomain(name, fn) {
|
|
410
|
+
return domainsMutex.run(async () => {
|
|
411
|
+
if (name) await resolveActiveDomain(name);
|
|
412
|
+
return fn();
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Every member of `name` the directory still holds (removed users have no
|
|
418
|
+
* membership): id, username, status, the membership's kind and when it was
|
|
419
|
+
* added.
|
|
420
|
+
*
|
|
421
|
+
* @param {string} name
|
|
422
|
+
*/
|
|
423
|
+
export async function listDomainMembers(name) {
|
|
424
|
+
const db = await getStateDb();
|
|
425
|
+
return db
|
|
426
|
+
.prepare(
|
|
427
|
+
`SELECT dm.user_id, u.username, u.status, dm.kind, dm.added_at, dm.added_by
|
|
428
|
+
FROM domain_members dm JOIN directory_users u ON u.user_id = dm.user_id
|
|
429
|
+
WHERE dm.domain = ? ORDER BY u.username`,
|
|
430
|
+
)
|
|
431
|
+
.all(name)
|
|
432
|
+
.map((row) => ({
|
|
433
|
+
userId: row.user_id,
|
|
434
|
+
username: row.username,
|
|
435
|
+
status: row.status,
|
|
436
|
+
kind: row.kind,
|
|
437
|
+
addedAt: row.added_at,
|
|
438
|
+
addedBy: row.added_by,
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Each user's memberships, by user id: `{ name, kind }`, by domain name.
|
|
444
|
+
*
|
|
445
|
+
* @returns {Promise<Map<string, Array<{ name: string, kind: 'member' | 'external' }>>>}
|
|
446
|
+
*/
|
|
447
|
+
export async function domainsByUser() {
|
|
448
|
+
const db = await getStateDb();
|
|
449
|
+
const map = new Map();
|
|
450
|
+
for (const { user_id: userId, domain, kind } of db
|
|
451
|
+
.prepare('SELECT user_id, domain, kind FROM domain_members ORDER BY domain')
|
|
452
|
+
.all()) {
|
|
453
|
+
map.set(userId, [...(map.get(userId) ?? []), { name: domain, kind }]);
|
|
454
|
+
}
|
|
455
|
+
return map;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Each domain's members, by domain: their user ids.
|
|
460
|
+
*
|
|
461
|
+
* @returns {Promise<Map<string, Set<string>>>}
|
|
462
|
+
*/
|
|
463
|
+
export async function membersByDomain() {
|
|
464
|
+
const db = await getStateDb();
|
|
465
|
+
const map = new Map();
|
|
466
|
+
for (const { domain, user_id: userId } of db
|
|
467
|
+
.prepare('SELECT domain, user_id FROM domain_members')
|
|
468
|
+
.all()) {
|
|
469
|
+
if (!map.has(domain)) map.set(domain, new Set());
|
|
470
|
+
map.get(domain).add(userId);
|
|
471
|
+
}
|
|
472
|
+
return map;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The memberships of `userId`: `{ name, kind }`, by domain name.
|
|
477
|
+
*
|
|
478
|
+
* @returns {Promise<Array<{ name: string, kind: 'member' | 'external' }>>}
|
|
479
|
+
*/
|
|
480
|
+
export async function domainsOfUser(userId) {
|
|
481
|
+
const db = await getStateDb();
|
|
482
|
+
return db
|
|
483
|
+
.prepare('SELECT domain, kind FROM domain_members WHERE user_id = ? ORDER BY domain')
|
|
484
|
+
.all(userId)
|
|
485
|
+
.map((row) => ({ name: row.domain, kind: row.kind }));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The domains, the primary first, with how many members and external
|
|
490
|
+
* members each has and what uses it (as {@link domainUsage}) — read once
|
|
491
|
+
* for all of them.
|
|
492
|
+
*/
|
|
493
|
+
export async function domainsOverview() {
|
|
494
|
+
const db = await getStateDb();
|
|
495
|
+
const domains = await listDomains();
|
|
496
|
+
const counts = new Map(
|
|
497
|
+
db
|
|
498
|
+
.prepare(
|
|
499
|
+
`SELECT domain, SUM(kind = 'member') AS members, SUM(kind = 'external') AS externals
|
|
500
|
+
FROM domain_members GROUP BY domain`,
|
|
501
|
+
)
|
|
502
|
+
.all()
|
|
503
|
+
.map((row) => [row.domain, row]),
|
|
504
|
+
);
|
|
505
|
+
const usage = await usageByDomain(domains.map((d) => d.name));
|
|
506
|
+
return domains.map((d) => ({
|
|
507
|
+
...d,
|
|
508
|
+
members: counts.get(d.name)?.members ?? 0,
|
|
509
|
+
externalMembers: counts.get(d.name)?.externals ?? 0,
|
|
510
|
+
usedBy: usage.get(d.name),
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Memberships inside the directory's transactions
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Whether new users must be given their domains: the relay has two or more
|
|
520
|
+
* (pending ones count). With one, every new user becomes its member.
|
|
521
|
+
*
|
|
522
|
+
* @param {ReadonlyArray<unknown>} domains
|
|
523
|
+
*/
|
|
524
|
+
export function newUsersNeedDomains(domains) {
|
|
525
|
+
return domains.length > 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* One membership as the API takes it: a domain name — a `member` — or
|
|
530
|
+
* `{ name, kind }`.
|
|
531
|
+
*/
|
|
532
|
+
export const MembershipInputSchema = z.union([
|
|
533
|
+
DomainNameSchema,
|
|
534
|
+
z.object({ name: DomainNameSchema, kind: z.enum(MEMBERSHIP_KINDS) }).strict(),
|
|
535
|
+
]);
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Memberships as the API took them, each domain once, names in lower case:
|
|
539
|
+
* a bare name is a `member`. Throws a 400 `conflicting_membership` when one
|
|
540
|
+
* domain is named with two kinds.
|
|
541
|
+
*
|
|
542
|
+
* @param {ReadonlyArray<string | { name: string, kind: 'member' | 'external' }>} input
|
|
543
|
+
* @returns {Array<{ name: string, kind: 'member' | 'external' }>}
|
|
544
|
+
*/
|
|
545
|
+
export function normalizeMemberships(input) {
|
|
546
|
+
const byName = new Map();
|
|
547
|
+
for (const entry of input) {
|
|
548
|
+
const { name, kind } =
|
|
549
|
+
typeof entry === 'string' ? { name: entry, kind: 'member' } : { kind: 'member', ...entry };
|
|
550
|
+
const lower = String(name).toLowerCase();
|
|
551
|
+
const seen = byName.get(lower);
|
|
552
|
+
if (seen && seen !== kind) {
|
|
553
|
+
throw httpError(`'${lower}' is named both as a member and as an external member`, 400, {
|
|
554
|
+
code: 'conflicting_membership',
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
byName.set(lower, kind);
|
|
558
|
+
}
|
|
559
|
+
return [...byName].map(([name, kind]) => ({ name, kind }));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* `groups` without the ones Lamaste owns (`domains`: every domain record) —
|
|
564
|
+
* what anything outside Authelia is shown of a user's groups: the
|
|
565
|
+
* rendering's are nobody's choice, and who is a member of which domain is
|
|
566
|
+
* shown as memberships.
|
|
567
|
+
*
|
|
568
|
+
* @param {unknown} groups
|
|
569
|
+
* @param {readonly string[]} domains
|
|
570
|
+
* @returns {string[]}
|
|
571
|
+
*/
|
|
572
|
+
export function adminGroups(groups, domains) {
|
|
573
|
+
return Array.isArray(groups)
|
|
574
|
+
? groups.filter((g) => typeof g === 'string' && !isLamasteOwnedGroup(g, domains))
|
|
575
|
+
: [];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The groups an administrator gave `username` in users.yml
|
|
580
|
+
* ({@link adminGroups}).
|
|
581
|
+
*
|
|
582
|
+
* @param {string} username
|
|
583
|
+
* @returns {Promise<string[]>}
|
|
584
|
+
*/
|
|
585
|
+
export async function adminGroupsOf(username) {
|
|
586
|
+
const [users, names] = await Promise.all([
|
|
587
|
+
readUsersRaw().then((data) => data.users ?? {}),
|
|
588
|
+
domainNames(),
|
|
589
|
+
]);
|
|
590
|
+
return adminGroups(users[username]?.groups, names);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The refusal (`external_member_groups`) of Authelia groups for an external
|
|
595
|
+
* member: groups grant by membership, relay-wide (`admins`, `feria-*`), and
|
|
596
|
+
* an external member reaches only what names them. 409 when the user's
|
|
597
|
+
* state is in the way, 400 when the request itself asks for both.
|
|
598
|
+
*
|
|
599
|
+
* @param {400 | 409} statusCode
|
|
600
|
+
*/
|
|
601
|
+
export function externalMemberGroupsError(statusCode) {
|
|
602
|
+
return httpError(
|
|
603
|
+
"An external member holds no Authelia group: remove the user's groups or the external memberships first",
|
|
604
|
+
statusCode,
|
|
605
|
+
{ code: 'external_member_groups' },
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Refuse (400 `reserved_group`) a group an administrator may not assign —
|
|
611
|
+
* one that could be a hostname or one of Lamaste's own groups (core
|
|
612
|
+
* `isAssignableGroupName`) — unless the user holds it already (a group from
|
|
613
|
+
* before the rule).
|
|
614
|
+
*
|
|
615
|
+
* @param {readonly string[]} groups
|
|
616
|
+
* @param {readonly string[]} [held]
|
|
617
|
+
*/
|
|
618
|
+
export function assertAssignableGroups(groups, held = []) {
|
|
619
|
+
if (groups.every((g) => isAssignableGroupName(g) || held.includes(g))) return;
|
|
620
|
+
throw httpError(
|
|
621
|
+
'A group name is letters, digits, "-" and "_" (1–64): names with ".", ":" or "@" are reserved',
|
|
622
|
+
400,
|
|
623
|
+
{ code: 'reserved_group' },
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Refuse (409 `external_member_groups`) memberships that make a user who
|
|
629
|
+
* holds Authelia groups an external member anywhere. Call it holding the
|
|
630
|
+
* users lock (the users API changes groups under it).
|
|
631
|
+
*
|
|
632
|
+
* @param {string | null} username
|
|
633
|
+
* @param {ReadonlyArray<{ kind: 'member' | 'external' }>} memberships
|
|
634
|
+
*/
|
|
635
|
+
export async function assertNoGroupsForExternal(username, memberships) {
|
|
636
|
+
if (!username || !memberships.some((m) => m.kind === 'external')) return;
|
|
637
|
+
if ((await adminGroupsOf(username)).length > 0) throw externalMemberGroupsError(409);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The memberships a user created now is given: `requested` (each an
|
|
642
|
+
* existing domain, pending ones too, so an activation goes live with its
|
|
643
|
+
* members; a bare name is a `member`), or — when none are requested and the
|
|
644
|
+
* relay has exactly one domain — a membership of that one. With two or more
|
|
645
|
+
* domains and none requested: none (a user adopted from users.yml waits for
|
|
646
|
+
* an administrator), or with `required` a 400 `domains_required` (the users
|
|
647
|
+
* API and invitations). Throws a 400 `unknown_domain` on a domain the relay
|
|
648
|
+
* does not have.
|
|
649
|
+
*
|
|
650
|
+
* @param {ReadonlyArray<string | { name: string, kind: 'member' | 'external' }> | undefined} requested
|
|
651
|
+
* @param {{ required?: boolean }} [opts]
|
|
652
|
+
* @returns {Promise<Array<{ name: string, kind: 'member' | 'external' }>>}
|
|
653
|
+
*/
|
|
654
|
+
export async function domainsForNewUser(requested, { required = false } = {}) {
|
|
655
|
+
const domains = await listDomains();
|
|
656
|
+
if (requested === undefined && !newUsersNeedDomains(domains)) {
|
|
657
|
+
return domains.length === 1 ? [{ name: domains[0].name, kind: 'member' }] : [];
|
|
658
|
+
}
|
|
659
|
+
// A user who is a member of nothing reaches nothing: when domains are
|
|
660
|
+
// asked for, none — or an empty list — is refused.
|
|
661
|
+
if (requested === undefined || requested.length === 0) {
|
|
662
|
+
if (!required) return [];
|
|
663
|
+
throw httpError(
|
|
664
|
+
domains.length > 1
|
|
665
|
+
? 'Name the domains the user is a member of: this relay has more than one'
|
|
666
|
+
: 'A new user is a member of at least one domain',
|
|
667
|
+
400,
|
|
668
|
+
{ code: 'domains_required' },
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
const names = new Set(domains.map((d) => d.name));
|
|
672
|
+
const wanted = normalizeMemberships(requested);
|
|
673
|
+
const unknown = wanted.find((m) => !names.has(m.name));
|
|
674
|
+
if (unknown !== undefined) {
|
|
675
|
+
throw httpError(`'${unknown.name}' is not a domain of this relay`, 400, {
|
|
676
|
+
code: 'unknown_domain',
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return wanted;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Give `userId` `memberships`, inside the caller's transaction: the rows
|
|
684
|
+
* that are new — a membership the user holds already keeps its kind — and
|
|
685
|
+
* the audit entries for them (to record once the transaction committed).
|
|
686
|
+
*
|
|
687
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
688
|
+
* @param {string} userId
|
|
689
|
+
* @param {ReadonlyArray<string | { name: string, kind: 'member' | 'external' }>} memberships
|
|
690
|
+
* a bare domain name is a `member`
|
|
691
|
+
* @param {{ actor: string, now: string }} opts
|
|
692
|
+
* @returns {Array<{ action: string, details: object }>}
|
|
693
|
+
*/
|
|
694
|
+
export function insertMembershipsSync(db, userId, memberships, { actor, now }) {
|
|
695
|
+
const insert = db.prepare(
|
|
696
|
+
`INSERT OR IGNORE INTO domain_members (domain, user_id, kind, added_at, added_by)
|
|
697
|
+
SELECT name, ?, ?, ?, ? FROM domains WHERE name = ?`,
|
|
698
|
+
);
|
|
699
|
+
const events = [];
|
|
700
|
+
for (const { name: domain, kind } of normalizeMemberships(memberships)) {
|
|
701
|
+
if (insert.run(userId, kind, now, actor, domain).changes > 0) {
|
|
702
|
+
events.push({ action: 'directory.membership.added', details: { domain, userId, kind } });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return events;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* End every membership of `userId`, inside the caller's transaction; returns
|
|
710
|
+
* the audit entries (`reason`, e.g. `deleted`).
|
|
711
|
+
*
|
|
712
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
713
|
+
* @param {string} userId
|
|
714
|
+
* @param {string} reason
|
|
715
|
+
* @returns {Array<{ action: string, details: object }>}
|
|
716
|
+
*/
|
|
717
|
+
export function deleteMembershipsSync(db, userId, reason) {
|
|
718
|
+
return db
|
|
719
|
+
.prepare('DELETE FROM domain_members WHERE user_id = ? RETURNING domain')
|
|
720
|
+
.all(userId)
|
|
721
|
+
.map(({ domain }) => ({
|
|
722
|
+
action: 'directory.membership.removed',
|
|
723
|
+
details: { domain, userId, reason },
|
|
724
|
+
}));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
// The primary domain
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Make the primary domain's record from `panel.json`'s `domain` when there
|
|
733
|
+
* is none yet — at the first start after onboarding, or of a version from
|
|
734
|
+
* before the multi-domain relay — with every user the directory holds as its
|
|
735
|
+
* member (`added_by: 'migration'`): a relay with one domain lets in whom it
|
|
736
|
+
* let in before. Idempotent. Returns the primary's name, or null without a
|
|
737
|
+
* domain.
|
|
738
|
+
*
|
|
739
|
+
* @param {{ logger?: import('pino').Logger }} [opts]
|
|
740
|
+
*/
|
|
741
|
+
export async function ensurePrimaryDomain({ logger } = {}) {
|
|
742
|
+
return domainsMutex.run(async () => {
|
|
743
|
+
const db = await getStateDb();
|
|
744
|
+
const existing = db.prepare('SELECT name FROM domains WHERE is_primary = 1').get();
|
|
745
|
+
if (existing) return existing.name;
|
|
746
|
+
const domain = configDomain();
|
|
747
|
+
if (!domain) return null;
|
|
748
|
+
const now = new Date().toISOString();
|
|
749
|
+
const members = inTransaction(db, () => {
|
|
750
|
+
db.prepare(
|
|
751
|
+
`INSERT INTO domains (name, is_primary, status, created_at, activated_at, group_key)
|
|
752
|
+
VALUES (?, 1, 'active', ?, ?, ?)`,
|
|
753
|
+
).run(domain, now, now, newGroupKey());
|
|
754
|
+
return db
|
|
755
|
+
.prepare(
|
|
756
|
+
`INSERT INTO domain_members (domain, user_id, added_at, added_by)
|
|
757
|
+
SELECT ?, user_id, ?, 'migration' FROM directory_users WHERE status <> 'removed'`,
|
|
758
|
+
)
|
|
759
|
+
.run(domain, now).changes;
|
|
760
|
+
});
|
|
761
|
+
await recordAuditBestEffort(
|
|
762
|
+
{ actor: 'lamaste', action: 'domain.added', details: { domain, primary: true, members } },
|
|
763
|
+
logger,
|
|
764
|
+
);
|
|
765
|
+
logger?.info({ domain, members }, 'Primary domain recorded');
|
|
766
|
+
return domain;
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// Rendering
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
/** The protected-site rules Authelia renders, from `sites.json`. */
|
|
775
|
+
function protectedSiteRules(sites) {
|
|
776
|
+
return sites
|
|
777
|
+
.filter((s) => s && typeof s.fqdn === 'string')
|
|
778
|
+
.map((s) => ({
|
|
779
|
+
...(typeof s.id === 'string' ? { id: s.id } : {}),
|
|
780
|
+
fqdn: s.fqdn,
|
|
781
|
+
autheliaProtected: s.autheliaProtected === true,
|
|
782
|
+
allowedUsers: Array.isArray(s.allowedUsers) ? s.allowedUsers : [],
|
|
783
|
+
...(s.restrictAccess === true ? { restrictAccess: true } : {}),
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Logged once per count, so a steady number of unrenderable members is not repeated every minute. */
|
|
788
|
+
let loggedIneligible = 0;
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The active domains with the usernames of their active members and of
|
|
792
|
+
* their active external members — what Authelia and Gatekeeper are told. A
|
|
793
|
+
* username users.yml holds that is not a Lamaste username is never rendered
|
|
794
|
+
* (counted, never named). Read it under {@link withDomainRendering} to write
|
|
795
|
+
* anything from it.
|
|
796
|
+
*
|
|
797
|
+
* @param {import('pino').Logger} [logger]
|
|
798
|
+
* @returns {Promise<Array<{ name: string, groupKey: string, members: string[], externals: string[] }>>}
|
|
799
|
+
*/
|
|
800
|
+
async function domainsWithMembers(logger) {
|
|
801
|
+
const db = await getStateDb();
|
|
802
|
+
const domains = db
|
|
803
|
+
.prepare(
|
|
804
|
+
"SELECT name, group_key FROM domains WHERE status = 'active' ORDER BY is_primary DESC, name",
|
|
805
|
+
)
|
|
806
|
+
.all();
|
|
807
|
+
const rows = db
|
|
808
|
+
.prepare(
|
|
809
|
+
`SELECT dm.domain, dm.kind, u.username FROM domain_members dm
|
|
810
|
+
JOIN directory_users u ON u.user_id = dm.user_id
|
|
811
|
+
JOIN domains d ON d.name = dm.domain
|
|
812
|
+
WHERE d.status = 'active' AND u.status = 'active' AND u.username IS NOT NULL
|
|
813
|
+
ORDER BY u.username`,
|
|
814
|
+
)
|
|
815
|
+
.all();
|
|
816
|
+
let ineligible = 0;
|
|
817
|
+
const byDomain = new Map(domains.map((d) => [d.name, { members: [], externals: [] }]));
|
|
818
|
+
for (const row of rows) {
|
|
819
|
+
if (!isLamasteUsername(row.username)) {
|
|
820
|
+
ineligible += 1;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
byDomain
|
|
824
|
+
.get(row.domain)
|
|
825
|
+
?.[row.kind === 'external' ? 'externals' : 'members'].push(row.username);
|
|
826
|
+
}
|
|
827
|
+
if (ineligible !== loggedIneligible) {
|
|
828
|
+
loggedIneligible = ineligible;
|
|
829
|
+
if (ineligible > 0) {
|
|
830
|
+
logger?.warn({ ineligible }, 'Members whose username Authelia cannot be told are left out');
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return domains.map((d) => ({ name: d.name, groupKey: d.group_key, ...byDomain.get(d.name) }));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Run `fn(domains)` — `domains` the active domains with their active
|
|
838
|
+
* members, read now — as the one writer of what members may do: two
|
|
839
|
+
* writers would each write their own reading, and the later one could put
|
|
840
|
+
* back a member the earlier had removed. Every write of Authelia's domain
|
|
841
|
+
* access, the projection and the OIDC provider goes through here.
|
|
842
|
+
*
|
|
843
|
+
* @template T
|
|
844
|
+
* @param {(domains: Array<{ name: string, groupKey: string, members: string[], externals: string[] }>) => Promise<T>} fn
|
|
845
|
+
* @param {{ logger?: import('pino').Logger }} [opts]
|
|
846
|
+
* @returns {Promise<T>}
|
|
847
|
+
*/
|
|
848
|
+
export function withDomainRendering(fn, { logger } = {}) {
|
|
849
|
+
return renderMutex.run(async () => fn(await domainsWithMembers(logger)));
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Whether the last rendering failed — so a failure streak is audited and
|
|
854
|
+
* notified once, and its end once.
|
|
855
|
+
*/
|
|
856
|
+
let renderFailing = false;
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Render the domains and their people, in this order:
|
|
860
|
+
*
|
|
861
|
+
* 1. the groups Lamaste owns into users.yml (lib/owned-groups.js) — who is
|
|
862
|
+
* a member or an external member of which domain, the static sites'
|
|
863
|
+
* listed users, the hosts and OIDC clients that name each external
|
|
864
|
+
* member. Authelia applies them at each user's next request. When the
|
|
865
|
+
* grants cannot be read, external members hold no granted host or
|
|
866
|
+
* client (fail closed) and the step counts as failed;
|
|
867
|
+
* 2. Gatekeeper's projection (`domains.json`, v2: members and external
|
|
868
|
+
* members of each active domain);
|
|
869
|
+
* 3. Authelia's session cookies and `access_control` (`sites`: the
|
|
870
|
+
* protected sites, `sites.json` unless given — a site change passes the
|
|
871
|
+
* sites it is saving), whose rules name the groups of step 1 and change
|
|
872
|
+
* only with the domains and protected sites: Authelia restarts only then;
|
|
873
|
+
* 4. Authelia restarted when it must read users.yml anew: when its
|
|
874
|
+
* configuration does not have it watch the file (one that could not be
|
|
875
|
+
* brought in line) and the file changed; and when a narrowing reached a
|
|
876
|
+
* device-code OIDC client — Authelia's device flow checks the groups a
|
|
877
|
+
* session last refreshed, never re-reading them, so a session that lost
|
|
878
|
+
* a member's or a granted client's group must end.
|
|
879
|
+
*
|
|
880
|
+
* The groups come first: a rule never names a group that is not yet filled,
|
|
881
|
+
* and a host group never outlives the rule that narrows its host. Each step
|
|
882
|
+
* is always attempted — one failing never keeps another at older, more
|
|
883
|
+
* permissive members. Nothing before onboarding made a domain.
|
|
884
|
+
*
|
|
885
|
+
* Then, outside the rendering's lock, each enabled user who lost an owned
|
|
886
|
+
* group is followed: audited (`directory.access.narrowed`, by id), and their
|
|
887
|
+
* registry tokens revoked when they no longer reach the registry's sign-in
|
|
888
|
+
* — whatever narrowed them (a membership, a kind, a grant revoked through
|
|
889
|
+
* the panel or with Gatekeeper's own CLI).
|
|
890
|
+
*
|
|
891
|
+
* Throws when any step failed (callers report a warning; the identity tick
|
|
892
|
+
* renders again within a minute); the first failure of a streak is audited
|
|
893
|
+
* (`domain.render.failed`) and notified to the administrators, its end
|
|
894
|
+
* audited (`domain.render.recovered`).
|
|
895
|
+
*
|
|
896
|
+
* @param {{ logger?: import('pino').Logger, sites?: readonly object[] }} [opts]
|
|
897
|
+
* @returns {Promise<{ changed: boolean, groupsChanged: boolean, restarted: boolean, domains: number, warnings?: object[] }>}
|
|
898
|
+
*/
|
|
899
|
+
export async function syncDomainAccess({ logger, sites } = {}) {
|
|
900
|
+
const outcome = await withDomainRendering(
|
|
901
|
+
async (domains) => {
|
|
902
|
+
if (domains.length === 0) {
|
|
903
|
+
return { changed: false, groupsChanged: false, restarted: false, domains: 0, failures: [] };
|
|
904
|
+
}
|
|
905
|
+
const siteList = sites ?? (await readSites());
|
|
906
|
+
const failures = [];
|
|
907
|
+
let groupsChanged = false;
|
|
908
|
+
let narrowed = [];
|
|
909
|
+
try {
|
|
910
|
+
const allDomains = await ownedGroupDomains();
|
|
911
|
+
const owned = await desiredOwnedGroups({ domains, sites: siteList });
|
|
912
|
+
if (!owned.complete) {
|
|
913
|
+
logger?.warn(owned.warning);
|
|
914
|
+
failures.push({ step: 'grants', message: owned.warning });
|
|
915
|
+
}
|
|
916
|
+
const held = await heldOwnedGroups(allDomains);
|
|
917
|
+
({ changed: groupsChanged } = await writeOwnedGroups(owned.desired, allDomains));
|
|
918
|
+
narrowed = narrowingSince(owned.desired, owned.complete, held);
|
|
919
|
+
if (groupsChanged) logger?.info('Authelia users file groups updated');
|
|
920
|
+
} catch (err) {
|
|
921
|
+
narrowOwnedGroupsOnly();
|
|
922
|
+
failures.push({ step: 'groups', message: err.message });
|
|
923
|
+
}
|
|
924
|
+
try {
|
|
925
|
+
await atomicWriteJSONIfChanged(
|
|
926
|
+
path.join(STATE_DIR, DOMAINS_PROJECTION_FILE),
|
|
927
|
+
{
|
|
928
|
+
v: 2,
|
|
929
|
+
domains: domains.map(({ name, members, externals }) => ({ name, members, externals })),
|
|
930
|
+
},
|
|
931
|
+
{ mode: 0o600 },
|
|
932
|
+
);
|
|
933
|
+
} catch (err) {
|
|
934
|
+
failures.push({ step: 'gatekeeper', message: err.message });
|
|
935
|
+
}
|
|
936
|
+
let changed = false;
|
|
937
|
+
try {
|
|
938
|
+
({ changed } = await updateDomainAccess({
|
|
939
|
+
domains: domains.map(({ name, groupKey }) => ({ name, groupKey })),
|
|
940
|
+
sites: protectedSiteRules(siteList),
|
|
941
|
+
}));
|
|
942
|
+
if (changed) logger?.info({ domains: domains.length }, 'Authelia domain access updated');
|
|
943
|
+
} catch (err) {
|
|
944
|
+
failures.push({ step: 'authelia', message: err.message });
|
|
945
|
+
}
|
|
946
|
+
let restarted = changed;
|
|
947
|
+
if (changed) takeUsersFileDirty();
|
|
948
|
+
try {
|
|
949
|
+
const reason = changed ? null : await restartReason(domains, narrowed);
|
|
950
|
+
if (reason) {
|
|
951
|
+
await reloadAuthelia();
|
|
952
|
+
restarted = true;
|
|
953
|
+
logger?.info({ reason }, 'Authelia restarted to read users.yml anew');
|
|
954
|
+
await recordAuditBestEffort(
|
|
955
|
+
{ actor: 'lamaste', action: 'authelia.restarted', details: { reason } },
|
|
956
|
+
logger,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
} catch (err) {
|
|
960
|
+
failures.push({ step: 'restart', message: err.message });
|
|
961
|
+
}
|
|
962
|
+
await recordRenderOutcome(failures, logger);
|
|
963
|
+
return { changed, groupsChanged, restarted, domains: domains.length, failures, narrowed };
|
|
964
|
+
},
|
|
965
|
+
{ logger },
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
const { failures, narrowed = [], ...result } = outcome;
|
|
969
|
+
const warnings = [];
|
|
970
|
+
if (result.restarted) {
|
|
971
|
+
const { bustGatekeeperSessions } = await import('./gatekeeper-client.js');
|
|
972
|
+
const busted = await bustGatekeeperSessions();
|
|
973
|
+
if (!busted.ok) {
|
|
974
|
+
warnings.push({
|
|
975
|
+
step: 'gatekeeper',
|
|
976
|
+
message: `Gatekeeper did not drop its cached sessions (${busted.error}); they expire within 30 seconds`,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (narrowed.length > 0) warnings.push(...(await followNarrowing(narrowed, logger)));
|
|
981
|
+
if (failures.length > 0) {
|
|
982
|
+
const err = new Error(failures.map((f) => `${f.step}: ${f.message}`).join('; '));
|
|
983
|
+
err.warnings = warnings;
|
|
984
|
+
throw err;
|
|
985
|
+
}
|
|
986
|
+
return { ...result, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Why Authelia must be restarted after this rendering wrote users.yml, or
|
|
991
|
+
* null: `unwatched` — its configuration does not have it watch the file and
|
|
992
|
+
* the file changed (a configuration that could not be brought in line);
|
|
993
|
+
* `device-code` — an enabled user lost a group that admitted them to a
|
|
994
|
+
* device-code OIDC client ({@link syncDomainAccess} step 4). When the
|
|
995
|
+
* device-code clients cannot be read, any narrowing restarts (fail closed).
|
|
996
|
+
*/
|
|
997
|
+
async function restartReason(domains, narrowed) {
|
|
998
|
+
if (!(await autheliaWatchesUsers()) && takeUsersFileDirty()) return 'unwatched';
|
|
999
|
+
if (narrowed.length === 0) return null;
|
|
1000
|
+
let clients;
|
|
1001
|
+
try {
|
|
1002
|
+
clients = await deviceCodeClients(domains);
|
|
1003
|
+
} catch {
|
|
1004
|
+
return 'device-code';
|
|
1005
|
+
}
|
|
1006
|
+
if (clients.length === 0) return null;
|
|
1007
|
+
const lostReach = narrowed.some(({ before, after }) => {
|
|
1008
|
+
const still = new Set(reachedOidcClients(after, clients));
|
|
1009
|
+
return reachedOidcClients(before, clients).some((id) => !still.has(id));
|
|
1010
|
+
});
|
|
1011
|
+
return lostReach ? 'device-code' : null;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Follow the users a rendering narrowed: audit each (`directory.access.narrowed`,
|
|
1016
|
+
* by directory id and the number of groups lost — never a username or a
|
|
1017
|
+
* group), and revoke the registry tokens of those who no longer reach the
|
|
1018
|
+
* registry's sign-in. Best effort; returns warnings.
|
|
1019
|
+
*/
|
|
1020
|
+
async function followNarrowing(narrowed, logger) {
|
|
1021
|
+
const db = await getStateDb();
|
|
1022
|
+
const ids = new Map(
|
|
1023
|
+
db
|
|
1024
|
+
.prepare("SELECT user_id, username FROM directory_users WHERE status = 'active'")
|
|
1025
|
+
.all()
|
|
1026
|
+
.map((r) => [r.username, r.user_id]),
|
|
1027
|
+
);
|
|
1028
|
+
const warnings = [];
|
|
1029
|
+
for (const { username, lost } of narrowed) {
|
|
1030
|
+
const userId = ids.get(username);
|
|
1031
|
+
if (!userId) continue;
|
|
1032
|
+
await recordAuditBestEffort(
|
|
1033
|
+
{
|
|
1034
|
+
actor: 'lamaste',
|
|
1035
|
+
action: 'directory.access.narrowed',
|
|
1036
|
+
details: { userId, groups: lost.length },
|
|
1037
|
+
},
|
|
1038
|
+
logger,
|
|
1039
|
+
);
|
|
1040
|
+
warnings.push(...(await revokeRegistryTokensFor(username, userId, logger)));
|
|
1041
|
+
}
|
|
1042
|
+
return warnings;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** How soon a rendering a grant change could not finish is tried again. */
|
|
1046
|
+
const RENDER_RETRY_DELAY_MS = 5_000;
|
|
1047
|
+
let renderRetryTimer = null;
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Render once more shortly — after a grant or group change whose rendering
|
|
1051
|
+
* failed, so an external member a revocation narrowed is not left holding
|
|
1052
|
+
* the group for the identity tick's minute. Coalesced, best effort.
|
|
1053
|
+
*/
|
|
1054
|
+
export function scheduleRenderRetry(logger) {
|
|
1055
|
+
if (renderRetryTimer) return;
|
|
1056
|
+
renderRetryTimer = setTimeout(() => {
|
|
1057
|
+
renderRetryTimer = null;
|
|
1058
|
+
void syncDomainAccess({ logger }).catch(() => {
|
|
1059
|
+
// Audited by the rendering itself; the identity tick renders again.
|
|
1060
|
+
});
|
|
1061
|
+
}, RENDER_RETRY_DELAY_MS);
|
|
1062
|
+
renderRetryTimer.unref();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** How long a change of tunnels or sites waits before the hosts it names are rendered. */
|
|
1066
|
+
const HOST_RENDER_DELAY_MS = 100;
|
|
1067
|
+
let hostRenderTimer = null;
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* After tunnels.json or sites.json changed (lib/state.js): render the
|
|
1071
|
+
* domains once more, shortly — a restricted host's group names the external
|
|
1072
|
+
* members granted it, and a tunnel or web surface that became restricted,
|
|
1073
|
+
* stopped being one or went changes who holds which. Only while some
|
|
1074
|
+
* domain has external members (the host groups name no one else);
|
|
1075
|
+
* coalesced, best effort (the identity tick renders every minute anyway).
|
|
1076
|
+
*/
|
|
1077
|
+
export function scheduleHostRender() {
|
|
1078
|
+
if (hostRenderTimer) return;
|
|
1079
|
+
hostRenderTimer = setTimeout(() => {
|
|
1080
|
+
hostRenderTimer = null;
|
|
1081
|
+
void (async () => {
|
|
1082
|
+
const db = await getStateDb();
|
|
1083
|
+
if (!db.prepare("SELECT 1 FROM domain_members WHERE kind = 'external' LIMIT 1").get()) return;
|
|
1084
|
+
await syncDomainAccess();
|
|
1085
|
+
})().catch(() => {
|
|
1086
|
+
// Audited by the rendering itself; the identity tick renders again.
|
|
1087
|
+
});
|
|
1088
|
+
}, HOST_RENDER_DELAY_MS);
|
|
1089
|
+
hostRenderTimer.unref();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** Audit and notify the start and the end of a failure streak (steps only, never members). */
|
|
1093
|
+
async function recordRenderOutcome(failures, logger) {
|
|
1094
|
+
const failing = failures.length > 0;
|
|
1095
|
+
if (failing === renderFailing) return;
|
|
1096
|
+
renderFailing = failing;
|
|
1097
|
+
const steps = failures.map((f) => f.step);
|
|
1098
|
+
await recordAuditBestEffort(
|
|
1099
|
+
failing
|
|
1100
|
+
? { actor: 'lamaste', action: 'domain.render.failed', details: { steps } }
|
|
1101
|
+
: { actor: 'lamaste', action: 'domain.render.recovered', details: {} },
|
|
1102
|
+
logger,
|
|
1103
|
+
);
|
|
1104
|
+
if (failing) {
|
|
1105
|
+
const { notifyAdminsBestEffort } = await import('./notifications.js');
|
|
1106
|
+
await notifyAdminsBestEffort(
|
|
1107
|
+
{
|
|
1108
|
+
source: 'lamaste',
|
|
1109
|
+
level: 'critical',
|
|
1110
|
+
message: `Who may reach the relay's domains could not be rendered (${steps.join(', ')}): a membership that ended may still let its user in. The panel retries every minute.`,
|
|
1111
|
+
},
|
|
1112
|
+
logger,
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* After a change of who may reach a domain: render it and — when the change
|
|
1119
|
+
* reaches a rendering (`effective`: an active user on an active domain) —
|
|
1120
|
+
* drop Gatekeeper's cached session verdicts and recompute the plugins'
|
|
1121
|
+
* audiences (a plugin's audience is its domain's members). Returns the
|
|
1122
|
+
* warnings.
|
|
1123
|
+
*/
|
|
1124
|
+
async function settleMembership(logger, { effective = true } = {}) {
|
|
1125
|
+
const warnings = [];
|
|
1126
|
+
try {
|
|
1127
|
+
warnings.push(...((await syncDomainAccess({ logger })).warnings ?? []));
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
logger?.error({ err: err.message }, 'Rendering domain access failed');
|
|
1130
|
+
warnings.push(...(err.warnings ?? []), {
|
|
1131
|
+
step: 'render',
|
|
1132
|
+
message: `Domain access was not fully rendered (${err.message}); the panel renders it again within a minute`,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
if (!effective) return warnings;
|
|
1136
|
+
const { bustGatekeeperSessions } = await import('./gatekeeper-client.js');
|
|
1137
|
+
const busted = await bustGatekeeperSessions();
|
|
1138
|
+
if (!busted.ok) {
|
|
1139
|
+
warnings.push({
|
|
1140
|
+
step: 'gatekeeper',
|
|
1141
|
+
message: `Gatekeeper did not drop its cached sessions (${busted.error}); they expire within 30 seconds`,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
const { recomputeAudiences } = await import('./audience.js');
|
|
1145
|
+
await recomputeAudiences({ logger });
|
|
1146
|
+
return warnings;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** `{ warnings }` when there are any, else nothing — what a route's answer carries. */
|
|
1150
|
+
const withWarnings = (warnings) => (warnings.length > 0 ? { warnings } : {});
|
|
1151
|
+
|
|
1152
|
+
// ---------------------------------------------------------------------------
|
|
1153
|
+
// Membership
|
|
1154
|
+
// ---------------------------------------------------------------------------
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* The directory id `ref` names — an id itself, or the username of a user
|
|
1158
|
+
* the directory holds (not a deleted one) — or a 404.
|
|
1159
|
+
*
|
|
1160
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
1161
|
+
* @param {string} ref
|
|
1162
|
+
*/
|
|
1163
|
+
function memberUserId(db, ref) {
|
|
1164
|
+
if (isIdentityUuid(ref)) return ref;
|
|
1165
|
+
const row = isLamasteUsername(ref)
|
|
1166
|
+
? db
|
|
1167
|
+
.prepare("SELECT user_id FROM directory_users WHERE username = ? AND status <> 'removed'")
|
|
1168
|
+
.get(ref)
|
|
1169
|
+
: undefined;
|
|
1170
|
+
if (!row) throw httpError('User not found', 404, { code: 'user_not_found' });
|
|
1171
|
+
return row.user_id;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** The directory row of `userId`, or a 404 / 409 the route answers with. */
|
|
1175
|
+
function memberCandidate(db, userId) {
|
|
1176
|
+
const row = db
|
|
1177
|
+
.prepare('SELECT user_id, status, username FROM directory_users WHERE user_id = ?')
|
|
1178
|
+
.get(userId);
|
|
1179
|
+
if (!row) throw httpError('User not found', 404, { code: 'user_not_found' });
|
|
1180
|
+
if (row.status === 'removed') {
|
|
1181
|
+
throw httpError('The user is deleted', 409, { code: 'user_removed' });
|
|
1182
|
+
}
|
|
1183
|
+
return row;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function domainOr404(db, name) {
|
|
1187
|
+
const domain = domainRow(db, name);
|
|
1188
|
+
if (!domain) throw httpError('Domain not found', 404, { code: 'domain_not_found' });
|
|
1189
|
+
return domain;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Make user `user` (a directory id or a username) a member of domain
|
|
1194
|
+
* `name` of `kind` (`member` unless named): added, or — when they are one
|
|
1195
|
+
* of the other kind — changed (audited `directory.membership.changed`).
|
|
1196
|
+
* Idempotent (`added: false, changed: false` when they were one of that
|
|
1197
|
+
* kind). Refused (409 `external_member_groups`) when an external
|
|
1198
|
+
* membership would go to a user who holds Authelia groups. Then renders,
|
|
1199
|
+
* busts Gatekeeper's sessions and recomputes the audiences; a member made
|
|
1200
|
+
* external whose access to the registry ends loses their registry tokens.
|
|
1201
|
+
*
|
|
1202
|
+
* @param {string} name
|
|
1203
|
+
* @param {string} user
|
|
1204
|
+
* @param {{ kind?: 'member' | 'external', actor: string, logger?: import('pino').Logger }} opts
|
|
1205
|
+
*/
|
|
1206
|
+
export async function addDomainMember(name, user, { kind = 'member', actor, logger }) {
|
|
1207
|
+
const change = await withUsersLock(() =>
|
|
1208
|
+
domainsMutex.run(async () => {
|
|
1209
|
+
const db = await getStateDb();
|
|
1210
|
+
const domain = domainOr404(db, name);
|
|
1211
|
+
const userId = memberUserId(db, user);
|
|
1212
|
+
const candidate = memberCandidate(db, userId);
|
|
1213
|
+
const current = db
|
|
1214
|
+
.prepare('SELECT kind FROM domain_members WHERE domain = ? AND user_id = ?')
|
|
1215
|
+
.get(name, userId)?.kind;
|
|
1216
|
+
if (current === kind) return { userId, added: false, changed: false };
|
|
1217
|
+
await assertNoGroupsForExternal(candidate.username, [{ kind }]);
|
|
1218
|
+
const now = new Date().toISOString();
|
|
1219
|
+
const events =
|
|
1220
|
+
current === undefined
|
|
1221
|
+
? insertMembershipsSync(db, userId, [{ name, kind }], { actor, now })
|
|
1222
|
+
: changeKindSync(db, userId, name, current, kind);
|
|
1223
|
+
for (const event of events) await recordAuditBestEffort({ actor, ...event }, logger);
|
|
1224
|
+
return {
|
|
1225
|
+
userId,
|
|
1226
|
+
username: candidate.username,
|
|
1227
|
+
added: current === undefined,
|
|
1228
|
+
changed: current !== undefined,
|
|
1229
|
+
external: kind === 'external',
|
|
1230
|
+
effective: domain.status === 'active' && candidate.status === 'active',
|
|
1231
|
+
};
|
|
1232
|
+
}),
|
|
1233
|
+
);
|
|
1234
|
+
if (!change.added && !change.changed) return { added: false, changed: false };
|
|
1235
|
+
const warnings = await settleMembership(logger, { effective: change.effective });
|
|
1236
|
+
return { added: change.added, changed: change.changed, ...withWarnings(warnings) };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Change the kind of `userId`'s membership of `domain` from `from` to `to`,
|
|
1241
|
+
* inside the caller's transaction (or alone); returns the audit entry.
|
|
1242
|
+
*
|
|
1243
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
1244
|
+
*/
|
|
1245
|
+
function changeKindSync(db, userId, domain, from, to) {
|
|
1246
|
+
db.prepare('UPDATE domain_members SET kind = ? WHERE domain = ? AND user_id = ?').run(
|
|
1247
|
+
to,
|
|
1248
|
+
domain,
|
|
1249
|
+
userId,
|
|
1250
|
+
);
|
|
1251
|
+
return [{ action: 'directory.membership.changed', details: { domain, userId, from, to } }];
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* End the membership of user `user` (a directory id or a username) of
|
|
1256
|
+
* domain `name` (`removed: false` when there was none). Then renders, busts
|
|
1257
|
+
* Gatekeeper's sessions, recomputes the audiences, and — when `name` is the
|
|
1258
|
+
* registry's domain — revokes the user's registry tokens.
|
|
1259
|
+
*
|
|
1260
|
+
* @param {string} name
|
|
1261
|
+
* @param {string} user
|
|
1262
|
+
* @param {{ actor: string, logger?: import('pino').Logger }} opts
|
|
1263
|
+
*/
|
|
1264
|
+
export async function removeDomainMember(name, user, { actor, logger }) {
|
|
1265
|
+
let userId;
|
|
1266
|
+
const removed = await domainsMutex.run(async () => {
|
|
1267
|
+
const db = await getStateDb();
|
|
1268
|
+
const domain = domainOr404(db, name);
|
|
1269
|
+
userId = memberUserId(db, user);
|
|
1270
|
+
const row = db
|
|
1271
|
+
.prepare(
|
|
1272
|
+
`SELECT u.username, u.status FROM domain_members dm JOIN directory_users u USING (user_id)
|
|
1273
|
+
WHERE dm.domain = ? AND dm.user_id = ?`,
|
|
1274
|
+
)
|
|
1275
|
+
.get(name, userId);
|
|
1276
|
+
if (!row) return null;
|
|
1277
|
+
db.prepare('DELETE FROM domain_members WHERE domain = ? AND user_id = ?').run(name, userId);
|
|
1278
|
+
await recordAuditBestEffort(
|
|
1279
|
+
{
|
|
1280
|
+
actor,
|
|
1281
|
+
action: 'directory.membership.removed',
|
|
1282
|
+
details: { domain: name, userId, reason: 'removed' },
|
|
1283
|
+
},
|
|
1284
|
+
logger,
|
|
1285
|
+
);
|
|
1286
|
+
return {
|
|
1287
|
+
username: row.username,
|
|
1288
|
+
effective: domain.status === 'active' && row.status === 'active',
|
|
1289
|
+
};
|
|
1290
|
+
});
|
|
1291
|
+
if (!removed) return { removed: false };
|
|
1292
|
+
const warnings = await settleMembership(logger, { effective: removed.effective });
|
|
1293
|
+
return { removed: true, ...withWarnings(warnings) };
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Make user `userId`'s memberships exactly `memberships` (a bare domain
|
|
1298
|
+
* name is a `member`) — the users API's edit: the difference applied in one
|
|
1299
|
+
* transaction — memberships added, ended, and changed in kind — each
|
|
1300
|
+
* audited as {@link addDomainMember} / {@link removeDomainMember} audit
|
|
1301
|
+
* them. Refused (409 `external_member_groups`) when it would make a user
|
|
1302
|
+
* who holds Authelia groups an external member. Then one rendering, and the
|
|
1303
|
+
* user's registry tokens revoked when a membership ended or became
|
|
1304
|
+
* external and their access to the registry with it. Throws a 400
|
|
1305
|
+
* `unknown_domain` for a domain the relay does not have, a 404 / 409 for a
|
|
1306
|
+
* user it cannot be.
|
|
1307
|
+
*
|
|
1308
|
+
* @param {string} userId
|
|
1309
|
+
* @param {ReadonlyArray<string | { name: string, kind: 'member' | 'external' }>} memberships
|
|
1310
|
+
* @param {{ actor: string, logger?: import('pino').Logger }} opts
|
|
1311
|
+
* @returns {Promise<{ domains: Array<{ name: string, kind: 'member' | 'external' }>, changed: boolean, warnings?: Array<object> }>}
|
|
1312
|
+
*/
|
|
1313
|
+
export async function setUserDomains(userId, memberships, { actor, logger }) {
|
|
1314
|
+
const wanted = normalizeMemberships(memberships).sort((a, b) => a.name.localeCompare(b.name));
|
|
1315
|
+
const change = await withUsersLock(() =>
|
|
1316
|
+
domainsMutex.run(async () => {
|
|
1317
|
+
const db = await getStateDb();
|
|
1318
|
+
const known = new Map((await listDomains()).map((d) => [d.name, d]));
|
|
1319
|
+
const unknown = wanted.find((m) => !known.has(m.name));
|
|
1320
|
+
if (unknown !== undefined) {
|
|
1321
|
+
throw httpError(`'${unknown.name}' is not a domain of this relay`, 400, {
|
|
1322
|
+
code: 'unknown_domain',
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
const user = memberCandidate(db, userId);
|
|
1326
|
+
const current = new Map(
|
|
1327
|
+
db
|
|
1328
|
+
.prepare('SELECT domain, kind FROM domain_members WHERE user_id = ?')
|
|
1329
|
+
.all(userId)
|
|
1330
|
+
.map((r) => [r.domain, r.kind]),
|
|
1331
|
+
);
|
|
1332
|
+
const wantedKinds = new Map(wanted.map((m) => [m.name, m.kind]));
|
|
1333
|
+
const ended = [...current.keys()].filter((d) => !wantedKinds.has(d));
|
|
1334
|
+
const rekinded = wanted.filter((m) => current.has(m.name) && current.get(m.name) !== m.kind);
|
|
1335
|
+
const added = wanted.filter((m) => !current.has(m.name));
|
|
1336
|
+
if (rekinded.length > 0 || added.length > 0) {
|
|
1337
|
+
await assertNoGroupsForExternal(user.username, [...rekinded, ...added]);
|
|
1338
|
+
}
|
|
1339
|
+
const events = inTransaction(db, () => {
|
|
1340
|
+
const remove = db.prepare('DELETE FROM domain_members WHERE domain = ? AND user_id = ?');
|
|
1341
|
+
for (const domain of ended) remove.run(domain, userId);
|
|
1342
|
+
return [
|
|
1343
|
+
...insertMembershipsSync(db, userId, added, { actor, now: new Date().toISOString() }),
|
|
1344
|
+
...rekinded.flatMap((m) =>
|
|
1345
|
+
changeKindSync(db, userId, m.name, current.get(m.name), m.kind),
|
|
1346
|
+
),
|
|
1347
|
+
...ended.map((domain) => ({
|
|
1348
|
+
action: 'directory.membership.removed',
|
|
1349
|
+
details: { domain, userId, reason: 'removed' },
|
|
1350
|
+
})),
|
|
1351
|
+
];
|
|
1352
|
+
});
|
|
1353
|
+
for (const event of events) await recordAuditBestEffort({ actor, ...event }, logger);
|
|
1354
|
+
const touched = events.map((e) => known.get(e.details.domain));
|
|
1355
|
+
return {
|
|
1356
|
+
username: user.username,
|
|
1357
|
+
changed: events.length > 0,
|
|
1358
|
+
effective: user.status === 'active' && touched.some((d) => d?.status === 'active'),
|
|
1359
|
+
};
|
|
1360
|
+
}),
|
|
1361
|
+
);
|
|
1362
|
+
if (!change.changed) return { domains: wanted, changed: false };
|
|
1363
|
+
const warnings = await settleMembership(logger, { effective: change.effective });
|
|
1364
|
+
return { domains: wanted, changed: true, ...withWarnings(warnings) };
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Whether user `userId` (`username`) still reaches the registry's sign-in:
|
|
1369
|
+
* one of the registry's own sign-in clients — the user-facing OIDC clients
|
|
1370
|
+
* whose audience is the registry (`registryAudience` of its URL) — admits
|
|
1371
|
+
* them: as a member of the client's domain, or as an external member of it
|
|
1372
|
+
* granted the client (a grant directly or through a Lamaste group). No
|
|
1373
|
+
* such client, or one that cannot be read — the plugin manifests,
|
|
1374
|
+
* Gatekeeper — is "no": fail closed.
|
|
1375
|
+
*
|
|
1376
|
+
* @param {string} username
|
|
1377
|
+
* @param {string} userId
|
|
1378
|
+
* @param {{ registryUrl: string }} config the stored registry credential
|
|
1379
|
+
*/
|
|
1380
|
+
async function reachesRegistry(username, userId, config) {
|
|
1381
|
+
let audience;
|
|
1382
|
+
try {
|
|
1383
|
+
audience = registryAudience(config.registryUrl);
|
|
1384
|
+
} catch {
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
const { planOidcClients } = await import('./plugin-oidc.js');
|
|
1388
|
+
const { isUserFacingOidcClient, readGrantedResources, OIDC_CLIENT_RESOURCE } =
|
|
1389
|
+
await import('./owned-groups.js');
|
|
1390
|
+
let signIn;
|
|
1391
|
+
try {
|
|
1392
|
+
signIn = (await planOidcClients()).served.filter(
|
|
1393
|
+
(c) =>
|
|
1394
|
+
isUserFacingOidcClient(c) &&
|
|
1395
|
+
c.audience.some((a) => a.replaceAll('{domain}', c.domain) === audience),
|
|
1396
|
+
);
|
|
1397
|
+
} catch {
|
|
1398
|
+
return false;
|
|
1399
|
+
}
|
|
1400
|
+
if (signIn.length === 0) return false;
|
|
1401
|
+
const db = await getStateDb();
|
|
1402
|
+
const kinds = new Map(
|
|
1403
|
+
db
|
|
1404
|
+
.prepare('SELECT domain, kind FROM domain_members WHERE user_id = ?')
|
|
1405
|
+
.all(userId)
|
|
1406
|
+
.map((r) => [r.domain, r.kind]),
|
|
1407
|
+
);
|
|
1408
|
+
if (signIn.some((c) => kinds.get(c.domain) === 'member')) return true;
|
|
1409
|
+
const external = signIn.filter((c) => kinds.get(c.domain) === 'external');
|
|
1410
|
+
if (external.length === 0) return false;
|
|
1411
|
+
const read = await readGrantedResources();
|
|
1412
|
+
if (!read.ok) return false;
|
|
1413
|
+
return external.some((c) => read.granted[OIDC_CLIENT_RESOURCE].get(c.id)?.has(username));
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* After a user's access narrowed — a membership ended or became external, a
|
|
1418
|
+
* grant on an OIDC client went: revoke their registry tokens when they no
|
|
1419
|
+
* longer reach the registry's sign-in ({@link reachesRegistry}) — the
|
|
1420
|
+
* sign-in that minted the tokens would refuse them now. Best effort;
|
|
1421
|
+
* returns warnings.
|
|
1422
|
+
*
|
|
1423
|
+
* @param {string} username
|
|
1424
|
+
* @param {string} userId
|
|
1425
|
+
* @param {import('pino').Logger} [logger]
|
|
1426
|
+
*/
|
|
1427
|
+
export async function revokeRegistryTokensFor(username, userId, logger) {
|
|
1428
|
+
try {
|
|
1429
|
+
const { readRegistryCredentialConfig } = await import('./registry-credential.js');
|
|
1430
|
+
const config = await readRegistryCredentialConfig();
|
|
1431
|
+
if (!config) return [];
|
|
1432
|
+
if (await reachesRegistry(username, userId, config)) return [];
|
|
1433
|
+
return await revokeTokensOf([{ username, userId }], config, logger);
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
logger?.warn({ userId, err: err.message }, 'Registry tokens were not revoked');
|
|
1436
|
+
return [{ step: 'registry', message: `Registry tokens were not revoked: ${err.message}` }];
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** At most this many registry revocations in flight. */
|
|
1441
|
+
const REVOCATION_CONCURRENCY = 4;
|
|
1442
|
+
|
|
1443
|
+
/** Revoke the registry tokens of `users` against `config`, a few at a time; returns warnings. */
|
|
1444
|
+
async function revokeTokensOf(users, config, logger) {
|
|
1445
|
+
const { revokeUserRegistryTokens } = await import('./registry-credential.js');
|
|
1446
|
+
const warnings = [];
|
|
1447
|
+
const queue = [...users];
|
|
1448
|
+
const worker = async () => {
|
|
1449
|
+
for (let user = queue.shift(); user; user = queue.shift()) {
|
|
1450
|
+
try {
|
|
1451
|
+
await revokeUserRegistryTokens(user.username, { deleted: true }, logger, {
|
|
1452
|
+
userId: user.userId,
|
|
1453
|
+
config,
|
|
1454
|
+
});
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
logger?.warn({ userId: user.userId, err: err.message }, 'Registry tokens were not revoked');
|
|
1457
|
+
warnings.push({
|
|
1458
|
+
step: 'registry',
|
|
1459
|
+
message: `Registry tokens were not revoked: ${err.message}`,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
await Promise.all(Array.from({ length: Math.min(REVOCATION_CONCURRENCY, users.length) }, worker));
|
|
1465
|
+
return warnings;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Revoke the registry tokens of every user who is a member or an external
|
|
1470
|
+
* member of none of `domains` — after the registry credential changed its
|
|
1471
|
+
* domains: who may hold a token follows them (an external member of one
|
|
1472
|
+
* whose client grant does not reach the registry is revoked when the grant
|
|
1473
|
+
* goes, {@link revokeRegistryTokensFor}). Best effort; returns warnings.
|
|
1474
|
+
*
|
|
1475
|
+
* @param {readonly string[]} domains
|
|
1476
|
+
* @param {object} config the stored credential
|
|
1477
|
+
* @param {import('pino').Logger} [logger]
|
|
1478
|
+
*/
|
|
1479
|
+
export async function revokeRegistryTokensOutside(domains, config, logger) {
|
|
1480
|
+
const db = await getStateDb();
|
|
1481
|
+
const placeholders = domains.map(() => '?').join(', ') || "''";
|
|
1482
|
+
const outside = db
|
|
1483
|
+
.prepare(
|
|
1484
|
+
`SELECT u.user_id AS userId, u.username FROM directory_users u
|
|
1485
|
+
WHERE u.status != 'removed' AND u.username IS NOT NULL
|
|
1486
|
+
AND NOT EXISTS (SELECT 1 FROM domain_members m
|
|
1487
|
+
WHERE m.user_id = u.user_id AND m.domain IN (${placeholders}))`,
|
|
1488
|
+
)
|
|
1489
|
+
.all(...domains);
|
|
1490
|
+
return revokeTokensOf(outside, config, logger);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// ---------------------------------------------------------------------------
|
|
1494
|
+
// Domain lifecycle
|
|
1495
|
+
// ---------------------------------------------------------------------------
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Change the set of domains — add one, activate one, remove one — holding
|
|
1499
|
+
* the tunnel lock, then the sites lock, then the domains lock: the locks
|
|
1500
|
+
* under which tunnels, sites and plugin moves resolve their domain, and
|
|
1501
|
+
* under which a site or an alias takes a hostname. So no hostname is taken
|
|
1502
|
+
* on a domain between the check of its relay hostnames and its insertion,
|
|
1503
|
+
* and nothing lands on a domain between its usage check and its removal.
|
|
1504
|
+
* Keep slow work (certificates, nginx reloads) outside.
|
|
1505
|
+
*
|
|
1506
|
+
* @template T
|
|
1507
|
+
* @param {() => Promise<T>} fn
|
|
1508
|
+
* @returns {Promise<T>}
|
|
1509
|
+
*/
|
|
1510
|
+
function changeDomains(fn) {
|
|
1511
|
+
return withTunnelLock(() => withSitesLock(() => domainsMutex.run(fn)));
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Add domain `name` (`pending`): validated, refused when it overlaps
|
|
1516
|
+
* another, shares a registrable domain with one or is a public suffix
|
|
1517
|
+
* (400 `invalid_domain` / `public_suffix`, 409 `domain_overlap` /
|
|
1518
|
+
* `same_registrable_domain`), or while a tunnel, site or alias holds one of
|
|
1519
|
+
* its relay hostnames (409 `relay_host_taken`).
|
|
1520
|
+
*
|
|
1521
|
+
* @param {string} name
|
|
1522
|
+
* @param {{ actor: string, logger?: import('pino').Logger }} opts
|
|
1523
|
+
* @returns {Promise<Domain>}
|
|
1524
|
+
*/
|
|
1525
|
+
export async function addDomain(name, { actor, logger }) {
|
|
1526
|
+
return changeDomains(async () => {
|
|
1527
|
+
const db = await getStateDb();
|
|
1528
|
+
const existing = db
|
|
1529
|
+
.prepare('SELECT name FROM domains')
|
|
1530
|
+
.all()
|
|
1531
|
+
.map((r) => r.name);
|
|
1532
|
+
try {
|
|
1533
|
+
assertDomainRegistrable(name, existing);
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
if (err.code === 'DOMAIN_OVERLAP' || err.code === 'SAME_REGISTRABLE_DOMAIN') {
|
|
1536
|
+
throw httpError(err.message, 409, { code: err.code.toLowerCase() });
|
|
1537
|
+
}
|
|
1538
|
+
throw httpError(err.message, 400, {
|
|
1539
|
+
code: err.code === 'PUBLIC_SUFFIX' ? 'public_suffix' : 'invalid_domain',
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
await assertRelayHostsFree(name);
|
|
1543
|
+
db.prepare(
|
|
1544
|
+
`INSERT INTO domains (name, is_primary, status, created_at, group_key)
|
|
1545
|
+
VALUES (?, 0, 'pending', ?, ?)`,
|
|
1546
|
+
).run(name, new Date().toISOString(), newGroupKey());
|
|
1547
|
+
await recordAuditBestEffort(
|
|
1548
|
+
{ actor, action: 'domain.added', details: { domain: name, primary: false } },
|
|
1549
|
+
logger,
|
|
1550
|
+
);
|
|
1551
|
+
return domainRow(db, name);
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Check the DNS a domain needs: `auth.<domain>` must resolve to the relay.
|
|
1557
|
+
* Reports whether a wildcard record is in place too (a probe name), which
|
|
1558
|
+
* every tunnel and site hostname then resolves through.
|
|
1559
|
+
*
|
|
1560
|
+
* @param {string} name
|
|
1561
|
+
*/
|
|
1562
|
+
export async function verifyDomainDns(name) {
|
|
1563
|
+
domainOr404(await getStateDb(), name);
|
|
1564
|
+
const expectedIp = getConfig().ip;
|
|
1565
|
+
const authName = `auth.${name}`;
|
|
1566
|
+
const [auth, wildcard] = await Promise.all([
|
|
1567
|
+
resolveHostA(authName),
|
|
1568
|
+
resolveHostA(`${DNS_WILDCARD_PROBE_LABEL}.${name}`),
|
|
1569
|
+
]);
|
|
1570
|
+
const record = (name, resolvedIps, required, purpose) => ({
|
|
1571
|
+
type: 'A',
|
|
1572
|
+
name,
|
|
1573
|
+
value: expectedIp,
|
|
1574
|
+
required,
|
|
1575
|
+
purpose,
|
|
1576
|
+
resolvedIps,
|
|
1577
|
+
ok: resolvedIps.includes(expectedIp),
|
|
1578
|
+
});
|
|
1579
|
+
const records = [
|
|
1580
|
+
record(authName, auth, true, 'the domain’s sign-in page'),
|
|
1581
|
+
// Probed through a name nothing else uses.
|
|
1582
|
+
record(
|
|
1583
|
+
`*.${name}`,
|
|
1584
|
+
wildcard,
|
|
1585
|
+
false,
|
|
1586
|
+
'every tunnel and site hostname — or one A record per hostname instead',
|
|
1587
|
+
),
|
|
1588
|
+
];
|
|
1589
|
+
return { ok: records.every((r) => !r.required || r.ok), expectedIp, records };
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** What activation needs from nginx and certbot (the real ones unless a test gives others). */
|
|
1593
|
+
async function activationDeps() {
|
|
1594
|
+
if (activationOverride) return activationOverride;
|
|
1595
|
+
const nginx = await import('./nginx.js');
|
|
1596
|
+
const certbot = await import('./certbot.js');
|
|
1597
|
+
return {
|
|
1598
|
+
issueCert: certbot.issueCert,
|
|
1599
|
+
writeAuthVhost: nginx.writeAuthVhost,
|
|
1600
|
+
removeAuthVhost: nginx.removeAuthVhost,
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
let activationOverride = null;
|
|
1605
|
+
|
|
1606
|
+
/** Replace nginx and certbot for activation and removal (tests). */
|
|
1607
|
+
export function setDomainActivationForTests(deps) {
|
|
1608
|
+
activationOverride = deps;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Activate domain `name`: issue `auth.<domain>`'s certificate, write and
|
|
1613
|
+
* link its auth vhost, mark it active and render — its session cookie, its
|
|
1614
|
+
* members' rules and Gatekeeper's projection. One operation: when a step
|
|
1615
|
+
* fails the vhost goes and the domain stays pending. Idempotent on an
|
|
1616
|
+
* active domain.
|
|
1617
|
+
*
|
|
1618
|
+
* @param {string} name
|
|
1619
|
+
* @param {{ actor: string, logger?: import('pino').Logger }} opts
|
|
1620
|
+
* @returns {Promise<Domain>}
|
|
1621
|
+
*/
|
|
1622
|
+
export async function activateDomain(name, { actor, logger }) {
|
|
1623
|
+
const pending = await domainsMutex.run(async () => {
|
|
1624
|
+
const domain = domainOr404(await getStateDb(), name);
|
|
1625
|
+
return domain.status === 'active' ? null : domain;
|
|
1626
|
+
});
|
|
1627
|
+
if (pending === null) return getDomain(name);
|
|
1628
|
+
const { email } = getConfig();
|
|
1629
|
+
if (!email) throw httpError('The panel has no email for certificates', 400);
|
|
1630
|
+
const deps = await activationDeps();
|
|
1631
|
+
const fqdn = `auth.${name}`;
|
|
1632
|
+
// Outside the locks: certbot takes its time, and an extra certificate of
|
|
1633
|
+
// a domain removed meanwhile is harmless.
|
|
1634
|
+
try {
|
|
1635
|
+
await deps.issueCert(fqdn, email);
|
|
1636
|
+
} catch (err) {
|
|
1637
|
+
throw httpError(`The certificate of ${fqdn} could not be issued: ${err.message}`, 502, {
|
|
1638
|
+
code: 'cert_failed',
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
const activated = await changeDomains(async () => {
|
|
1642
|
+
const db = await getStateDb();
|
|
1643
|
+
// Checked again under the locks: removed, activated or its relay
|
|
1644
|
+
// hostnames taken while the certificate was issued.
|
|
1645
|
+
const domain = domainOr404(db, name);
|
|
1646
|
+
if (domain.status === 'active') return null;
|
|
1647
|
+
await assertRelayHostsFree(name);
|
|
1648
|
+
try {
|
|
1649
|
+
await deps.writeAuthVhost(name, { primary: false });
|
|
1650
|
+
} catch (err) {
|
|
1651
|
+
throw httpError(`nginx refused the vhost of ${fqdn}: ${err.message}`, 502, {
|
|
1652
|
+
code: 'nginx_failed',
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
db.prepare("UPDATE domains SET status = 'active', activated_at = ? WHERE name = ?").run(
|
|
1656
|
+
new Date().toISOString(),
|
|
1657
|
+
name,
|
|
1658
|
+
);
|
|
1659
|
+
try {
|
|
1660
|
+
await syncDomainAccess({ logger });
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
db.prepare("UPDATE domains SET status = 'pending', activated_at = NULL WHERE name = ?").run(
|
|
1663
|
+
name,
|
|
1664
|
+
);
|
|
1665
|
+
await deps.removeAuthVhost(name).catch(() => {});
|
|
1666
|
+
// Back to what it was before: the other domains as they were.
|
|
1667
|
+
await syncDomainAccess({ logger }).catch(() => {});
|
|
1668
|
+
throw httpError(`Authelia was not updated: ${err.message}`, 502, { code: 'authelia_failed' });
|
|
1669
|
+
}
|
|
1670
|
+
return domainRow(db, name);
|
|
1671
|
+
});
|
|
1672
|
+
if (activated === null) return getDomain(name);
|
|
1673
|
+
await recordAuditBestEffort(
|
|
1674
|
+
{ actor, action: 'domain.activated', details: { domain: name } },
|
|
1675
|
+
logger,
|
|
1676
|
+
);
|
|
1677
|
+
const { bustGatekeeperSessions } = await import('./gatekeeper-client.js');
|
|
1678
|
+
await bustGatekeeperSessions();
|
|
1679
|
+
return activated;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* What uses each of `names`: tunnels and sites (a hostname or an alias
|
|
1684
|
+
* under it), plugins that belong to it, the registry credential's issuer —
|
|
1685
|
+
* the state read once.
|
|
1686
|
+
*
|
|
1687
|
+
* @param {readonly string[]} names
|
|
1688
|
+
* @returns {Promise<Map<string, { tunnels: string[], sites: string[], plugins: string[], registry: boolean }>>}
|
|
1689
|
+
*/
|
|
1690
|
+
async function usageByDomain(names) {
|
|
1691
|
+
const usage = new Map(
|
|
1692
|
+
names.map((n) => [n, { tunnels: [], sites: [], plugins: [], registry: false }]),
|
|
1693
|
+
);
|
|
1694
|
+
const of = (host) => (typeof host === 'string' ? usage.get(domainOfHost(host, names)) : null);
|
|
1695
|
+
for (const t of await readTunnels()) of(t.fqdn)?.tunnels.push(t.fqdn);
|
|
1696
|
+
for (const s of await readSites()) {
|
|
1697
|
+
const hosts = [s.fqdn, ...(s.aliases ?? [])];
|
|
1698
|
+
for (const name of new Set(hosts.map((h) => domainOfHost(h, names)).filter(Boolean))) {
|
|
1699
|
+
usage.get(name).sites.push(s.fqdn);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
const db = await getStateDb();
|
|
1703
|
+
for (const row of db.prepare('SELECT name, domain FROM plugins ORDER BY name').all()) {
|
|
1704
|
+
if (row.domain) usage.get(row.domain)?.plugins.push(row.name);
|
|
1705
|
+
}
|
|
1706
|
+
for (const domain of await registryDomains().catch(() => [])) {
|
|
1707
|
+
if (usage.has(domain)) usage.get(domain).registry = true;
|
|
1708
|
+
}
|
|
1709
|
+
return usage;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* What uses domain `name`: tunnels and sites (a hostname or an alias under
|
|
1714
|
+
* it), plugins that belong to it, the registry credential's issuer.
|
|
1715
|
+
*
|
|
1716
|
+
* @param {string} name
|
|
1717
|
+
*/
|
|
1718
|
+
export async function domainUsage(name) {
|
|
1719
|
+
return (await usageByDomain([name])).get(name);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* Remove domain `name`: refused for the primary (400 `primary_domain`) and
|
|
1724
|
+
* while anything uses it (409 `domain_in_use` with `usedBy`). Its
|
|
1725
|
+
* memberships end (audited, reason `domain-removed`), its auth vhost goes,
|
|
1726
|
+
* and the rest is rendered without it.
|
|
1727
|
+
*
|
|
1728
|
+
* @param {string} name
|
|
1729
|
+
* @param {{ actor: string, logger?: import('pino').Logger }} opts
|
|
1730
|
+
*/
|
|
1731
|
+
export async function removeDomain(name, { actor, logger }) {
|
|
1732
|
+
// The usage check and the deletion alone hold the locks (see
|
|
1733
|
+
// changeDomains); audits and nginx come after.
|
|
1734
|
+
const removed = await changeDomains(async () => {
|
|
1735
|
+
const db = await getStateDb();
|
|
1736
|
+
const domain = domainOr404(db, name);
|
|
1737
|
+
if (domain.primary) {
|
|
1738
|
+
throw httpError('The primary domain cannot be removed', 400, { code: 'primary_domain' });
|
|
1739
|
+
}
|
|
1740
|
+
const usedBy = await domainUsage(name);
|
|
1741
|
+
const { tunnels, sites, plugins, registry } = usedBy;
|
|
1742
|
+
if (tunnels.length + sites.length + plugins.length > 0 || registry) {
|
|
1743
|
+
throw httpError(`Domain '${name}' is in use`, 409, { code: 'domain_in_use', usedBy });
|
|
1744
|
+
}
|
|
1745
|
+
const members = inTransaction(db, () => {
|
|
1746
|
+
const ended = db
|
|
1747
|
+
.prepare('DELETE FROM domain_members WHERE domain = ? RETURNING user_id')
|
|
1748
|
+
.all(name)
|
|
1749
|
+
.map((r) => r.user_id);
|
|
1750
|
+
db.prepare('DELETE FROM domains WHERE name = ?').run(name);
|
|
1751
|
+
db.prepare(
|
|
1752
|
+
`INSERT INTO retired_domains (name, removed_at) VALUES (?, ?)
|
|
1753
|
+
ON CONFLICT (name) DO UPDATE SET removed_at = excluded.removed_at`,
|
|
1754
|
+
).run(name, new Date().toISOString());
|
|
1755
|
+
return ended;
|
|
1756
|
+
});
|
|
1757
|
+
return { active: domain.status === 'active', members };
|
|
1758
|
+
});
|
|
1759
|
+
for (const userId of removed.members) {
|
|
1760
|
+
await recordAuditBestEffort(
|
|
1761
|
+
{
|
|
1762
|
+
actor,
|
|
1763
|
+
action: 'directory.membership.removed',
|
|
1764
|
+
details: { domain: name, userId, reason: 'domain-removed' },
|
|
1765
|
+
},
|
|
1766
|
+
logger,
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
await recordAuditBestEffort(
|
|
1770
|
+
{ actor, action: 'domain.removed', details: { domain: name, members: removed.members.length } },
|
|
1771
|
+
logger,
|
|
1772
|
+
);
|
|
1773
|
+
if (removed.active) {
|
|
1774
|
+
// Unless the name was added again meanwhile — its new vhost is its own.
|
|
1775
|
+
await domainsMutex.run(async () => {
|
|
1776
|
+
if (domainRow(await getStateDb(), name)) return;
|
|
1777
|
+
const deps = await activationDeps();
|
|
1778
|
+
await deps.removeAuthVhost(name).catch((err) => {
|
|
1779
|
+
logger?.warn(
|
|
1780
|
+
{ domain: name, err: err.message },
|
|
1781
|
+
'The auth vhost of a removed domain stayed',
|
|
1782
|
+
);
|
|
1783
|
+
});
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
// No registry token can depend on a removed domain: the registry's
|
|
1787
|
+
// domains cannot be removed (`domain_in_use`).
|
|
1788
|
+
return {
|
|
1789
|
+
removed: true,
|
|
1790
|
+
...withWarnings(await settleMembership(logger, { effective: removed.active })),
|
|
1791
|
+
};
|
|
1792
|
+
}
|