@lamalibre/create-lamaste 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/lamaste-priv +200 -16
- package/src/index.js +1 -0
- package/src/lib/backend-cookie.js +65 -0
- package/src/lib/ip-panel-vhost.js +208 -0
- package/src/lib/service-config.js +5 -3
- package/src/tasks/nginx.js +18 -135
- package/src/tasks/redeploy.js +39 -1
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +1 -1
- package/vendor/docs/01-concepts/authorization.md +66 -48
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +70 -33
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +5 -5
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/managing-users.md +113 -25
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +3 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
- package/vendor/docs/03-architecture/overview.md +13 -17
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +3 -2
- package/vendor/docs/03-architecture/state-management.md +4 -8
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +292 -23
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +15 -13
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +6 -4
- package/vendor/docs/04-api-reference/tunnels.md +67 -29
- package/vendor/docs/04-api-reference/users.md +241 -45
- package/vendor/docs/05-operations/backup-and-restore.md +35 -26
- package/vendor/docs/05-operations/monitoring.md +39 -0
- package/vendor/docs/05-operations/uninstalling.md +2 -1
- package/vendor/docs/05-operations/upgrades.md +15 -6
- package/vendor/docs/06-reference/config-files.md +83 -189
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +25 -26
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +10 -8
- package/vendor/docs/identity-directory-design.md +48 -14
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -0
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +6 -1
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.js +5 -1
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
- package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/principals.js +67 -0
- package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +22 -48
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +15 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-identity.js +335 -0
- package/vendor/server/src/commands/plugins.js +99 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/tunnels.js +102 -2
- package/vendor/server/src/commands/uninstall.js +3 -1
- package/vendor/server/src/commands/users.js +358 -0
- package/vendor/server/src/plugin-approval.js +107 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +25 -1
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/config.js +68 -43
- package/vendor/serverd/src/lib/directory.js +1113 -0
- package/vendor/serverd/src/lib/entitlements.js +517 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
- package/vendor/serverd/src/lib/identity-sync.js +905 -0
- package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
- package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +279 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
- package/vendor/serverd/src/lib/plugin-packages.js +30 -22
- package/vendor/serverd/src/lib/plugins.js +94 -15
- package/vendor/serverd/src/lib/registry-credential.js +46 -6
- package/vendor/serverd/src/lib/request-logging.js +19 -0
- package/vendor/serverd/src/lib/retained-data.js +50 -4
- package/vendor/serverd/src/lib/state.js +18 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
- package/vendor/serverd/src/lib/user-access-session.js +21 -11
- package/vendor/serverd/src/lib/user-caller.js +67 -0
- package/vendor/serverd/src/lib/user-surface.js +733 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
- package/vendor/serverd/src/middleware/user-access-session.js +9 -0
- package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
- package/vendor/serverd/src/routes/invite.js +53 -12
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
- package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
- package/vendor/serverd/src/routes/management/invitations.js +78 -68
- package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/tunnels.js +98 -2
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
- package/vendor/serverd/src/routes/user-access.js +17 -4
- package/vendor/serverd/src/routes/user-surface.js +69 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
- package/vendor/server-ui/dist/assets/index-CESyyK8V.js +0 -84
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user directory — every user the panel knows, by a stable id.
|
|
3
|
+
*
|
|
4
|
+
* Authelia's users.yml stays its credential file and keys users by a
|
|
5
|
+
* username anyone can reuse. The directory (state.db, migration 0013) gives
|
|
6
|
+
* each user a UUID v4 the moment their username appears and keeps the row
|
|
7
|
+
* for good: plugins key their data on the id, and a username that comes
|
|
8
|
+
* back after a deletion is a new user with a new id. A row is `active`,
|
|
9
|
+
* `disabled` (Authelia's `disabled: true` — the one offboarding action) or
|
|
10
|
+
* `removed` — deleted through Lamaste (`deleted`) or gone from users.yml
|
|
11
|
+
* without it (`deleted-outside`) — and a removed row keeps the username,
|
|
12
|
+
* display name and email it last had, so history stays readable, until an
|
|
13
|
+
* administrator erases it ({@link eraseDirectoryUser}).
|
|
14
|
+
*
|
|
15
|
+
* users.yml is never written from here. Every write of it goes through
|
|
16
|
+
* {@link changeUsers}: one in-process lock around the read-modify-write,
|
|
17
|
+
* then the directory is reconciled against the file, then Authelia
|
|
18
|
+
* restarts, then the plugins' audiences are recomputed. The reconcile
|
|
19
|
+
* ({@link reconcileDirectory}) also runs at start, every minute and after
|
|
20
|
+
* onboarding — read only: writing users.yml restarts Authelia and ends
|
|
21
|
+
* every session. It adopts what changed outside Lamaste (Authelia's own
|
|
22
|
+
* password reset rewrites the file; an operator may edit it), and only
|
|
23
|
+
* after reading the file twice does it take a user for deleted outside —
|
|
24
|
+
* Authelia rewrites the file in place, and a read that met a half-written
|
|
25
|
+
* file must not tombstone anyone.
|
|
26
|
+
*
|
|
27
|
+
* Gatekeeper keys users by username too: the grants naming a user and
|
|
28
|
+
* their memberships of Lamaste groups would pass to anyone who later holds
|
|
29
|
+
* the username. Every removed user's principal is therefore cleared in
|
|
30
|
+
* Gatekeeper (`principal_cleared_at`): when the user is removed (through
|
|
31
|
+
* Lamaste or outside it), then every minute while it is pending
|
|
32
|
+
* (Gatekeeper down), before an erasure scrubs the username it needs, and
|
|
33
|
+
* whenever a user is created under a username a removed user held — always
|
|
34
|
+
* then, pending or not: a restored gatekeeper.db or a grant that raced the
|
|
35
|
+
* deletion can bring grants back (refused while it cannot complete). A
|
|
36
|
+
* username a removed user held and nobody holds now takes no new grant or
|
|
37
|
+
* group membership (409 `username_deleted`), and grants are created under
|
|
38
|
+
* the users lock, so none races a deletion. While a username's cleanup is
|
|
39
|
+
* pending it holds no role in any plugin.
|
|
40
|
+
*
|
|
41
|
+
* Authelia keys a user's second factors and the OpenID Connect identity it
|
|
42
|
+
* gives relying parties by username as well (`authelia-user-storage.ts` in
|
|
43
|
+
* core): a removed user's TOTP configuration and WebAuthn credentials are
|
|
44
|
+
* deleted when they are removed and again before a user is created under
|
|
45
|
+
* the username, and a username Authelia holds an opaque identifier for is
|
|
46
|
+
* never given to a new user (409 `username_has_oidc_identity`) — Authelia
|
|
47
|
+
* cannot delete one, and every relying party would take the new user for
|
|
48
|
+
* the old.
|
|
49
|
+
*
|
|
50
|
+
* **Offboarding** ({@link offboardUserUnlocked}, keyed by user id) is one
|
|
51
|
+
* function for every way a user stops being one — disabled or removed,
|
|
52
|
+
* through the users API or adopted from users.yml by a reconcile: the
|
|
53
|
+
* user's desktop sessions end (their session epoch, panel.json keyed by
|
|
54
|
+
* user id), their registry tokens are revoked, and for a removal their
|
|
55
|
+
* legacy user-plugin-access grants, their Gatekeeper principal and their
|
|
56
|
+
* Authelia second factors go; Gatekeeper's cached session verdicts are
|
|
57
|
+
* dropped. Every reconcile offboards exactly the users it saw change.
|
|
58
|
+
*
|
|
59
|
+
* Audit entries name users by their id alone: never a username, a name or
|
|
60
|
+
* an address.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import crypto from 'node:crypto';
|
|
64
|
+
import { PromiseChainMutex } from '@lamalibre/lamaste';
|
|
65
|
+
import { httpError } from './app-error.js';
|
|
66
|
+
import { recordAuditBestEffort } from './audit.js';
|
|
67
|
+
import {
|
|
68
|
+
autheliaHoldsOidcIdentity,
|
|
69
|
+
purgeAutheliaUserSecondFactors,
|
|
70
|
+
readUsersRaw,
|
|
71
|
+
reloadAuthelia,
|
|
72
|
+
} from './authelia.js';
|
|
73
|
+
import { bumpUserEpoch } from './config.js';
|
|
74
|
+
import {
|
|
75
|
+
bustGatekeeperSessions,
|
|
76
|
+
purgeAccessLogForUser,
|
|
77
|
+
removeUserPrincipal,
|
|
78
|
+
} from './gatekeeper-client.js';
|
|
79
|
+
import { revokeUserRegistryTokens } from './registry-credential.js';
|
|
80
|
+
import { getStateDb } from './state-db.js';
|
|
81
|
+
import { readInvitations, withInvitationsLock, writeInvitations } from './state.js';
|
|
82
|
+
import { removeGrantsForUser } from './user-access.js';
|
|
83
|
+
|
|
84
|
+
/** How long the reconcile waits before reading users.yml again to confirm a user is gone. */
|
|
85
|
+
const DIRECTORY_CONFIRM_DELAY_MS = 500;
|
|
86
|
+
|
|
87
|
+
/** A logger for callees that need one when the caller passed none. */
|
|
88
|
+
const SILENT_LOGGER = Object.freeze({ info() {}, warn() {}, error() {} });
|
|
89
|
+
|
|
90
|
+
/** The Authelia group whose enabled members administer Lamaste. */
|
|
91
|
+
export const ADMIN_GROUP = 'admins';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A user as the directory keeps them.
|
|
95
|
+
*
|
|
96
|
+
* @typedef {object} DirectoryUser
|
|
97
|
+
* @property {string} userId UUID v4
|
|
98
|
+
* @property {string | null} username null once erased
|
|
99
|
+
* @property {string | null} displayName
|
|
100
|
+
* @property {string | null} email
|
|
101
|
+
* @property {'active' | 'disabled' | 'removed'} status
|
|
102
|
+
* @property {'deleted' | 'deleted-outside' | null} removedReason
|
|
103
|
+
* @property {string | null} removedAt
|
|
104
|
+
* @property {string | null} erasedAt
|
|
105
|
+
* @property {string | null} principalClearedAt when Gatekeeper dropped what it held under the
|
|
106
|
+
* username (removed users only; null on a removed user: pending)
|
|
107
|
+
* @property {number} version
|
|
108
|
+
* @property {string} createdAt
|
|
109
|
+
* @property {string} updatedAt
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @typedef {object} DirectoryReconcileResult
|
|
114
|
+
* @property {number} created
|
|
115
|
+
* @property {number} updated
|
|
116
|
+
* @property {Array<{ userId: string, username: string, reason: 'deleted' | 'deleted-outside' }>} removed
|
|
117
|
+
* in memory only — never logged or audited with the username
|
|
118
|
+
* @property {string[]} disabled the ids of users this reconcile saw become disabled
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// The users lock
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const usersMutex = new PromiseChainMutex();
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run `fn` holding the users lock — the one lock around every
|
|
129
|
+
* read-modify-write of users.yml and every change of the directory in this
|
|
130
|
+
* process (core's users functions take none).
|
|
131
|
+
*
|
|
132
|
+
* @template T
|
|
133
|
+
* @param {() => Promise<T>} fn
|
|
134
|
+
* @returns {Promise<T>}
|
|
135
|
+
*/
|
|
136
|
+
function withUsersLock(fn) {
|
|
137
|
+
return usersMutex.run(fn);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Reading
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** @returns {DirectoryUser} */
|
|
145
|
+
function rowToUser(row) {
|
|
146
|
+
return {
|
|
147
|
+
userId: row.user_id,
|
|
148
|
+
username: row.username,
|
|
149
|
+
displayName: row.display_name,
|
|
150
|
+
email: row.email,
|
|
151
|
+
status: row.status,
|
|
152
|
+
removedReason: row.removed_reason,
|
|
153
|
+
removedAt: row.removed_at,
|
|
154
|
+
erasedAt: row.erased_at,
|
|
155
|
+
principalClearedAt: row.principal_cleared_at,
|
|
156
|
+
version: row.version,
|
|
157
|
+
createdAt: row.created_at,
|
|
158
|
+
updatedAt: row.updated_at,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The directory's id (UUID v4), made with the store. */
|
|
163
|
+
export async function getDirectoryId() {
|
|
164
|
+
const db = await getStateDb();
|
|
165
|
+
const row = db.prepare('SELECT directory_id FROM directory_meta WHERE id = 1').get();
|
|
166
|
+
if (!row) throw new Error('The user directory has no id (state.db migration 0013 missing)');
|
|
167
|
+
return row.directory_id;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Every user the directory knows — removed and erased ones too: members
|
|
172
|
+
* first by username, then the removed, then the erased.
|
|
173
|
+
*
|
|
174
|
+
* @returns {Promise<DirectoryUser[]>}
|
|
175
|
+
*/
|
|
176
|
+
export async function listDirectoryUsers() {
|
|
177
|
+
const db = await getStateDb();
|
|
178
|
+
return db
|
|
179
|
+
.prepare(
|
|
180
|
+
`SELECT * FROM directory_users
|
|
181
|
+
ORDER BY status = 'removed', erased_at IS NOT NULL, username, created_at, user_id`,
|
|
182
|
+
)
|
|
183
|
+
.all()
|
|
184
|
+
.map(rowToUser);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* One user by id, or null.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} userId
|
|
191
|
+
* @returns {Promise<DirectoryUser | null>}
|
|
192
|
+
*/
|
|
193
|
+
export async function getDirectoryUser(userId) {
|
|
194
|
+
const db = await getStateDb();
|
|
195
|
+
const row = db.prepare('SELECT * FROM directory_users WHERE user_id = ?').get(userId);
|
|
196
|
+
return row ? rowToUser(row) : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The user who holds `username` now (active or disabled), or null — a
|
|
201
|
+
* removed user keeps their username but no longer holds it.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} username
|
|
204
|
+
* @returns {Promise<DirectoryUser | null>}
|
|
205
|
+
*/
|
|
206
|
+
export async function findDirectoryUser(username) {
|
|
207
|
+
const db = await getStateDb();
|
|
208
|
+
const row = db
|
|
209
|
+
.prepare("SELECT * FROM directory_users WHERE username = ? AND status <> 'removed'")
|
|
210
|
+
.get(username);
|
|
211
|
+
return row ? rowToUser(row) : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The users who hold a username now, by username.
|
|
216
|
+
*
|
|
217
|
+
* @returns {Promise<Map<string, DirectoryUser>>}
|
|
218
|
+
*/
|
|
219
|
+
export async function directoryUsersByUsername() {
|
|
220
|
+
const db = await getStateDb();
|
|
221
|
+
const rows = db.prepare("SELECT * FROM directory_users WHERE status <> 'removed'").all();
|
|
222
|
+
return new Map(rows.map((row) => [row.username, rowToUser(row)]));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Gatekeeper principals of removed users
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The usernames whose Gatekeeper principal is still to be cleared — of
|
|
231
|
+
* removed users whose cleanup has not succeeded yet. No role is counted for
|
|
232
|
+
* them, whoever holds the username now.
|
|
233
|
+
*
|
|
234
|
+
* @returns {Promise<Set<string>>}
|
|
235
|
+
*/
|
|
236
|
+
export async function pendingPrincipalCleanups() {
|
|
237
|
+
const db = await getStateDb();
|
|
238
|
+
return new Set(
|
|
239
|
+
db
|
|
240
|
+
.prepare(
|
|
241
|
+
`SELECT DISTINCT username FROM directory_users
|
|
242
|
+
WHERE status = 'removed' AND principal_cleared_at IS NULL AND username IS NOT NULL`,
|
|
243
|
+
)
|
|
244
|
+
.all()
|
|
245
|
+
.map((row) => row.username),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Whether a removed user (not erased: an erased one no longer has a
|
|
251
|
+
* username) held `username`.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} username
|
|
254
|
+
*/
|
|
255
|
+
async function usernameHasTombstone(username) {
|
|
256
|
+
const db = await getStateDb();
|
|
257
|
+
return (
|
|
258
|
+
db
|
|
259
|
+
.prepare("SELECT 1 FROM directory_users WHERE username = ? AND status = 'removed'")
|
|
260
|
+
.get(username) !== undefined
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Whether a removed user held `username` and Gatekeeper may still hold
|
|
266
|
+
* their grants or group memberships under it.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} username
|
|
269
|
+
*/
|
|
270
|
+
export async function principalCleanupPending(username) {
|
|
271
|
+
const db = await getStateDb();
|
|
272
|
+
return (
|
|
273
|
+
db
|
|
274
|
+
.prepare(
|
|
275
|
+
`SELECT 1 FROM directory_users
|
|
276
|
+
WHERE username = ? AND status = 'removed' AND principal_cleared_at IS NULL`,
|
|
277
|
+
)
|
|
278
|
+
.get(username) !== undefined
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The longest username a grant or a group member can name: Gatekeeper holds nothing longer. */
|
|
283
|
+
const GATEKEEPER_PRINCIPAL_MAX = 100;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Have Gatekeeper drop everything it holds under `username` and record it
|
|
287
|
+
* on every removed user whose cleanup was pending under it — users lock
|
|
288
|
+
* held. A username Gatekeeper cannot hold needs no call. Returns
|
|
289
|
+
* `{ ok: true }` or the failure (`error`, never thrown); audited by user id.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} username
|
|
292
|
+
* @param {{ actor: string, logger?: import('pino').Logger }} opts
|
|
293
|
+
* @returns {Promise<{ ok: true, grantsRemoved: number, groupsLeft: number } | { ok: false, error: string }>}
|
|
294
|
+
*/
|
|
295
|
+
async function clearPrincipalUnlocked(username, { actor, logger }) {
|
|
296
|
+
let removed = { grantsRemoved: 0, groupsLeft: 0 };
|
|
297
|
+
if (username.length > 0 && username.length <= GATEKEEPER_PRINCIPAL_MAX) {
|
|
298
|
+
const result = await removeUserPrincipal(username);
|
|
299
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
300
|
+
removed = { grantsRemoved: result.grantsRemoved, groupsLeft: result.groupsLeft };
|
|
301
|
+
}
|
|
302
|
+
const db = await getStateDb();
|
|
303
|
+
const cleared = db
|
|
304
|
+
.prepare(
|
|
305
|
+
`SELECT user_id FROM directory_users
|
|
306
|
+
WHERE username = ? AND status = 'removed' AND principal_cleared_at IS NULL`,
|
|
307
|
+
)
|
|
308
|
+
.all(username)
|
|
309
|
+
.map((row) => row.user_id);
|
|
310
|
+
if (cleared.length > 0) {
|
|
311
|
+
db.prepare(
|
|
312
|
+
`UPDATE directory_users SET principal_cleared_at = ?
|
|
313
|
+
WHERE username = ? AND status = 'removed' AND principal_cleared_at IS NULL`,
|
|
314
|
+
).run(new Date().toISOString(), username);
|
|
315
|
+
await recordAuditBestEffort(
|
|
316
|
+
{
|
|
317
|
+
actor,
|
|
318
|
+
action: 'directory.user.principal-cleared',
|
|
319
|
+
details: { userIds: cleared, ...removed },
|
|
320
|
+
},
|
|
321
|
+
logger,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (cleared.length > 0 || removed.grantsRemoved > 0 || removed.groupsLeft > 0) {
|
|
325
|
+
logger?.info(
|
|
326
|
+
{ userIds: cleared, ...removed },
|
|
327
|
+
'Cleared what Gatekeeper held under a removed user’s username',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return { ok: true, ...removed };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 503 `gatekeeper_unavailable`: what Gatekeeper holds under a username could not be cleared. */
|
|
334
|
+
function gatekeeperCleanupError(error) {
|
|
335
|
+
return httpError(
|
|
336
|
+
`A removed user's grants and group memberships under this username could not be cleared — Gatekeeper cannot clear them now (${error}); try again once Gatekeeper answers`,
|
|
337
|
+
503,
|
|
338
|
+
{ code: 'gatekeeper_unavailable' },
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** 503 `authelia_storage_unavailable`: Authelia's per-user storage could not be read or changed. */
|
|
343
|
+
function autheliaStorageError(what, err) {
|
|
344
|
+
return httpError(
|
|
345
|
+
`Authelia's storage could not ${what} (${err.message}) — nothing was changed; try again`,
|
|
346
|
+
503,
|
|
347
|
+
{ code: 'authelia_storage_unavailable' },
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Before a user is created under `username` — users lock held: refuse a
|
|
353
|
+
* username Authelia holds an OpenID Connect opaque identifier for (409
|
|
354
|
+
* `username_has_oidc_identity`), and when a removed user held it, clear
|
|
355
|
+
* everything Gatekeeper holds under it and delete Authelia's second factors
|
|
356
|
+
* for it — always, whether or not a cleanup was pending: a restored
|
|
357
|
+
* gatekeeper.db or Authelia database, or a grant that raced the deletion,
|
|
358
|
+
* may have brought something back. Throws 503 (`gatekeeper_unavailable`,
|
|
359
|
+
* `authelia_storage_unavailable`) when that cannot be done now.
|
|
360
|
+
* `autheliaStorage: false` skips Authelia's storage (onboarding: it does not
|
|
361
|
+
* exist yet).
|
|
362
|
+
*
|
|
363
|
+
* @param {string} username
|
|
364
|
+
* @param {{ actor: string, logger?: import('pino').Logger, autheliaStorage: boolean }} opts
|
|
365
|
+
*/
|
|
366
|
+
async function prepareUsernameUnlocked(username, { actor, logger, autheliaStorage }) {
|
|
367
|
+
if (autheliaStorage) {
|
|
368
|
+
let held;
|
|
369
|
+
try {
|
|
370
|
+
held = await autheliaHoldsOidcIdentity(username);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
throw autheliaStorageError('be checked for an OpenID Connect identity of this username', err);
|
|
373
|
+
}
|
|
374
|
+
if (held) {
|
|
375
|
+
throw httpError(
|
|
376
|
+
'Authelia holds an OpenID Connect identity for this username (it was signed in to an OpenID Connect relying party before): every relying party — plugins signing users in through Lamaste — would take a new user for the earlier one, and Authelia cannot forget it. Choose another username.',
|
|
377
|
+
409,
|
|
378
|
+
{ code: 'username_has_oidc_identity' },
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (!(await usernameHasTombstone(username))) return;
|
|
383
|
+
const cleared = await clearPrincipalUnlocked(username, { actor, logger });
|
|
384
|
+
if (!cleared.ok) throw gatekeeperCleanupError(cleared.error);
|
|
385
|
+
if (autheliaStorage) {
|
|
386
|
+
try {
|
|
387
|
+
await purgeAutheliaUserSecondFactors(username);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
throw autheliaStorageError(
|
|
390
|
+
'delete an earlier user’s second factors under this username',
|
|
391
|
+
err,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Hold the users lock while something is given to `usernames` in
|
|
399
|
+
* Gatekeeper — a grant or a group membership — so no deletion runs
|
|
400
|
+
* between the checks and `give`: a username a removed user held and nobody
|
|
401
|
+
* holds now is refused (409 `username_deleted` — whatever was given would
|
|
402
|
+
* pass to whoever is given the username next), and a pending Gatekeeper
|
|
403
|
+
* cleanup under one is completed first (503 `gatekeeper_unavailable` when
|
|
404
|
+
* it cannot be). With `requireUser`, a username nobody holds is refused
|
|
405
|
+
* too (404 `user_not_found`). A username the directory has never seen
|
|
406
|
+
* goes through: Gatekeeper may hold grants for a user still to come.
|
|
407
|
+
*
|
|
408
|
+
* @template T
|
|
409
|
+
* @param {string[]} usernames
|
|
410
|
+
* @param {{ actor: string, logger?: import('pino').Logger, requireUser?: boolean }} opts
|
|
411
|
+
* @param {() => Promise<T>} give
|
|
412
|
+
* @returns {Promise<T>}
|
|
413
|
+
*/
|
|
414
|
+
export function withUsernamesForGrants(usernames, { actor, logger, requireUser = false }, give) {
|
|
415
|
+
return withUsersLock(async () => {
|
|
416
|
+
for (const username of usernames) {
|
|
417
|
+
const holder = await findDirectoryUser(username);
|
|
418
|
+
if (!holder) {
|
|
419
|
+
if (await usernameHasTombstone(username)) {
|
|
420
|
+
throw httpError(
|
|
421
|
+
'This username belonged to a user who was deleted, and nobody holds it now — nothing is given to it until a user is created under it',
|
|
422
|
+
409,
|
|
423
|
+
{ code: 'username_deleted' },
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (requireUser) {
|
|
427
|
+
throw httpError('No user with this username in the directory', 404, {
|
|
428
|
+
code: 'user_not_found',
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (await principalCleanupPending(username)) {
|
|
433
|
+
const cleared = await clearPrincipalUnlocked(username, { actor, logger });
|
|
434
|
+
if (!cleared.ok) throw gatekeeperCleanupError(cleared.error);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return give();
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Retry every pending cleanup (the minute's tick): one Gatekeeper call per
|
|
443
|
+
* username, failures logged and left pending. Returns how many usernames
|
|
444
|
+
* were cleared and how many are still pending.
|
|
445
|
+
*
|
|
446
|
+
* @param {{ logger?: import('pino').Logger }} [opts]
|
|
447
|
+
* @returns {Promise<{ cleared: number, pending: number }>}
|
|
448
|
+
*/
|
|
449
|
+
export function clearPendingPrincipals({ logger } = {}) {
|
|
450
|
+
return withUsersLock(async () => {
|
|
451
|
+
let cleared = 0;
|
|
452
|
+
let pending = 0;
|
|
453
|
+
for (const username of await pendingPrincipalCleanups()) {
|
|
454
|
+
const result = await clearPrincipalUnlocked(username, { actor: 'lamaste', logger });
|
|
455
|
+
if (result.ok) cleared += 1;
|
|
456
|
+
else pending += 1;
|
|
457
|
+
}
|
|
458
|
+
if (pending > 0) {
|
|
459
|
+
logger?.warn(
|
|
460
|
+
{ pending },
|
|
461
|
+
'Gatekeeper principals of removed users are still to be cleared — retried every minute',
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return { cleared, pending };
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Reconcile (read only on users.yml)
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
function isRecord(value) {
|
|
473
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function textOrNull(value) {
|
|
477
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Why users.yml could not be read, in words that carry none of its content:
|
|
482
|
+
* a YAML parser's message quotes the lines around the fault — usernames,
|
|
483
|
+
* addresses, password hashes — and must never reach a log.
|
|
484
|
+
*
|
|
485
|
+
* @param {unknown} err
|
|
486
|
+
* @returns {Error}
|
|
487
|
+
*/
|
|
488
|
+
export function usersFileError(err) {
|
|
489
|
+
const cause =
|
|
490
|
+
err?.name === 'YAMLException'
|
|
491
|
+
? `not valid YAML${Number.isInteger(err.mark?.line) ? ` at line ${err.mark.line + 1}` : ''}`
|
|
492
|
+
: typeof err?.code === 'string'
|
|
493
|
+
? err.code
|
|
494
|
+
: (err?.name ?? 'unknown error');
|
|
495
|
+
return new Error(`users.yml could not be read (${cause})`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* users.yml as the directory sees it: username → display name, email and
|
|
500
|
+
* status. Throws when the file cannot be read or parsed (without its
|
|
501
|
+
* content: {@link usersFileError}), or is not a users file — a reconcile
|
|
502
|
+
* then changes nothing.
|
|
503
|
+
*
|
|
504
|
+
* @returns {Promise<Map<string, { displayName: string | null, email: string | null, status: 'active' | 'disabled' }>>}
|
|
505
|
+
*/
|
|
506
|
+
async function readUsersSnapshot() {
|
|
507
|
+
let raw;
|
|
508
|
+
try {
|
|
509
|
+
raw = await readUsersRaw();
|
|
510
|
+
} catch (err) {
|
|
511
|
+
throw usersFileError(err);
|
|
512
|
+
}
|
|
513
|
+
if (!isRecord(raw) || !isRecord(raw.users)) {
|
|
514
|
+
throw new Error('users.yml is not a users file (no "users" map)');
|
|
515
|
+
}
|
|
516
|
+
const users = new Map();
|
|
517
|
+
for (const [username, value] of Object.entries(raw.users)) {
|
|
518
|
+
const entry = isRecord(value) ? value : {};
|
|
519
|
+
users.set(username, {
|
|
520
|
+
displayName: textOrNull(entry.displayname),
|
|
521
|
+
email: textOrNull(entry.email),
|
|
522
|
+
status: entry.disabled === true ? 'disabled' : 'active',
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return users;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Refuse a users.yml that lists nobody while the directory has users: never a real state. */
|
|
529
|
+
function assertNotEmpty(snapshot, live) {
|
|
530
|
+
if (snapshot.size === 0 && live.length > 0) {
|
|
531
|
+
throw new Error('users.yml lists no users — the directory is left as it is');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* {@link reconcileDirectory} with the users lock already held.
|
|
537
|
+
*
|
|
538
|
+
* @param {{ actor: string, logger?: import('pino').Logger, deleted?: string[], confirmDelayMs?: number }} opts
|
|
539
|
+
* @returns {Promise<DirectoryReconcileResult>}
|
|
540
|
+
*/
|
|
541
|
+
async function reconcileUnlocked({
|
|
542
|
+
actor,
|
|
543
|
+
logger,
|
|
544
|
+
deleted = [],
|
|
545
|
+
confirmDelayMs = DIRECTORY_CONFIRM_DELAY_MS,
|
|
546
|
+
}) {
|
|
547
|
+
const db = await getStateDb();
|
|
548
|
+
const live = db
|
|
549
|
+
.prepare("SELECT * FROM directory_users WHERE status <> 'removed'")
|
|
550
|
+
.all()
|
|
551
|
+
.map(rowToUser);
|
|
552
|
+
const deletedByLamaste = new Set(deleted);
|
|
553
|
+
|
|
554
|
+
let snapshot = await readUsersSnapshot();
|
|
555
|
+
assertNotEmpty(snapshot, live);
|
|
556
|
+
const absentFirst = new Set(live.filter((u) => !snapshot.has(u.username)).map((u) => u.username));
|
|
557
|
+
if ([...absentFirst].some((username) => !deletedByLamaste.has(username))) {
|
|
558
|
+
// Someone left users.yml without Lamaste — or the read met a file
|
|
559
|
+
// Authelia was rewriting in place. Read it again before believing it.
|
|
560
|
+
if (confirmDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, confirmDelayMs));
|
|
561
|
+
snapshot = await readUsersSnapshot();
|
|
562
|
+
assertNotEmpty(snapshot, live);
|
|
563
|
+
}
|
|
564
|
+
// Gone in every read made: Lamaste's own deletions after one, anyone
|
|
565
|
+
// else's after two.
|
|
566
|
+
const missing = live.filter((u) => absentFirst.has(u.username) && !snapshot.has(u.username));
|
|
567
|
+
|
|
568
|
+
const now = new Date().toISOString();
|
|
569
|
+
const events = [];
|
|
570
|
+
const result = { created: 0, updated: 0, removed: [], disabled: [] };
|
|
571
|
+
const selectLive = db.prepare(
|
|
572
|
+
"SELECT * FROM directory_users WHERE username = ? AND status <> 'removed'",
|
|
573
|
+
);
|
|
574
|
+
const remove = db.prepare(
|
|
575
|
+
`UPDATE directory_users
|
|
576
|
+
SET status = 'removed', removed_reason = ?, removed_at = ?, version = version + 1,
|
|
577
|
+
updated_at = ?
|
|
578
|
+
WHERE user_id = ? AND status <> 'removed'`,
|
|
579
|
+
);
|
|
580
|
+
const insert = db.prepare(
|
|
581
|
+
`INSERT INTO directory_users
|
|
582
|
+
(user_id, username, display_name, email, status, version, created_at, updated_at)
|
|
583
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?)`,
|
|
584
|
+
);
|
|
585
|
+
const update = db.prepare(
|
|
586
|
+
`UPDATE directory_users
|
|
587
|
+
SET display_name = ?, email = ?, status = ?, version = version + 1, updated_at = ?
|
|
588
|
+
WHERE user_id = ?`,
|
|
589
|
+
);
|
|
590
|
+
db.exec('BEGIN IMMEDIATE');
|
|
591
|
+
try {
|
|
592
|
+
for (const user of missing) {
|
|
593
|
+
const reason = deletedByLamaste.has(user.username) ? 'deleted' : 'deleted-outside';
|
|
594
|
+
remove.run(reason, now, now, user.userId);
|
|
595
|
+
result.removed.push({ userId: user.userId, username: user.username, reason });
|
|
596
|
+
events.push({ action: 'directory.user.removed', details: { userId: user.userId, reason } });
|
|
597
|
+
}
|
|
598
|
+
for (const [username, attrs] of snapshot) {
|
|
599
|
+
const row = selectLive.get(username);
|
|
600
|
+
if (!row) {
|
|
601
|
+
const userId = crypto.randomUUID();
|
|
602
|
+
insert.run(userId, username, attrs.displayName, attrs.email, attrs.status, now, now);
|
|
603
|
+
result.created += 1;
|
|
604
|
+
events.push({
|
|
605
|
+
action: 'directory.user.created',
|
|
606
|
+
details: { userId, status: attrs.status },
|
|
607
|
+
});
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const changed = [];
|
|
611
|
+
if (row.display_name !== attrs.displayName) changed.push('displayName');
|
|
612
|
+
if (row.email !== attrs.email) changed.push('email');
|
|
613
|
+
if (row.status !== attrs.status) changed.push('status');
|
|
614
|
+
if (changed.length === 0) continue;
|
|
615
|
+
update.run(attrs.displayName, attrs.email, attrs.status, now, row.user_id);
|
|
616
|
+
result.updated += 1;
|
|
617
|
+
if (row.status !== 'disabled' && attrs.status === 'disabled') {
|
|
618
|
+
result.disabled.push(row.user_id);
|
|
619
|
+
}
|
|
620
|
+
events.push({
|
|
621
|
+
action: 'directory.user.updated',
|
|
622
|
+
details: { userId: row.user_id, changed, status: attrs.status },
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
db.exec('COMMIT');
|
|
626
|
+
} catch (err) {
|
|
627
|
+
db.exec('ROLLBACK');
|
|
628
|
+
throw err;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
for (const event of events) {
|
|
632
|
+
await recordAuditBestEffort({ actor, action: event.action, details: event.details }, logger);
|
|
633
|
+
}
|
|
634
|
+
if (events.length > 0) {
|
|
635
|
+
logger?.info(
|
|
636
|
+
{ created: result.created, updated: result.updated, removed: result.removed.length },
|
|
637
|
+
'User directory reconciled',
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
return result;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// Offboarding
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Offboard user `userId` — users lock held — who was just disabled
|
|
649
|
+
* (`kind: 'disabled'`) or removed (`'deleted'`), whoever did it: end their
|
|
650
|
+
* desktop sessions (bump their session epoch), revoke their registry
|
|
651
|
+
* tokens, and for a removal remove their legacy user-plugin-access grants,
|
|
652
|
+
* clear their Gatekeeper principal (pending and retried every minute when
|
|
653
|
+
* Gatekeeper fails) and delete their Authelia second factors (done again
|
|
654
|
+
* before the username is given to anyone). Each step is best effort; what
|
|
655
|
+
* failed comes back as `warnings` (`{ step, message }`, never naming the
|
|
656
|
+
* user). Gatekeeper's cached session verdicts are not dropped here: the
|
|
657
|
+
* caller does that once, after Authelia restarted where it restarts it.
|
|
658
|
+
*
|
|
659
|
+
* @param {string} userId
|
|
660
|
+
* @param {{ kind: 'disabled' | 'deleted', actor: string, logger?: import('pino').Logger }} opts
|
|
661
|
+
* @returns {Promise<Array<{ step: string, message: string }>>}
|
|
662
|
+
*/
|
|
663
|
+
async function offboardUserUnlocked(userId, { kind, actor, logger }) {
|
|
664
|
+
const warnings = [];
|
|
665
|
+
const db = await getStateDb();
|
|
666
|
+
const row = db.prepare('SELECT username FROM directory_users WHERE user_id = ?').get(userId);
|
|
667
|
+
const username = row?.username ?? null;
|
|
668
|
+
const failed = (step, message, err) => {
|
|
669
|
+
logger?.warn({ userId, step, err: err.message }, message);
|
|
670
|
+
warnings.push({ step, message: `${message}: ${err.message}` });
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
await bumpUserEpoch(userId);
|
|
675
|
+
} catch (err) {
|
|
676
|
+
failed('session-epoch', 'Live desktop sessions were not ended', err);
|
|
677
|
+
}
|
|
678
|
+
if (username !== null) {
|
|
679
|
+
try {
|
|
680
|
+
await revokeUserRegistryTokens(username, { deleted: true }, logger, { userId });
|
|
681
|
+
} catch (err) {
|
|
682
|
+
failed('registry', 'Registry tokens were not revoked', err);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (kind === 'deleted' && username !== null) {
|
|
686
|
+
try {
|
|
687
|
+
const removed = await removeGrantsForUser(username, logger ?? SILENT_LOGGER);
|
|
688
|
+
if (removed > 0)
|
|
689
|
+
logger?.info({ userId, removed }, 'Removed legacy user-plugin-access grants');
|
|
690
|
+
} catch (err) {
|
|
691
|
+
failed('user-plugin-access', 'Legacy user-plugin-access grants were not removed', err);
|
|
692
|
+
}
|
|
693
|
+
const cleared = await clearPrincipalUnlocked(username, { actor, logger });
|
|
694
|
+
if (!cleared.ok) {
|
|
695
|
+
logger?.warn(
|
|
696
|
+
{ userId, err: cleared.error },
|
|
697
|
+
'Gatekeeper did not clear a removed user’s principal',
|
|
698
|
+
);
|
|
699
|
+
warnings.push({
|
|
700
|
+
step: 'gatekeeper',
|
|
701
|
+
message: `Gatekeeper did not remove the user's grants and group memberships (${cleared.error}) — retried every minute; until it succeeds the username holds no role and cannot be given to a new user`,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
await purgeAutheliaUserSecondFactors(username);
|
|
706
|
+
} catch (err) {
|
|
707
|
+
failed(
|
|
708
|
+
'authelia-storage',
|
|
709
|
+
'Authelia kept the user’s second factors (TOTP, WebAuthn) — they are deleted before the username is given to anyone',
|
|
710
|
+
err,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
logger?.info({ userId, kind }, 'User offboarded');
|
|
715
|
+
return warnings;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Offboard every user a reconcile saw stop being one — removed, or newly
|
|
720
|
+
* disabled — users lock held; with `bustSessions`, drop Gatekeeper's cached
|
|
721
|
+
* session verdicts once after them. Returns the warnings.
|
|
722
|
+
*
|
|
723
|
+
* @param {DirectoryReconcileResult} reconciled
|
|
724
|
+
* @param {{ actor: string, logger?: import('pino').Logger, bustSessions: boolean }} opts
|
|
725
|
+
*/
|
|
726
|
+
async function offboardTransitionsUnlocked(reconciled, { actor, logger, bustSessions }) {
|
|
727
|
+
const warnings = [];
|
|
728
|
+
for (const { userId } of reconciled.removed) {
|
|
729
|
+
warnings.push(...(await offboardUserUnlocked(userId, { kind: 'deleted', actor, logger })));
|
|
730
|
+
}
|
|
731
|
+
for (const userId of reconciled.disabled) {
|
|
732
|
+
warnings.push(...(await offboardUserUnlocked(userId, { kind: 'disabled', actor, logger })));
|
|
733
|
+
}
|
|
734
|
+
if (bustSessions && reconciled.removed.length + reconciled.disabled.length > 0) {
|
|
735
|
+
warnings.push(...(await bustCachedSessions(logger)));
|
|
736
|
+
}
|
|
737
|
+
return warnings;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Drop Gatekeeper's cached session verdicts. Returns the warning when it
|
|
742
|
+
* did not (the cache expires within 30 seconds anyway).
|
|
743
|
+
*
|
|
744
|
+
* @param {import('pino').Logger} [logger]
|
|
745
|
+
* @returns {Promise<Array<{ step: string, message: string }>>}
|
|
746
|
+
*/
|
|
747
|
+
async function bustCachedSessions(logger) {
|
|
748
|
+
const busted = await bustGatekeeperSessions();
|
|
749
|
+
if (busted.ok) return [];
|
|
750
|
+
logger?.warn({ err: busted.error }, 'Gatekeeper did not drop its cached sessions');
|
|
751
|
+
return [
|
|
752
|
+
{
|
|
753
|
+
step: 'gatekeeper',
|
|
754
|
+
message: `Gatekeeper did not drop its cached sessions (${busted.error}); they expire within 30 seconds`,
|
|
755
|
+
},
|
|
756
|
+
];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Bring the directory in line with users.yml — read only on the file: a
|
|
761
|
+
* username it does not know gets a new user (active, or disabled per
|
|
762
|
+
* `disabled: true`), a user whose username left the file (seen in two reads
|
|
763
|
+
* {@link DIRECTORY_CONFIRM_DELAY_MS} apart) is removed as deleted outside
|
|
764
|
+
* Lamaste, and a changed display name, email or `disabled` is adopted
|
|
765
|
+
* (version + 1). Each change is audited by user id, and a user who was
|
|
766
|
+
* removed or became disabled is offboarded ({@link offboardUserUnlocked}).
|
|
767
|
+
* Throws, changing nothing, when users.yml cannot be read or lists nobody
|
|
768
|
+
* while the directory has users.
|
|
769
|
+
*
|
|
770
|
+
* @param {{ actor?: string, logger?: import('pino').Logger, confirmDelayMs?: number }} [opts]
|
|
771
|
+
* @returns {Promise<DirectoryReconcileResult>}
|
|
772
|
+
*/
|
|
773
|
+
export function reconcileDirectory({ actor = 'lamaste', logger, confirmDelayMs } = {}) {
|
|
774
|
+
return withUsersLock(async () => {
|
|
775
|
+
const reconciled = await reconcileUnlocked({ actor, logger, confirmDelayMs });
|
|
776
|
+
await offboardTransitionsUnlocked(reconciled, { actor, logger, bustSessions: true });
|
|
777
|
+
return reconciled;
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
// Writing users.yml
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Change users.yml — a read-modify-write `mutate` through `lib/authelia.js`
|
|
787
|
+
* — holding the users lock, all of it:
|
|
788
|
+
*
|
|
789
|
+
* 1. when it creates or deletes users, reconcile first (a user deleted
|
|
790
|
+
* outside Lamaste a moment ago is tombstoned before a namesake is
|
|
791
|
+
* created; a user about to be deleted must be in the directory to leave
|
|
792
|
+
* a tombstone), offboarding whatever that reconcile saw change;
|
|
793
|
+
* 2. for every username `creates` names, {@link prepareUsernameUnlocked}:
|
|
794
|
+
* refused when Authelia holds an OpenID Connect identity for it (409
|
|
795
|
+
* `username_has_oidc_identity`); when a removed user held it, Gatekeeper
|
|
796
|
+
* drops everything under it and Authelia its second factors — always
|
|
797
|
+
* (503 `gatekeeper_unavailable` / `authelia_storage_unavailable` when
|
|
798
|
+
* that cannot be done: a new user never inherits an earlier one's grants,
|
|
799
|
+
* groups or second factors);
|
|
800
|
+
* 3. `mutate`, then reconcile the directory (`deleted` names the usernames
|
|
801
|
+
* `mutate` deletes: removed as `deleted`), offboarding every user that
|
|
802
|
+
* reconcile saw removed or newly disabled ({@link offboardUserUnlocked});
|
|
803
|
+
* 4. restart Authelia (unless `restartAuthelia` is false: onboarding starts
|
|
804
|
+
* it next) — which ends every session — and drop Gatekeeper's cached
|
|
805
|
+
* session verdicts with them;
|
|
806
|
+
* 5. with `expect` (`(users) => boolean` over users.yml's `users` map: does
|
|
807
|
+
* the file hold the intended change?), read users.yml again: Authelia's
|
|
808
|
+
* own password reset rewrites the file from what it read at start, so a
|
|
809
|
+
* write made just before its restart can be lost. When the change is not
|
|
810
|
+
* there it is made once more (steps 3 and 4); when it is still not
|
|
811
|
+
* there, `persisted` is false and a `persist` warning says so.
|
|
812
|
+
*
|
|
813
|
+
* Then the plugins' audiences are recomputed. A refusal `mutate` (or step
|
|
814
|
+
* 2) throws comes back as it was, nothing written; what goes wrong after
|
|
815
|
+
* the write is returned in `warnings` (`{ step, message }`) — the write
|
|
816
|
+
* stays, and a cleanup that failed stays pending (Gatekeeper: retried every
|
|
817
|
+
* minute; Authelia: done again before the username is given to anyone).
|
|
818
|
+
*
|
|
819
|
+
* @template T
|
|
820
|
+
* @param {() => Promise<T>} mutate
|
|
821
|
+
* @param {{ actor: string, logger?: import('pino').Logger, creates?: string[], deleted?: string[], restartAuthelia?: boolean, autheliaStorage?: boolean, confirmDelayMs?: number, expect?: (users: Record<string, unknown>) => boolean }} opts
|
|
822
|
+
* @returns {Promise<{ result: T, directory: DirectoryReconcileResult | null, warnings: Array<{ step: string, message: string }>, persisted: boolean }>}
|
|
823
|
+
*/
|
|
824
|
+
export async function changeUsers(
|
|
825
|
+
mutate,
|
|
826
|
+
{
|
|
827
|
+
actor,
|
|
828
|
+
logger,
|
|
829
|
+
creates = [],
|
|
830
|
+
deleted = [],
|
|
831
|
+
restartAuthelia = true,
|
|
832
|
+
autheliaStorage = true,
|
|
833
|
+
confirmDelayMs,
|
|
834
|
+
expect,
|
|
835
|
+
},
|
|
836
|
+
) {
|
|
837
|
+
const warnings = [];
|
|
838
|
+
|
|
839
|
+
/** Reconcile after the write and offboard what it saw; null when users.yml cannot be read. */
|
|
840
|
+
const settle = async () => {
|
|
841
|
+
let reconciled = null;
|
|
842
|
+
try {
|
|
843
|
+
reconciled = await reconcileUnlocked({ actor, logger, deleted, confirmDelayMs });
|
|
844
|
+
} catch (err) {
|
|
845
|
+
logger?.error({ err: err.message }, 'Could not bring the user directory in line');
|
|
846
|
+
warnings.push({
|
|
847
|
+
step: 'directory',
|
|
848
|
+
message: `The user directory was not updated (${err.message}); it catches up at the next reconcile, within a minute`,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
if (reconciled) {
|
|
852
|
+
warnings.push(
|
|
853
|
+
...(await offboardTransitionsUnlocked(reconciled, { actor, logger, bustSessions: false })),
|
|
854
|
+
);
|
|
855
|
+
} else {
|
|
856
|
+
// No tombstone yet (the next reconcile makes it, pending): Gatekeeper
|
|
857
|
+
// drops what it holds under the deleted usernames now all the same.
|
|
858
|
+
for (const username of deleted) {
|
|
859
|
+
const cleared = await clearPrincipalUnlocked(username, { actor, logger });
|
|
860
|
+
if (!cleared.ok) {
|
|
861
|
+
warnings.push({
|
|
862
|
+
step: 'gatekeeper',
|
|
863
|
+
message: `Gatekeeper did not remove the user's grants and group memberships (${cleared.error}) — retried every minute`,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return reconciled;
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const restart = async () => {
|
|
872
|
+
if (!restartAuthelia) return;
|
|
873
|
+
try {
|
|
874
|
+
await reloadAuthelia();
|
|
875
|
+
} catch (err) {
|
|
876
|
+
logger?.warn({ err: err.message }, 'Failed to restart Authelia after a users change');
|
|
877
|
+
warnings.push({ step: 'authelia', message: `Authelia was not restarted: ${err.message}` });
|
|
878
|
+
}
|
|
879
|
+
warnings.push(...(await bustCachedSessions(logger)));
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const holds = async () => {
|
|
883
|
+
try {
|
|
884
|
+
const raw = await readUsersRaw();
|
|
885
|
+
return expect(isRecord(raw?.users) ? raw.users : {});
|
|
886
|
+
} catch {
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
const outcome = await withUsersLock(async () => {
|
|
892
|
+
if (creates.length > 0 || deleted.length > 0) {
|
|
893
|
+
try {
|
|
894
|
+
const before = await reconcileUnlocked({ actor, logger, confirmDelayMs });
|
|
895
|
+
warnings.push(
|
|
896
|
+
...(await offboardTransitionsUnlocked(before, { actor, logger, bustSessions: false })),
|
|
897
|
+
);
|
|
898
|
+
} catch (err) {
|
|
899
|
+
logger?.warn(
|
|
900
|
+
{ err: err.message },
|
|
901
|
+
'Could not reconcile the directory before a users change',
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
for (const username of creates) {
|
|
906
|
+
await prepareUsernameUnlocked(username, { actor, logger, autheliaStorage });
|
|
907
|
+
}
|
|
908
|
+
let value = await mutate();
|
|
909
|
+
let reconciled = await settle();
|
|
910
|
+
await restart();
|
|
911
|
+
|
|
912
|
+
let persisted = true;
|
|
913
|
+
if (expect && !(await holds())) {
|
|
914
|
+
// Authelia rewrote users.yml over the change (its own password reset
|
|
915
|
+
// writes the file from what it read at start): make it once more.
|
|
916
|
+
logger?.warn('users.yml lost a change while Authelia restarted — making it again');
|
|
917
|
+
try {
|
|
918
|
+
value = await mutate();
|
|
919
|
+
const again = await settle();
|
|
920
|
+
if (again) {
|
|
921
|
+
reconciled = reconciled
|
|
922
|
+
? {
|
|
923
|
+
created: reconciled.created + again.created,
|
|
924
|
+
updated: reconciled.updated + again.updated,
|
|
925
|
+
removed: [...reconciled.removed, ...again.removed],
|
|
926
|
+
disabled: [...reconciled.disabled, ...again.disabled],
|
|
927
|
+
}
|
|
928
|
+
: again;
|
|
929
|
+
}
|
|
930
|
+
await restart();
|
|
931
|
+
persisted = await holds();
|
|
932
|
+
} catch (err) {
|
|
933
|
+
logger?.warn({ err: err.message }, 'Making a lost users change again failed');
|
|
934
|
+
persisted = false;
|
|
935
|
+
}
|
|
936
|
+
if (!persisted) {
|
|
937
|
+
logger?.error('users.yml does not hold a users change after it was made twice');
|
|
938
|
+
warnings.push({
|
|
939
|
+
step: 'persist',
|
|
940
|
+
message:
|
|
941
|
+
'users.yml does not hold the change: Authelia rewrote the file while it was being made (a password reset, say), and making it again did not stick either — check the user and make the change again',
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return { result: value, directory: reconciled, persisted };
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
const { recomputeAudiences } = await import('./audience.js');
|
|
949
|
+
await recomputeAudiences({ logger });
|
|
950
|
+
return { ...outcome, warnings };
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ---------------------------------------------------------------------------
|
|
954
|
+
// Erasure
|
|
955
|
+
// ---------------------------------------------------------------------------
|
|
956
|
+
|
|
957
|
+
/** Whether an invitation was made at or before `time` (or says nothing usable about when). */
|
|
958
|
+
function madeBy(invitation, time) {
|
|
959
|
+
const made = Date.parse(invitation?.createdAt);
|
|
960
|
+
return !Number.isFinite(made) || made <= Date.parse(time);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Delete the invitations of `username` made at or before `removedAt` — the
|
|
965
|
+
* removed user's, accepted or not; a later invitation for the same
|
|
966
|
+
* username is someone else's.
|
|
967
|
+
*
|
|
968
|
+
* @returns {Promise<number>} how many went
|
|
969
|
+
*/
|
|
970
|
+
function purgeInvitations(username, removedAt) {
|
|
971
|
+
return withInvitationsLock(async () => {
|
|
972
|
+
const invitations = await readInvitations();
|
|
973
|
+
const kept = invitations.filter(
|
|
974
|
+
(inv) => !(inv?.username === username && madeBy(inv, removedAt)),
|
|
975
|
+
);
|
|
976
|
+
if (kept.length === invitations.length) return 0;
|
|
977
|
+
await writeInvitations(kept);
|
|
978
|
+
return invitations.length - kept.length;
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Erase removed user `userId`: with the username the tombstone still holds,
|
|
984
|
+
* clear their Gatekeeper principal if that is still pending, delete
|
|
985
|
+
* Gatekeeper's access-request log rows, their Authelia second factors and
|
|
986
|
+
* their invitations (log and invitations as of when they were removed),
|
|
987
|
+
* then scrub the row to the bare id
|
|
988
|
+
* (`erased_at` set, version + 1), record who asked, audit it, and recompute
|
|
989
|
+
* the audiences — every plugin that had them as a member is sent
|
|
990
|
+
* `remove/erased`, and acknowledges it when its half's cursor passes it.
|
|
991
|
+
*
|
|
992
|
+
* Refuses (409 `user_not_removed`) a user who is active or disabled —
|
|
993
|
+
* delete them first, or delete and erase in one step — and (409
|
|
994
|
+
* `already_erased`) a second erasure. When Gatekeeper cannot purge its log
|
|
995
|
+
* (503 `gatekeeper_unavailable`) or Authelia's storage cannot be changed
|
|
996
|
+
* (503 `authelia_storage_unavailable`) nothing is scrubbed: the tombstone
|
|
997
|
+
* still names the username a later attempt needs. What Lamaste cannot reach
|
|
998
|
+
* stays: Authelia's OpenID Connect opaque identifier for the username and
|
|
999
|
+
* its authentication log.
|
|
1000
|
+
*
|
|
1001
|
+
* @param {string} userId
|
|
1002
|
+
* @param {{ actor: string, logger?: import('pino').Logger }} opts
|
|
1003
|
+
* @returns {Promise<{ userId: string, erasedAt: string, invitationsPurged: number, accessLogPurged: number }>}
|
|
1004
|
+
*/
|
|
1005
|
+
export async function eraseDirectoryUser(userId, { actor, logger }) {
|
|
1006
|
+
const erased = await withUsersLock(async () => {
|
|
1007
|
+
const db = await getStateDb();
|
|
1008
|
+
const row = db.prepare('SELECT * FROM directory_users WHERE user_id = ?').get(userId);
|
|
1009
|
+
if (!row)
|
|
1010
|
+
throw httpError('No user with this id in the directory', 404, { code: 'user_not_found' });
|
|
1011
|
+
if (row.status !== 'removed') {
|
|
1012
|
+
throw httpError(
|
|
1013
|
+
'Only a removed user is erased — delete the user first, or delete and erase in one step (DELETE /api/users/:username?erase=true)',
|
|
1014
|
+
409,
|
|
1015
|
+
{ code: 'user_not_removed' },
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
if (row.erased_at !== null) {
|
|
1019
|
+
throw httpError('This user was erased already', 409, { code: 'already_erased' });
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Gatekeeper first: the one service that may be unavailable, so a
|
|
1023
|
+
// refusal leaves everything here as it was. The principal is cleared
|
|
1024
|
+
// while the username that names it is still known; both Gatekeeper
|
|
1025
|
+
// steps and the purge of invitations can be repeated.
|
|
1026
|
+
let invitationsPurged = 0;
|
|
1027
|
+
let accessLogPurged = 0;
|
|
1028
|
+
if (row.username !== null) {
|
|
1029
|
+
if (row.principal_cleared_at === null) {
|
|
1030
|
+
const cleared = await clearPrincipalUnlocked(row.username, { actor, logger });
|
|
1031
|
+
if (!cleared.ok) {
|
|
1032
|
+
throw httpError(
|
|
1033
|
+
`Gatekeeper could not remove the user's grants and group memberships (${cleared.error}) — nothing was erased; try again once Gatekeeper answers`,
|
|
1034
|
+
503,
|
|
1035
|
+
{ code: 'gatekeeper_unavailable' },
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const purge = await purgeAccessLogForUser(row.username, { before: row.removed_at });
|
|
1040
|
+
if (!purge.ok) {
|
|
1041
|
+
throw httpError(
|
|
1042
|
+
`Gatekeeper's access-request log could not be purged (${purge.error}) — nothing was erased; try again once Gatekeeper answers`,
|
|
1043
|
+
503,
|
|
1044
|
+
{ code: 'gatekeeper_unavailable' },
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
accessLogPurged = purge.removed;
|
|
1048
|
+
// Authelia's second factors under the username go before it is
|
|
1049
|
+
// forgotten (they went at removal unless that failed — then never
|
|
1050
|
+
// again, once nothing here names it).
|
|
1051
|
+
try {
|
|
1052
|
+
await purgeAutheliaUserSecondFactors(row.username);
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
throw httpError(
|
|
1055
|
+
`Authelia's storage could not delete the user's second factors (${err.message}) — nothing was erased; try again`,
|
|
1056
|
+
503,
|
|
1057
|
+
{ code: 'authelia_storage_unavailable' },
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
invitationsPurged = await purgeInvitations(row.username, row.removed_at);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const now = new Date().toISOString();
|
|
1064
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1065
|
+
try {
|
|
1066
|
+
db.prepare(
|
|
1067
|
+
`UPDATE directory_users
|
|
1068
|
+
SET username = NULL, display_name = NULL, email = NULL, erased_at = ?,
|
|
1069
|
+
version = version + 1, updated_at = ?
|
|
1070
|
+
WHERE user_id = ? AND status = 'removed' AND erased_at IS NULL`,
|
|
1071
|
+
).run(now, now, userId);
|
|
1072
|
+
db.prepare(
|
|
1073
|
+
'INSERT INTO directory_erasures (user_id, requested_at, requested_by) VALUES (?, ?, ?)',
|
|
1074
|
+
).run(userId, now, actor);
|
|
1075
|
+
db.exec('COMMIT');
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
db.exec('ROLLBACK');
|
|
1078
|
+
throw err;
|
|
1079
|
+
}
|
|
1080
|
+
await recordAuditBestEffort(
|
|
1081
|
+
{
|
|
1082
|
+
actor,
|
|
1083
|
+
action: 'directory.user.erased',
|
|
1084
|
+
details: { userId, invitationsPurged, accessLogPurged },
|
|
1085
|
+
},
|
|
1086
|
+
logger,
|
|
1087
|
+
);
|
|
1088
|
+
logger?.info({ userId, invitationsPurged, accessLogPurged }, 'Erased a removed user');
|
|
1089
|
+
return { userId, erasedAt: now, invitationsPurged, accessLogPurged };
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
const { recomputeAudiences } = await import('./audience.js');
|
|
1093
|
+
await recomputeAudiences({ logger });
|
|
1094
|
+
return erased;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Who erased each erased user, and when.
|
|
1099
|
+
*
|
|
1100
|
+
* @returns {Promise<Map<string, { requestedAt: string, requestedBy: string }>>}
|
|
1101
|
+
*/
|
|
1102
|
+
export async function listErasures() {
|
|
1103
|
+
const db = await getStateDb();
|
|
1104
|
+
return new Map(
|
|
1105
|
+
db
|
|
1106
|
+
.prepare('SELECT * FROM directory_erasures')
|
|
1107
|
+
.all()
|
|
1108
|
+
.map((row) => [
|
|
1109
|
+
row.user_id,
|
|
1110
|
+
{ requestedAt: row.requested_at, requestedBy: row.requested_by },
|
|
1111
|
+
]),
|
|
1112
|
+
);
|
|
1113
|
+
}
|