@lamalibre/create-lamaste 4.4.1 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/lamaste-priv +234 -18
- package/src/index.js +1 -0
- package/src/lib/backend-cookie.js +65 -0
- package/src/lib/ip-panel-vhost.js +208 -0
- package/src/lib/service-config.js +5 -3
- package/src/tasks/nginx.js +18 -135
- package/src/tasks/redeploy.js +39 -1
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +1 -1
- package/vendor/docs/01-concepts/authorization.md +66 -48
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +70 -33
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +5 -5
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/managing-users.md +113 -25
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/02-guides/static-sites.md +4 -0
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +3 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +229 -24
- package/vendor/docs/03-architecture/overview.md +13 -17
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +3 -2
- package/vendor/docs/03-architecture/state-management.md +4 -8
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +378 -34
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +78 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
- package/vendor/docs/04-api-reference/sites.md +4 -0
- package/vendor/docs/04-api-reference/tunnels.md +87 -47
- package/vendor/docs/04-api-reference/users.md +241 -45
- package/vendor/docs/05-operations/backup-and-restore.md +35 -26
- package/vendor/docs/05-operations/monitoring.md +39 -0
- package/vendor/docs/05-operations/uninstalling.md +2 -1
- package/vendor/docs/05-operations/upgrades.md +24 -6
- package/vendor/docs/06-reference/config-files.md +100 -202
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +25 -26
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +10 -8
- package/vendor/docs/identity-directory-design.md +48 -14
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/cli/index.js +0 -0
- package/vendor/gatekeeper/dist/index.d.ts +4 -3
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +4 -2
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.js +31 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.js +5 -2
- package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +6 -1
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.js +5 -1
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
- package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/principals.js +67 -0
- package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +10 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +49 -56
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +332 -104
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +15 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-identity.js +335 -0
- package/vendor/server/src/commands/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +156 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/sites.js +15 -1
- package/vendor/server/src/commands/tunnels.js +102 -2
- package/vendor/server/src/commands/uninstall.js +3 -1
- package/vendor/server/src/commands/users.js +358 -0
- package/vendor/server/src/plugin-approval.js +186 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server/src/plugin-web-surface.js +307 -0
- package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
- package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +34 -5
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- package/vendor/serverd/src/lib/config.js +68 -43
- package/vendor/serverd/src/lib/directory.js +1113 -0
- package/vendor/serverd/src/lib/entitlements.js +517 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +226 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
- package/vendor/serverd/src/lib/identity-sync.js +905 -0
- package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
- package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
- package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +546 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +95 -7
- package/vendor/serverd/src/lib/registry-credential.js +9 -6
- package/vendor/serverd/src/lib/request-logging.js +19 -0
- package/vendor/serverd/src/lib/retained-data.js +50 -4
- package/vendor/serverd/src/lib/state.js +36 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
- package/vendor/serverd/src/lib/user-access-session.js +21 -11
- package/vendor/serverd/src/lib/user-caller.js +67 -0
- package/vendor/serverd/src/lib/user-surface.js +754 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
- package/vendor/serverd/src/lib/web-surface.js +766 -0
- package/vendor/serverd/src/middleware/user-access-session.js +9 -0
- package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
- package/vendor/serverd/src/routes/invite.js +53 -12
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
- package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
- package/vendor/serverd/src/routes/management/invitations.js +78 -68
- package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
- package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
- package/vendor/serverd/src/routes/management/plugins.js +2 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/sites.js +143 -65
- package/vendor/serverd/src/routes/management/tunnels.js +109 -22
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +6 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
- package/vendor/serverd/src/routes/user-access.js +17 -4
- package/vendor/serverd/src/routes/user-surface.js +69 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
- package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin audiences — who each agent-hosted plugin's users are, as its agent
|
|
3
|
+
* half is told (`identity.ts` in the core library: `AudienceChange`).
|
|
4
|
+
*
|
|
5
|
+
* For every installed plugin that declares `identity`, the audience is the
|
|
6
|
+
* directory's users who hold at least one of its roles: the union of the
|
|
7
|
+
* roles of their direct entitlements and of the Lamaste groups they are in
|
|
8
|
+
* (`lib/entitlements.js` — only plugin grants with roles count), restricted
|
|
9
|
+
* to the roles the installed version declares. A member is carried in full
|
|
10
|
+
* (`upsert`: id, username, the declared attributes the directory has, the
|
|
11
|
+
* roles, `active` or `disabled`, the record's version); a user who leaves is
|
|
12
|
+
* a `remove` — `unentitled` (lost every role) or `deleted` (removed from the
|
|
13
|
+
* directory), each with the last known declared attributes, or `erased`
|
|
14
|
+
* (scrubbed: nothing but the id). Users who were never members have no row.
|
|
15
|
+
*
|
|
16
|
+
* The audience is materialised in `plugin_audience` (state.db): one row per
|
|
17
|
+
* plugin and user, the change as the feed delivers it (without `seq`),
|
|
18
|
+
* written only when it differs from the stored one — its version goes up by
|
|
19
|
+
* one and the triggers stamp the next store-wide `seq`. A removal carries
|
|
20
|
+
* what the plugin last received about the user (less any attribute it no
|
|
21
|
+
* longer declares): what the directory learns about someone who left is no
|
|
22
|
+
* longer the plugin's.
|
|
23
|
+
*
|
|
24
|
+
* **The rows are the plugin's ledger** of everyone its agent halves were
|
|
25
|
+
* ever sent, and so of the erasures it owes. They stay when the plugin is
|
|
26
|
+
* uninstalled or stops declaring identity — its data stays on an agent
|
|
27
|
+
* (retained data, or a hosted half that no longer takes the feed) — and go
|
|
28
|
+
* only once no data of the plugin is left anywhere: it is not installed and
|
|
29
|
+
* no agent retains its data ({@link dropIdentityLedgerIfNoData}: an
|
|
30
|
+
* uninstall with purge, a purge or forget of retained data). Such an
|
|
31
|
+
* *inactive* plugin's rows are recomputed for erasures alone: erasing a
|
|
32
|
+
* user rewrites the user's row in every plugin that has one, inactive ones
|
|
33
|
+
* included (a new `seq`), so a plugin that comes back — reinstalled, its
|
|
34
|
+
* half reusing the data an agent kept, or declaring identity again —
|
|
35
|
+
* replays the erasure (its next hosting replays everything). The erasures an
|
|
36
|
+
* inactive plugin has not acknowledged stay pending, marked as such
|
|
37
|
+
* (`lib/identity-sync.js` `pendingErasuresByUser`).
|
|
38
|
+
*
|
|
39
|
+
* Recomputed after every change of users, of the directory and of
|
|
40
|
+
* entitlements made through the panel (the Gatekeeper proxy included), on
|
|
41
|
+
* plugin install, update and approval, every minute (Gatekeeper's own CLI
|
|
42
|
+
* edits), and at start. Recomputes are serialised and coalesce. Erasures
|
|
43
|
+
* reach every ledger first, whatever Gatekeeper answers; when Gatekeeper
|
|
44
|
+
* cannot be read the rest of the stored audience stays as it is, and the
|
|
45
|
+
* next recompute tries again. A declaring plugin whose rows changed is
|
|
46
|
+
* synced to its agent half (`lib/identity-sync.js`).
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
AudienceChangeSchema,
|
|
51
|
+
AudienceMemberSchema,
|
|
52
|
+
IDENTITY_ATTRIBUTES,
|
|
53
|
+
isLamasteUsername,
|
|
54
|
+
} from '@lamalibre/lamaste';
|
|
55
|
+
import { listDirectoryUsers, pendingPrincipalCleanups } from './directory.js';
|
|
56
|
+
import { declaredRoleIds, grantRoles } from './entitlements.js';
|
|
57
|
+
import { listGatekeeperGroups, listPluginGrants } from './gatekeeper-client.js';
|
|
58
|
+
import { findPlugin, readPlugins } from './plugins.js';
|
|
59
|
+
import { getStateDb } from './state-db.js';
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Reading the stored audience
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function parseChange(text) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {object} AudienceRow
|
|
75
|
+
* @property {string} userId
|
|
76
|
+
* @property {object} change the AudienceChange without `seq`
|
|
77
|
+
* @property {number} version
|
|
78
|
+
* @property {number} seq
|
|
79
|
+
* @property {string} updatedAt
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
function rowToAudience(row) {
|
|
83
|
+
return {
|
|
84
|
+
userId: row.user_id,
|
|
85
|
+
change: parseChange(row.change),
|
|
86
|
+
version: row.version,
|
|
87
|
+
seq: row.seq,
|
|
88
|
+
updatedAt: row.updated_at,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The plugin's audience rows in `seq` order — members and tombstones.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} plugin
|
|
96
|
+
* @returns {Promise<AudienceRow[]>}
|
|
97
|
+
*/
|
|
98
|
+
export async function listAudience(plugin) {
|
|
99
|
+
const db = await getStateDb();
|
|
100
|
+
return db
|
|
101
|
+
.prepare('SELECT * FROM plugin_audience WHERE plugin = ? ORDER BY seq')
|
|
102
|
+
.all(plugin)
|
|
103
|
+
.map(rowToAudience);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The plugin's rows stamped after `cursor`, in `seq` order — what a half at
|
|
108
|
+
* that cursor has not applied.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} plugin
|
|
111
|
+
* @param {number} cursor
|
|
112
|
+
* @returns {Promise<AudienceRow[]>}
|
|
113
|
+
*/
|
|
114
|
+
export async function audienceRowsAfter(plugin, cursor) {
|
|
115
|
+
const db = await getStateDb();
|
|
116
|
+
return db
|
|
117
|
+
.prepare('SELECT * FROM plugin_audience WHERE plugin = ? AND seq > ? ORDER BY seq')
|
|
118
|
+
.all(plugin, cursor)
|
|
119
|
+
.map(rowToAudience);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The plugin's head: the highest `seq` of its rows, 0 when it has none.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} plugin
|
|
126
|
+
*/
|
|
127
|
+
export async function audienceHead(plugin) {
|
|
128
|
+
const db = await getStateDb();
|
|
129
|
+
const row = db
|
|
130
|
+
.prepare('SELECT COALESCE(MAX(seq), 0) AS head FROM plugin_audience WHERE plugin = ?')
|
|
131
|
+
.get(plugin);
|
|
132
|
+
return Number(row.head);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One user's row in the plugin's audience, or null.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} plugin
|
|
139
|
+
* @param {string} userId
|
|
140
|
+
* @returns {Promise<AudienceRow | null>}
|
|
141
|
+
*/
|
|
142
|
+
export async function readAudienceRow(plugin, userId) {
|
|
143
|
+
const db = await getStateDb();
|
|
144
|
+
const row = db
|
|
145
|
+
.prepare('SELECT * FROM plugin_audience WHERE plugin = ? AND user_id = ?')
|
|
146
|
+
.get(plugin, userId);
|
|
147
|
+
return row ? rowToAudience(row) : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* How many of the plugin's rows are active members, disabled members,
|
|
152
|
+
* removed (deleted or unentitled) and erased; and its head.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} plugin
|
|
155
|
+
*/
|
|
156
|
+
export async function audienceSummary(plugin) {
|
|
157
|
+
const rows = await listAudience(plugin);
|
|
158
|
+
const summary = { active: 0, disabled: 0, removed: 0, erased: 0, head: 0 };
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
summary.head = Math.max(summary.head, row.seq);
|
|
161
|
+
const change = row.change;
|
|
162
|
+
if (change?.kind === 'upsert') {
|
|
163
|
+
if (change.member?.status === 'disabled') summary.disabled += 1;
|
|
164
|
+
else summary.active += 1;
|
|
165
|
+
} else if (change?.reason === 'erased') summary.erased += 1;
|
|
166
|
+
else summary.removed += 1;
|
|
167
|
+
}
|
|
168
|
+
return summary;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Computing a plugin's audience
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The roles each username holds on the plugin: the union over its direct
|
|
177
|
+
* entitlements and those of the Lamaste groups it is in, restricted to the
|
|
178
|
+
* declared roles, sorted. Grants without roles never count.
|
|
179
|
+
*
|
|
180
|
+
* @param {Array<{ principalType: 'user' | 'group', principalId: string, context: Record<string, unknown> }>} grants
|
|
181
|
+
* @param {Map<string, { members: string[] }>} groups by name
|
|
182
|
+
* @param {Set<string>} declared role ids
|
|
183
|
+
* @returns {Map<string, string[]>}
|
|
184
|
+
*/
|
|
185
|
+
function rolesByUsername(grants, groups, declared) {
|
|
186
|
+
const held = new Map();
|
|
187
|
+
const add = (username, roles) => {
|
|
188
|
+
let set = held.get(username);
|
|
189
|
+
if (!set) {
|
|
190
|
+
set = new Set();
|
|
191
|
+
held.set(username, set);
|
|
192
|
+
}
|
|
193
|
+
for (const role of roles) if (declared.has(role)) set.add(role);
|
|
194
|
+
};
|
|
195
|
+
for (const grant of grants) {
|
|
196
|
+
const roles = grantRoles(grant);
|
|
197
|
+
if (!roles) continue;
|
|
198
|
+
if (grant.principalType === 'user') add(grant.principalId, roles);
|
|
199
|
+
else for (const member of groups.get(grant.principalId)?.members ?? []) add(member, roles);
|
|
200
|
+
}
|
|
201
|
+
const out = new Map();
|
|
202
|
+
for (const [username, set] of held) if (set.size > 0) out.set(username, [...set].sort());
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The declared attributes the directory has for `user` (its fields carry
|
|
208
|
+
* the attributes' names) that pass the feed's checks — an attribute the
|
|
209
|
+
* directory lacks, or that does not pass (a display name with control
|
|
210
|
+
* characters, say), is left out.
|
|
211
|
+
*/
|
|
212
|
+
function declaredAttributes(user, attributes) {
|
|
213
|
+
const out = {};
|
|
214
|
+
for (const attribute of IDENTITY_ATTRIBUTES) {
|
|
215
|
+
if (!attributes.includes(attribute)) continue;
|
|
216
|
+
const value = user[attribute];
|
|
217
|
+
if (
|
|
218
|
+
typeof value === 'string' &&
|
|
219
|
+
AudienceMemberSchema.shape[attribute].safeParse(value).success
|
|
220
|
+
) {
|
|
221
|
+
out[attribute] = value;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The change of a member; `version` is filled in when it is written. */
|
|
228
|
+
function upsertChange(user, roles, attributes) {
|
|
229
|
+
return {
|
|
230
|
+
kind: 'upsert',
|
|
231
|
+
member: {
|
|
232
|
+
userId: user.userId,
|
|
233
|
+
username: user.username,
|
|
234
|
+
...declaredAttributes(user, attributes),
|
|
235
|
+
roles,
|
|
236
|
+
status: user.status,
|
|
237
|
+
version: 0,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What the plugin last received about a user, restricted to the attributes
|
|
244
|
+
* it declares now: the member it was sent, or the `last` of the removal it
|
|
245
|
+
* was sent. Null when that was an erasure.
|
|
246
|
+
*/
|
|
247
|
+
function lastDelivered(change, attributes) {
|
|
248
|
+
const source = change.kind === 'upsert' ? change.member : change.last;
|
|
249
|
+
if (!source || typeof source.username !== 'string') return null;
|
|
250
|
+
const last = { username: source.username };
|
|
251
|
+
for (const attribute of IDENTITY_ATTRIBUTES) {
|
|
252
|
+
if (attributes.includes(attribute) && typeof source[attribute] === 'string') {
|
|
253
|
+
last[attribute] = source[attribute];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return last;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A change as compared: a member's version is the row's, not part of what changed. */
|
|
260
|
+
function comparable(change) {
|
|
261
|
+
if (change?.kind !== 'upsert') return JSON.stringify(change);
|
|
262
|
+
return JSON.stringify({ kind: 'upsert', member: { ...change.member, version: 0 } });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* What the plugin's audience should hold for each user: a member's full
|
|
267
|
+
* state, or the reason a former member left with what the plugin last
|
|
268
|
+
* received about them — a user who left is no longer the plugin's to hear
|
|
269
|
+
* about, so later changes in the directory never reach it, and attributes
|
|
270
|
+
* it stopped declaring are dropped. Nothing is said about users who were
|
|
271
|
+
* never members. A username whose earlier holder's Gatekeeper principal is
|
|
272
|
+
* still to be cleared (`pending`) holds no role: what Gatekeeper has under
|
|
273
|
+
* it may be theirs.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} identity the plugin's normalized identity block
|
|
276
|
+
* @param {import('./directory.js').DirectoryUser[]} users
|
|
277
|
+
* @param {Map<string, string[]>} roles by username
|
|
278
|
+
* @param {Map<string, AudienceRow>} stored by user id
|
|
279
|
+
* @param {Set<string>} [pending] usernames with a pending principal cleanup
|
|
280
|
+
* @returns {{ desired: Map<string, object>, ineligible: number }}
|
|
281
|
+
*/
|
|
282
|
+
function desiredAudience(identity, users, roles, stored, pending = new Set()) {
|
|
283
|
+
const attributes = identity.attributes ?? [];
|
|
284
|
+
const desired = new Map();
|
|
285
|
+
let ineligible = 0;
|
|
286
|
+
for (const user of users) {
|
|
287
|
+
const existing = stored.get(user.userId)?.change ?? null;
|
|
288
|
+
/** A former member leaves (or stays gone) for `reason`. */
|
|
289
|
+
const leave = (reason) => {
|
|
290
|
+
const last = lastDelivered(existing, attributes);
|
|
291
|
+
if (last) desired.set(user.userId, { kind: 'remove', userId: user.userId, reason, last });
|
|
292
|
+
};
|
|
293
|
+
if (user.erasedAt !== null) {
|
|
294
|
+
// An erased user is the id alone.
|
|
295
|
+
if (existing) {
|
|
296
|
+
desired.set(user.userId, { kind: 'remove', userId: user.userId, reason: 'erased' });
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (user.status === 'removed') {
|
|
301
|
+
if (existing) leave('deleted');
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const held = pending.has(user.username) ? undefined : roles.get(user.username);
|
|
305
|
+
if (held && held.length > 0) {
|
|
306
|
+
// A username the feed cannot carry is never a member (users.yml
|
|
307
|
+
// edited outside Lamaste may hold one); counted, never named.
|
|
308
|
+
if (isLamasteUsername(user.username)) {
|
|
309
|
+
desired.set(user.userId, upsertChange(user, held, attributes));
|
|
310
|
+
} else ineligible += 1;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (existing) leave('unentitled');
|
|
314
|
+
}
|
|
315
|
+
return { desired, ineligible };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Write the rows of `desired` that differ from `stored`, in one transaction:
|
|
320
|
+
* version + 1 (1 for a new row), a member's version set to it, each change
|
|
321
|
+
* checked against core's `AudienceChangeSchema` first (one that fails is not
|
|
322
|
+
* written, and logged by user id). Returns how many rows were written.
|
|
323
|
+
*/
|
|
324
|
+
function writeAudience(db, plugin, desired, stored, logger) {
|
|
325
|
+
const now = new Date().toISOString();
|
|
326
|
+
const insert = db.prepare(
|
|
327
|
+
'INSERT INTO plugin_audience (plugin, user_id, change, version, updated_at) VALUES (?, ?, ?, ?, ?)',
|
|
328
|
+
);
|
|
329
|
+
const update = db.prepare(
|
|
330
|
+
'UPDATE plugin_audience SET change = ?, version = ?, updated_at = ? WHERE plugin = ? AND user_id = ?',
|
|
331
|
+
);
|
|
332
|
+
let written = 0;
|
|
333
|
+
db.exec('BEGIN IMMEDIATE');
|
|
334
|
+
try {
|
|
335
|
+
for (const [userId, change] of desired) {
|
|
336
|
+
const existing = stored.get(userId) ?? null;
|
|
337
|
+
if (existing && comparable(existing.change) === comparable(change)) continue;
|
|
338
|
+
const version = existing ? existing.version + 1 : 1;
|
|
339
|
+
const next =
|
|
340
|
+
change.kind === 'upsert'
|
|
341
|
+
? { kind: 'upsert', member: { ...change.member, version } }
|
|
342
|
+
: change;
|
|
343
|
+
if (!AudienceChangeSchema.safeParse({ seq: 1, ...next }).success) {
|
|
344
|
+
logger?.error({ plugin, userId }, 'An audience change is not the contract — not written');
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const text = JSON.stringify(next);
|
|
348
|
+
if (existing) update.run(text, version, now, plugin, userId);
|
|
349
|
+
else insert.run(plugin, userId, text, version, now);
|
|
350
|
+
written += 1;
|
|
351
|
+
}
|
|
352
|
+
db.exec('COMMIT');
|
|
353
|
+
} catch (err) {
|
|
354
|
+
db.exec('ROLLBACK');
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
return written;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Erasures reach every ledger: each row of an erased user that is not yet
|
|
362
|
+
* their `remove/erased` is rewritten to it (version + 1, a new `seq`), in
|
|
363
|
+
* every plugin that has one — those that no longer take the feed included.
|
|
364
|
+
* Needs nothing from Gatekeeper. Returns how many rows each plugin got.
|
|
365
|
+
*
|
|
366
|
+
* @returns {Map<string, number>}
|
|
367
|
+
*/
|
|
368
|
+
function writeErasures(db) {
|
|
369
|
+
const rows = db
|
|
370
|
+
.prepare(
|
|
371
|
+
`SELECT a.plugin, a.user_id, a.version FROM plugin_audience a
|
|
372
|
+
JOIN directory_users u ON u.user_id = a.user_id
|
|
373
|
+
WHERE u.erased_at IS NOT NULL
|
|
374
|
+
AND (json_extract(a.change, '$.kind') <> 'remove'
|
|
375
|
+
OR json_extract(a.change, '$.reason') IS NOT 'erased')`,
|
|
376
|
+
)
|
|
377
|
+
.all();
|
|
378
|
+
const written = new Map();
|
|
379
|
+
if (rows.length === 0) return written;
|
|
380
|
+
const now = new Date().toISOString();
|
|
381
|
+
const update = db.prepare(
|
|
382
|
+
'UPDATE plugin_audience SET change = ?, version = ?, updated_at = ? WHERE plugin = ? AND user_id = ?',
|
|
383
|
+
);
|
|
384
|
+
db.exec('BEGIN IMMEDIATE');
|
|
385
|
+
try {
|
|
386
|
+
for (const row of rows) {
|
|
387
|
+
const change = JSON.stringify({ kind: 'remove', userId: row.user_id, reason: 'erased' });
|
|
388
|
+
update.run(change, row.version + 1, now, row.plugin, row.user_id);
|
|
389
|
+
written.set(row.plugin, (written.get(row.plugin) ?? 0) + 1);
|
|
390
|
+
}
|
|
391
|
+
db.exec('COMMIT');
|
|
392
|
+
} catch (err) {
|
|
393
|
+
db.exec('ROLLBACK');
|
|
394
|
+
throw err;
|
|
395
|
+
}
|
|
396
|
+
return written;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Drop plugin `name`'s ledger — its audience rows and its feed's state —
|
|
401
|
+
* once no data of it is left on any agent: it is not installed and no
|
|
402
|
+
* agent retains its data. Called after an uninstall and after retained
|
|
403
|
+
* data is purged or forgotten; a plugin with data somewhere keeps its
|
|
404
|
+
* ledger, and the erasures it owes with it. Returns whether it went.
|
|
405
|
+
*
|
|
406
|
+
* @param {string} name
|
|
407
|
+
* @param {import('pino').Logger} [logger]
|
|
408
|
+
*/
|
|
409
|
+
export async function dropIdentityLedgerIfNoData(name, logger) {
|
|
410
|
+
if (await findPlugin(name)) return false;
|
|
411
|
+
const db = await getStateDb();
|
|
412
|
+
const retained = db
|
|
413
|
+
.prepare('SELECT COUNT(*) AS n FROM retained_agent_data WHERE plugin = ?')
|
|
414
|
+
.get(name);
|
|
415
|
+
if (Number(retained.n) > 0) return false;
|
|
416
|
+
db.exec('BEGIN IMMEDIATE');
|
|
417
|
+
let rows = 0;
|
|
418
|
+
try {
|
|
419
|
+
rows = Number(db.prepare('DELETE FROM plugin_audience WHERE plugin = ?').run(name).changes);
|
|
420
|
+
db.prepare('DELETE FROM plugin_identity_sync WHERE plugin = ?').run(name);
|
|
421
|
+
db.exec('COMMIT');
|
|
422
|
+
} catch (err) {
|
|
423
|
+
db.exec('ROLLBACK');
|
|
424
|
+
throw err;
|
|
425
|
+
}
|
|
426
|
+
if (rows > 0) {
|
|
427
|
+
logger?.info(
|
|
428
|
+
{ plugin: name, rows },
|
|
429
|
+
'Dropped the identity ledger of a plugin no agent keeps data of',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Ineligible users last logged per plugin, so a steady count is logged once. */
|
|
436
|
+
const loggedIneligible = new Map();
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* One recompute of every audience. Never throws: a plugin whose grants
|
|
440
|
+
* cannot be read keeps its stored audience (logged, without names).
|
|
441
|
+
*
|
|
442
|
+
* @returns {Promise<{ plugins: Array<{ plugin: string, written: number }>, error?: string }>}
|
|
443
|
+
*/
|
|
444
|
+
async function recomputeOnce(logger) {
|
|
445
|
+
/** plugin → rows written by this recompute */
|
|
446
|
+
const written = new Map();
|
|
447
|
+
const summary = () => ({
|
|
448
|
+
plugins: [...written].map(([plugin, count]) => ({ plugin, written: count })),
|
|
449
|
+
});
|
|
450
|
+
let declaring = new Set();
|
|
451
|
+
try {
|
|
452
|
+
const db = await getStateDb();
|
|
453
|
+
const plugins = (await readPlugins()).plugins.filter((p) => p.identity && p.route);
|
|
454
|
+
declaring = new Set(plugins.map((p) => p.name));
|
|
455
|
+
|
|
456
|
+
// Erasures first, into every ledger — inactive plugins' too (a plugin
|
|
457
|
+
// that no longer takes the feed keeps its rows: they are what it owes).
|
|
458
|
+
for (const [plugin, count] of writeErasures(db)) {
|
|
459
|
+
written.set(plugin, count);
|
|
460
|
+
logger?.info(
|
|
461
|
+
{ plugin, erasures: count, active: declaring.has(plugin) },
|
|
462
|
+
'Erasures written to a plugin audience',
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (plugins.length === 0) return summary();
|
|
466
|
+
|
|
467
|
+
const groupsResult = await listGatekeeperGroups();
|
|
468
|
+
if (!groupsResult.ok) {
|
|
469
|
+
logger?.warn(
|
|
470
|
+
{ reason: groupsResult.reason, status: groupsResult.statusCode },
|
|
471
|
+
'Gatekeeper groups could not be read — plugin audiences kept as they are',
|
|
472
|
+
);
|
|
473
|
+
await syncChanged(written, declaring, logger);
|
|
474
|
+
return { ...summary(), error: groupsResult.error };
|
|
475
|
+
}
|
|
476
|
+
const groups = new Map(groupsResult.groups.map((g) => [g.name, g]));
|
|
477
|
+
const users = await listDirectoryUsers();
|
|
478
|
+
const pending = await pendingPrincipalCleanups();
|
|
479
|
+
|
|
480
|
+
for (const plugin of plugins) {
|
|
481
|
+
const grants = await listPluginGrants(plugin.route);
|
|
482
|
+
if (!grants.ok) {
|
|
483
|
+
logger?.warn(
|
|
484
|
+
{ plugin: plugin.name, reason: grants.reason, status: grants.statusCode },
|
|
485
|
+
'Gatekeeper grants could not be read — the plugin audience is kept as it is',
|
|
486
|
+
);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const declared = declaredRoleIds(plugin.identity);
|
|
490
|
+
const stored = new Map((await listAudience(plugin.name)).map((row) => [row.userId, row]));
|
|
491
|
+
const { desired, ineligible } = desiredAudience(
|
|
492
|
+
plugin.identity,
|
|
493
|
+
users,
|
|
494
|
+
rolesByUsername(grants.grants, groups, declared),
|
|
495
|
+
stored,
|
|
496
|
+
pending,
|
|
497
|
+
);
|
|
498
|
+
if ((loggedIneligible.get(plugin.name) ?? 0) !== ineligible) {
|
|
499
|
+
loggedIneligible.set(plugin.name, ineligible);
|
|
500
|
+
if (ineligible > 0) {
|
|
501
|
+
logger?.warn(
|
|
502
|
+
{ plugin: plugin.name, ineligible },
|
|
503
|
+
'Entitled users whose username the identity feed cannot carry are left out of the audience',
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const count = writeAudience(db, plugin.name, desired, stored, logger);
|
|
508
|
+
written.set(plugin.name, (written.get(plugin.name) ?? 0) + count);
|
|
509
|
+
if (count > 0) {
|
|
510
|
+
logger?.info({ plugin: plugin.name, written: count }, 'Plugin audience changed');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
} catch (err) {
|
|
514
|
+
logger?.error({ err: err.message }, 'Recomputing the plugin audiences failed');
|
|
515
|
+
return { ...summary(), error: err.message };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
await syncChanged(written, declaring, logger);
|
|
519
|
+
return summary();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Start the sync of every declaring plugin whose rows changed (inactive ones take no feed). */
|
|
523
|
+
async function syncChanged(written, declaring, logger) {
|
|
524
|
+
const changed = [...written].filter(([name, count]) => count > 0 && declaring.has(name));
|
|
525
|
+
if (changed.length === 0) return;
|
|
526
|
+
try {
|
|
527
|
+
const { syncPluginIdentity } = await import('./identity-sync.js');
|
|
528
|
+
for (const [name] of changed) void syncPluginIdentity(name, { logger });
|
|
529
|
+
} catch (err) {
|
|
530
|
+
logger?.error({ err: err.message }, 'Could not start the identity sync of changed audiences');
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The end of the last recompute started (settled, never rejected). */
|
|
535
|
+
let tail = Promise.resolve();
|
|
536
|
+
/** A recompute waiting for the running one to end: every request made meanwhile joins it. */
|
|
537
|
+
let queued = null;
|
|
538
|
+
/** The logger of the latest request — background recomputes log through it. */
|
|
539
|
+
let lastLogger = null;
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Resolves once no recompute runs or waits — tests, and whatever needs the
|
|
543
|
+
* audiences settled.
|
|
544
|
+
*/
|
|
545
|
+
export async function audiencesIdle() {
|
|
546
|
+
for (;;) {
|
|
547
|
+
const current = tail;
|
|
548
|
+
await current;
|
|
549
|
+
if (current === tail && queued === null) return;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Recompute every plugin's audience and write what changed. Serialised:
|
|
555
|
+
* a request made while one runs waits for the next run — which starts
|
|
556
|
+
* after it, so it sees every change made before the request — and every
|
|
557
|
+
* request made meanwhile shares that run. Never rejects; plugins whose rows
|
|
558
|
+
* changed are synced to their agent halves.
|
|
559
|
+
*
|
|
560
|
+
* @param {{ logger?: import('pino').Logger }} [opts]
|
|
561
|
+
* @returns {Promise<{ plugins: Array<{ plugin: string, written: number }>, error?: string }>}
|
|
562
|
+
*/
|
|
563
|
+
export function recomputeAudiences({ logger } = {}) {
|
|
564
|
+
if (logger) lastLogger = logger;
|
|
565
|
+
if (queued) return queued;
|
|
566
|
+
const run = tail.then(() => {
|
|
567
|
+
queued = null;
|
|
568
|
+
return recomputeOnce(lastLogger);
|
|
569
|
+
});
|
|
570
|
+
queued = run;
|
|
571
|
+
tail = run.then(
|
|
572
|
+
() => undefined,
|
|
573
|
+
() => undefined,
|
|
574
|
+
);
|
|
575
|
+
return run;
|
|
576
|
+
}
|