@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.
Files changed (150) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +200 -16
  3. package/src/index.js +1 -0
  4. package/src/lib/backend-cookie.js +65 -0
  5. package/src/lib/ip-panel-vhost.js +208 -0
  6. package/src/lib/service-config.js +5 -3
  7. package/src/tasks/nginx.js +18 -135
  8. package/src/tasks/redeploy.js +39 -1
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +1 -1
  13. package/vendor/docs/01-concepts/authorization.md +66 -48
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/mtls.md +2 -2
  16. package/vendor/docs/01-concepts/security-model.md +70 -33
  17. package/vendor/docs/01-concepts/tickets.md +2 -2
  18. package/vendor/docs/01-concepts/tunneling.md +4 -4
  19. package/vendor/docs/02-guides/agent-setup.md +5 -5
  20. package/vendor/docs/02-guides/certificate-management.md +2 -2
  21. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  22. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  23. package/vendor/docs/02-guides/installation.md +3 -3
  24. package/vendor/docs/02-guides/managing-users.md +113 -25
  25. package/vendor/docs/02-guides/onboarding.md +2 -2
  26. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  27. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  28. package/vendor/docs/03-architecture/installer.md +4 -4
  29. package/vendor/docs/03-architecture/management-flow.md +3 -1
  30. package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
  31. package/vendor/docs/03-architecture/overview.md +13 -17
  32. package/vendor/docs/03-architecture/panel-client.md +6 -6
  33. package/vendor/docs/03-architecture/panel-server.md +3 -2
  34. package/vendor/docs/03-architecture/state-management.md +4 -8
  35. package/vendor/docs/03-architecture/system-overview.md +1 -1
  36. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +292 -23
  37. package/vendor/docs/04-api-reference/app-host.md +3 -3
  38. package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
  39. package/vendor/docs/04-api-reference/lifecycle-hooks.md +15 -13
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +6 -4
  42. package/vendor/docs/04-api-reference/tunnels.md +67 -29
  43. package/vendor/docs/04-api-reference/users.md +241 -45
  44. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  45. package/vendor/docs/05-operations/monitoring.md +39 -0
  46. package/vendor/docs/05-operations/uninstalling.md +2 -1
  47. package/vendor/docs/05-operations/upgrades.md +15 -6
  48. package/vendor/docs/06-reference/config-files.md +83 -189
  49. package/vendor/docs/06-reference/glossary.md +3 -3
  50. package/vendor/docs/06-reference/installer-flags.md +13 -13
  51. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  52. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  53. package/vendor/docs/gatekeeper-design.md +10 -8
  54. package/vendor/docs/identity-directory-design.md +48 -14
  55. package/vendor/docs/package.json +1 -1
  56. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  57. package/vendor/gatekeeper/dist/index.d.ts +2 -1
  58. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  59. package/vendor/gatekeeper/dist/index.js +2 -0
  60. package/vendor/gatekeeper/dist/index.js.map +1 -1
  61. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  62. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  63. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  64. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  65. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  66. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  68. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  70. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  72. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
  74. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  75. package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
  76. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  77. package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
  78. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  80. package/vendor/gatekeeper/dist/server/index.js +22 -48
  81. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  83. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
  85. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  88. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  89. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  90. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  91. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  92. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  93. package/vendor/gatekeeper/package.json +2 -2
  94. package/vendor/server/bin/lamaste-server.js +15 -1
  95. package/vendor/server/package.json +2 -2
  96. package/vendor/server/src/commands/plugin-identity.js +335 -0
  97. package/vendor/server/src/commands/plugins.js +99 -11
  98. package/vendor/server/src/commands/registry-credential.js +2 -1
  99. package/vendor/server/src/commands/tunnels.js +102 -2
  100. package/vendor/server/src/commands/uninstall.js +3 -1
  101. package/vendor/server/src/commands/users.js +358 -0
  102. package/vendor/server/src/plugin-approval.js +107 -5
  103. package/vendor/server/src/plugin-identity.js +382 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
  106. package/vendor/server-ui/dist/index.html +2 -2
  107. package/vendor/serverd/package.json +2 -2
  108. package/vendor/serverd/src/index.js +25 -1
  109. package/vendor/serverd/src/lib/audience.js +576 -0
  110. package/vendor/serverd/src/lib/authelia.js +78 -7
  111. package/vendor/serverd/src/lib/config.js +68 -43
  112. package/vendor/serverd/src/lib/directory.js +1113 -0
  113. package/vendor/serverd/src/lib/entitlements.js +517 -0
  114. package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
  115. package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
  116. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  117. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  118. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  119. package/vendor/serverd/src/lib/mtls.js +6 -2
  120. package/vendor/serverd/src/lib/nginx.js +279 -48
  121. package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
  122. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  123. package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
  124. package/vendor/serverd/src/lib/plugin-packages.js +30 -22
  125. package/vendor/serverd/src/lib/plugins.js +94 -15
  126. package/vendor/serverd/src/lib/registry-credential.js +46 -6
  127. package/vendor/serverd/src/lib/request-logging.js +19 -0
  128. package/vendor/serverd/src/lib/retained-data.js +50 -4
  129. package/vendor/serverd/src/lib/state.js +18 -0
  130. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  131. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  132. package/vendor/serverd/src/lib/user-caller.js +67 -0
  133. package/vendor/serverd/src/lib/user-surface.js +733 -0
  134. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  135. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  136. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  137. package/vendor/serverd/src/routes/invite.js +53 -12
  138. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  139. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  140. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  141. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  142. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  143. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  144. package/vendor/serverd/src/routes/management/users.js +290 -208
  145. package/vendor/serverd/src/routes/management.js +4 -0
  146. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  147. package/vendor/serverd/src/routes/user-access.js +17 -4
  148. package/vendor/serverd/src/routes/user-surface.js +69 -0
  149. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  150. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +0 -84
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Entitlements — who may use an agent-hosted plugin, with which of its
3
+ * roles.
4
+ *
5
+ * An entitlement is a Gatekeeper grant on the plugin (`resourceType:
6
+ * 'plugin'`, `resourceId: <route>`) to a user or a Lamaste group whose
7
+ * `context.roles` is a non-empty list of the plugin's role ids. Nothing else
8
+ * makes anyone a member of the plugin's audience: a grant without roles —
9
+ * the desktop's local-install grants, ones migrated from
10
+ * `user-plugin-access.json` — never counts. The panel keeps roles in the
11
+ * manifest's terms: it refuses a role the installed version does not
12
+ * declare, here and in the Gatekeeper proxy, and writes them sorted and
13
+ * without repeats (Gatekeeper tells duplicates apart by their context's
14
+ * JSON).
15
+ *
16
+ * Every change recomputes the audiences (`lib/audience.js`). Audit entries
17
+ * name a user principal by their directory id, never by username.
18
+ */
19
+
20
+ import { KeyedPromiseChainMutex } from '@lamalibre/lamaste';
21
+ import { httpError } from './app-error.js';
22
+ import { recordAuditBestEffort } from './audit.js';
23
+ import { findDirectoryUser, withUsernamesForGrants } from './directory.js';
24
+ import {
25
+ createGatekeeperGrant,
26
+ gatekeeperRequest,
27
+ listPluginGrants,
28
+ revokeGatekeeperGrant,
29
+ } from './gatekeeper-client.js';
30
+ import { findPluginByRoute } from './plugins.js';
31
+
32
+ /** One entitlement write per plugin at a time: a replacement creates, then revokes. */
33
+ const pluginMutex = new KeyedPromiseChainMutex();
34
+
35
+ function isRecord(value) {
36
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ /**
40
+ * The roles a grant carries — its `context.roles` when that is a non-empty
41
+ * list of strings — or null: the grant is not an entitlement.
42
+ *
43
+ * @param {{ context?: Record<string, unknown> }} grant
44
+ * @returns {string[] | null}
45
+ */
46
+ export function grantRoles(grant) {
47
+ const roles = grant?.context?.roles;
48
+ if (!Array.isArray(roles) || roles.length === 0) return null;
49
+ if (!roles.every((role) => typeof role === 'string')) return null;
50
+ return [...new Set(roles)].sort();
51
+ }
52
+
53
+ /** The role ids a normalized identity block declares. */
54
+ export function declaredRoleIds(identity) {
55
+ return new Set((identity?.roles ?? []).map((role) => role.id));
56
+ }
57
+
58
+ /**
59
+ * Check roles against a plugin's identity block: a non-empty list of role
60
+ * ids it declares. Returns them sorted, each once; throws 400 — `code`
61
+ * `unknown_roles` with the `unknown` ones — otherwise.
62
+ *
63
+ * @param {{ roles: Array<{ id: string }> }} identity normalized
64
+ * @param {unknown} roles
65
+ * @returns {string[]}
66
+ */
67
+ function checkedRoles(identity, roles) {
68
+ if (
69
+ !Array.isArray(roles) ||
70
+ roles.length === 0 ||
71
+ !roles.every((role) => typeof role === 'string')
72
+ ) {
73
+ throw httpError('roles is a non-empty list of the plugin’s role ids', 400, {
74
+ code: 'invalid_roles',
75
+ });
76
+ }
77
+ const declared = declaredRoleIds(identity);
78
+ const unique = [...new Set(roles)].sort();
79
+ const unknown = unique.filter((role) => !declared.has(role));
80
+ if (unknown.length > 0) {
81
+ throw httpError(`The plugin declares no role ${unknown.join(', ')}`, 400, {
82
+ code: 'unknown_roles',
83
+ unknown,
84
+ });
85
+ }
86
+ return unique;
87
+ }
88
+
89
+ /** A Gatekeeper failure as the error a route answers with. */
90
+ function gatekeeperError(result, what) {
91
+ if (result.reason === 'unreachable' || result.reason === 'no-secret') {
92
+ return httpError(`Gatekeeper is not reachable — ${what} cannot be read or changed now`, 503, {
93
+ code: 'gatekeeper_unavailable',
94
+ });
95
+ }
96
+ const status = result.statusCode >= 400 && result.statusCode < 600 ? result.statusCode : 502;
97
+ return httpError(`Gatekeeper refused: ${result.error}`, status, { code: 'gatekeeper_refused' });
98
+ }
99
+
100
+ /** The role grants `principalId` holds on `plugin` (Gatekeeper failures thrown as route errors). */
101
+ async function roleGrantsOf(plugin, principalType, principalId) {
102
+ const listed = await listPluginGrants(plugin.route);
103
+ if (!listed.ok) throw gatekeeperError(listed, 'the entitlements');
104
+ return listed.grants.filter(
105
+ (g) =>
106
+ g.principalType === principalType && g.principalId === principalId && grantRoles(g) !== null,
107
+ );
108
+ }
109
+
110
+ /** How an audit entry names a principal: a user by directory id, a group by name. */
111
+ async function auditedPrincipal(principalType, principalId) {
112
+ if (principalType === 'group') return { principalType, group: principalId };
113
+ const user = await findDirectoryUser(principalId);
114
+ return { principalType, userId: user?.userId ?? null };
115
+ }
116
+
117
+ async function recompute(logger) {
118
+ const { recomputeAudiences } = await import('./audience.js');
119
+ await recomputeAudiences({ logger });
120
+ }
121
+
122
+ /**
123
+ * @typedef {object} Entitlement
124
+ * @property {string} grantId
125
+ * @property {'user' | 'group'} principalType
126
+ * @property {string} principalId a username or a group name
127
+ * @property {string | null} userId the directory id of a user principal holding the username now
128
+ * @property {string[]} roles as granted, sorted
129
+ * @property {string[]} unknownRoles granted roles the installed version does not declare
130
+ * @property {string} createdAt
131
+ */
132
+
133
+ /**
134
+ * The entitlements on installed plugin `plugin` (registry entry, with an
135
+ * identity block), and how many of its grants carry no roles.
136
+ *
137
+ * @param {{ route: string, identity: { roles: Array<{ id: string }> } }} plugin
138
+ * @returns {Promise<{ entitlements: Entitlement[], roleless: number }>}
139
+ */
140
+ export async function listEntitlements(plugin) {
141
+ const listed = await listPluginGrants(plugin.route);
142
+ if (!listed.ok) throw gatekeeperError(listed, 'the entitlements');
143
+ const declared = declaredRoleIds(plugin.identity);
144
+ const entitlements = [];
145
+ let roleless = 0;
146
+ for (const grant of listed.grants) {
147
+ const roles = grantRoles(grant);
148
+ if (!roles) {
149
+ roleless += 1;
150
+ continue;
151
+ }
152
+ const user = grant.principalType === 'user' ? await findDirectoryUser(grant.principalId) : null;
153
+ entitlements.push({
154
+ grantId: grant.grantId,
155
+ principalType: grant.principalType,
156
+ principalId: grant.principalId,
157
+ userId: user?.userId ?? null,
158
+ roles,
159
+ unknownRoles: roles.filter((role) => !declared.has(role)),
160
+ createdAt: grant.createdAt,
161
+ });
162
+ }
163
+ entitlements.sort(
164
+ (a, b) =>
165
+ a.principalType.localeCompare(b.principalType) || a.principalId.localeCompare(b.principalId),
166
+ );
167
+ return { entitlements, roleless };
168
+ }
169
+
170
+ /**
171
+ * Give `principal` exactly `roles` on installed plugin `plugin`: one grant
172
+ * with those roles replaces every role grant the principal had on it
173
+ * (created first, the others revoked after — a failure leaves the old
174
+ * grants as they were). A user must hold the username now (404
175
+ * `user_not_found`; 409 `username_deleted` when a removed user held it and
176
+ * nobody holds it now), an earlier holder's pending Gatekeeper cleanup
177
+ * under it is completed first (503 `gatekeeper_unavailable` when it cannot
178
+ * be), and the grant is made holding the users lock — no deletion of the
179
+ * user runs between the check and the grant; a group must exist in
180
+ * Gatekeeper. Gatekeeper's refusals come back with its
181
+ * status — 503 at its grant limit. Audited, then the audiences recomputed.
182
+ *
183
+ * @param {{ name: string, route: string, identity: { roles: Array<{ id: string }> } }} plugin
184
+ * @param {{ principalType: 'user' | 'group', principalId: string, roles: string[] }} entitlement
185
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
186
+ * @returns {Promise<{ entitlement: Entitlement, changed: boolean, warnings: Array<{ step: string, message: string }> }>}
187
+ */
188
+ export async function setEntitlement(plugin, { principalType, principalId, roles }, opts) {
189
+ const wanted = checkedRoles(plugin.identity, roles);
190
+ if (principalType === 'group') {
191
+ const group = await gatekeeperRequest('GET', `/api/groups/${encodeURIComponent(principalId)}`);
192
+ if (!group.ok) {
193
+ if (group.statusCode === 404) {
194
+ throw httpError(`No group "${principalId}"`, 404, { code: 'group_not_found' });
195
+ }
196
+ throw gatekeeperError(group, 'the group');
197
+ }
198
+ }
199
+
200
+ const replace = () =>
201
+ pluginMutex.run(plugin.name, async () => {
202
+ const current = await roleGrantsOf(plugin, principalType, principalId);
203
+ const same = current.find(
204
+ (g) => JSON.stringify(g.context) === JSON.stringify({ roles: wanted }),
205
+ );
206
+ let kept = same ?? null;
207
+ if (!kept) {
208
+ const created = await createGatekeeperGrant({
209
+ principalType,
210
+ principalId,
211
+ resourceType: 'plugin',
212
+ resourceId: plugin.route,
213
+ context: { roles: wanted },
214
+ });
215
+ if (!created.ok) throw gatekeeperError(created, 'the entitlement');
216
+ kept = created.grant;
217
+ }
218
+ const warnings = [];
219
+ for (const grant of current) {
220
+ if (grant.grantId === kept.grantId) continue;
221
+ const revoked = await revokeGatekeeperGrant(grant.grantId);
222
+ if (!revoked.ok && revoked.statusCode !== 404) {
223
+ warnings.push({
224
+ step: 'gatekeeper',
225
+ message: `An earlier grant (${grant.grantId}) was not revoked: ${revoked.error}`,
226
+ });
227
+ }
228
+ }
229
+ return { grant: kept, changed: !same || current.length > 1, warnings };
230
+ });
231
+ const outcome =
232
+ principalType === 'user'
233
+ ? await withUsernamesForGrants(
234
+ [principalId],
235
+ { actor: opts.actor, logger: opts.logger, requireUser: true },
236
+ replace,
237
+ )
238
+ : await replace();
239
+
240
+ const principal = await auditedPrincipal(principalType, principalId);
241
+ if (outcome.changed) {
242
+ await recordAuditBestEffort(
243
+ {
244
+ actor: opts.actor,
245
+ action: 'plugin.entitlement.set',
246
+ plugin: plugin.name,
247
+ details: { ...principal, roles: wanted },
248
+ },
249
+ opts.logger,
250
+ );
251
+ await recompute(opts.logger);
252
+ }
253
+ return {
254
+ entitlement: {
255
+ grantId: outcome.grant.grantId,
256
+ principalType,
257
+ principalId,
258
+ userId: principal.userId ?? null,
259
+ roles: wanted,
260
+ unknownRoles: [],
261
+ createdAt: outcome.grant.createdAt,
262
+ },
263
+ changed: outcome.changed,
264
+ warnings: outcome.warnings,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Revoke every role grant `principal` has on installed plugin `plugin`
270
+ * (grants without roles stay). 404 `entitlement_not_found` when there is
271
+ * none. Audited, then the audiences recomputed.
272
+ *
273
+ * @param {{ name: string, route: string }} plugin
274
+ * @param {{ principalType: 'user' | 'group', principalId: string }} principal
275
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
276
+ * @returns {Promise<{ revoked: number, warnings: Array<{ step: string, message: string }> }>}
277
+ */
278
+ export async function removeEntitlement(plugin, { principalType, principalId }, opts) {
279
+ const outcome = await pluginMutex.run(plugin.name, async () => {
280
+ const current = await roleGrantsOf(plugin, principalType, principalId);
281
+ if (current.length === 0) {
282
+ throw httpError(`"${principalId}" has no roles on plugin "${plugin.name}"`, 404, {
283
+ code: 'entitlement_not_found',
284
+ });
285
+ }
286
+ let revoked = 0;
287
+ const warnings = [];
288
+ for (const grant of current) {
289
+ const result = await revokeGatekeeperGrant(grant.grantId);
290
+ if (result.ok || result.statusCode === 404) revoked += 1;
291
+ else {
292
+ warnings.push({
293
+ step: 'gatekeeper',
294
+ message: `Grant ${grant.grantId} was not revoked: ${result.error}`,
295
+ });
296
+ }
297
+ }
298
+ if (revoked === 0) {
299
+ throw httpError(`Gatekeeper revoked none of the grants: ${warnings[0]?.message}`, 502, {
300
+ code: 'gatekeeper_refused',
301
+ });
302
+ }
303
+ return { revoked, warnings };
304
+ });
305
+
306
+ await recordAuditBestEffort(
307
+ {
308
+ actor: opts.actor,
309
+ action: 'plugin.entitlement.removed',
310
+ plugin: plugin.name,
311
+ details: await auditedPrincipal(principalType, principalId),
312
+ },
313
+ opts.logger,
314
+ );
315
+ await recompute(opts.logger);
316
+ return outcome;
317
+ }
318
+
319
+ /**
320
+ * A grant the Gatekeeper proxy is about to create, checked as an
321
+ * entitlement when it is one: a plugin grant whose context names `roles`
322
+ * must carry a non-empty list of role ids the plugin on that route
323
+ * declares (400 otherwise), and goes on with them sorted and without
324
+ * repeats. Any other body goes on as it came — Gatekeeper validates it.
325
+ *
326
+ * @param {unknown} body
327
+ * @returns {Promise<unknown>}
328
+ */
329
+ export async function checkProxiedGrant(body) {
330
+ if (!isRecord(body) || body.resourceType !== 'plugin' || !isRecord(body.context)) return body;
331
+ if (!Object.hasOwn(body.context, 'roles')) return body;
332
+ const plugin =
333
+ typeof body.resourceId === 'string' ? await findPluginByRoute(body.resourceId) : null;
334
+ if (!plugin?.identity) {
335
+ throw httpError(
336
+ 'Roles are granted on an installed plugin that declares them (resourceId is its route)',
337
+ 400,
338
+ { code: 'no_identity' },
339
+ );
340
+ }
341
+ return {
342
+ ...body,
343
+ context: { ...body.context, roles: checkedRoles(plugin.identity, body.context.roles) },
344
+ };
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Roles a manifest no longer declares
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * What bringing role grants in line with `declared` takes: a grant whose
353
+ * roles all stay is left alone, one that keeps some is rewritten to those
354
+ * (`rewrite`), one that keeps none is revoked (`revoke`). Grants without
355
+ * roles are no entitlements and are never touched.
356
+ *
357
+ * @param {Array<{ grantId: string, context: Record<string, unknown> }>} grants
358
+ * @param {Set<string>} declared
359
+ */
360
+ function planRoleChanges(grants, declared) {
361
+ const rewrite = [];
362
+ const revoke = [];
363
+ for (const grant of grants) {
364
+ const roles = grantRoles(grant);
365
+ if (!roles) continue;
366
+ const kept = roles.filter((role) => declared.has(role));
367
+ if (kept.length === roles.length) continue;
368
+ if (kept.length === 0) revoke.push(grant);
369
+ else rewrite.push({ grant, kept });
370
+ }
371
+ return { rewrite, revoke };
372
+ }
373
+
374
+ /**
375
+ * Drop from the role grants on installed plugin `plugin` every role its
376
+ * installed version does not declare — none at all when it declares no
377
+ * identity — so a role a manifest removed stays removed: a later version
378
+ * that declares it again finds nobody holding it. A grant left with no role
379
+ * is revoked; one with some is replaced by a grant with those (created
380
+ * first, then the old one revoked — Gatekeeper's duplicate refusal means the
381
+ * principal holds that grant already). Call it once the installed version
382
+ * is approved (an approval, an update that took). Audited
383
+ * (`plugin.entitlement.roles-removed`: the roles, how many grants were
384
+ * rewritten and revoked), then the audiences recomputed. Gatekeeper's
385
+ * failures are returned, never thrown: what was not done is done at the
386
+ * next refresh.
387
+ *
388
+ * @param {{ name: string, route: string | null, identity?: { roles: Array<{ id: string }> } | null }} plugin
389
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
390
+ * @returns {Promise<{ ok: true, rewritten: number, revoked: number, roles: string[] } | { ok: false, error: string }>}
391
+ */
392
+ export async function pruneUndeclaredRoles(plugin, { actor, logger }) {
393
+ if (!plugin.route) return { ok: true, rewritten: 0, revoked: 0, roles: [] };
394
+ const declared = declaredRoleIds(plugin.identity);
395
+ const outcome = await pluginMutex.run(plugin.name, async () => {
396
+ const listed = await listPluginGrants(plugin.route);
397
+ if (!listed.ok) return { ok: false, error: listed.error };
398
+ const { rewrite, revoke } = planRoleChanges(listed.grants, declared);
399
+ const removed = new Set();
400
+ const errors = [];
401
+ /** Revoke `grant` (gone already is fine), noting the roles it loses; false when it stays. */
402
+ const revokeNoting = async (grant) => {
403
+ const gone = await revokeGatekeeperGrant(grant.grantId);
404
+ if (!gone.ok && gone.statusCode !== 404) {
405
+ errors.push(gone.error);
406
+ return false;
407
+ }
408
+ for (const role of grantRoles(grant) ?? []) if (!declared.has(role)) removed.add(role);
409
+ return true;
410
+ };
411
+ let rewritten = 0;
412
+ let revoked = 0;
413
+ for (const { grant, kept } of rewrite) {
414
+ const created = await createGatekeeperGrant({
415
+ principalType: grant.principalType,
416
+ principalId: grant.principalId,
417
+ resourceType: 'plugin',
418
+ resourceId: plugin.route,
419
+ context: { ...grant.context, roles: kept },
420
+ });
421
+ if (!created.ok && created.statusCode !== 409) {
422
+ errors.push(created.error);
423
+ continue;
424
+ }
425
+ if (await revokeNoting(grant)) rewritten += 1;
426
+ }
427
+ for (const grant of revoke) {
428
+ if (await revokeNoting(grant)) revoked += 1;
429
+ }
430
+ return { ok: true, rewritten, revoked, roles: [...removed].sort(), errors };
431
+ });
432
+ if (!outcome.ok) {
433
+ logger?.warn(
434
+ { plugin: plugin.name, err: outcome.error },
435
+ 'Gatekeeper could not be read — undeclared roles are dropped at the next refresh',
436
+ );
437
+ return outcome;
438
+ }
439
+ const { errors, ...done } = outcome;
440
+ if (errors.length > 0) {
441
+ logger?.warn(
442
+ { plugin: plugin.name, failed: errors.length, err: errors[0] },
443
+ 'Some grants still hold roles the plugin no longer declares — retried at the next refresh',
444
+ );
445
+ }
446
+ if (done.rewritten + done.revoked > 0) {
447
+ await recordAuditBestEffort(
448
+ {
449
+ actor,
450
+ action: 'plugin.entitlement.roles-removed',
451
+ plugin: plugin.name,
452
+ details: {
453
+ roles: done.roles,
454
+ grantsRewritten: done.rewritten,
455
+ grantsRevoked: done.revoked,
456
+ },
457
+ },
458
+ logger,
459
+ );
460
+ logger?.info(
461
+ { plugin: plugin.name, rewritten: done.rewritten, revoked: done.revoked },
462
+ 'Dropped roles the plugin no longer declares from its grants',
463
+ );
464
+ await recompute(logger);
465
+ }
466
+ return done;
467
+ }
468
+
469
+ /**
470
+ * Revoke every role grant on plugin `plugin`'s route — an uninstall with
471
+ * purge: nothing of who held which role is kept for a later install.
472
+ * Grants without roles stay. Audited as {@link pruneUndeclaredRoles} does
473
+ * (every role removed); Gatekeeper's failures are returned, never thrown.
474
+ *
475
+ * @param {{ name: string, route: string | null }} plugin
476
+ * @param {{ actor: string, logger?: import('pino').Logger }} opts
477
+ * @returns {Promise<{ ok: true, revoked: number } | { ok: false, error: string }>}
478
+ */
479
+ export async function revokePluginRoleGrants(plugin, opts) {
480
+ const outcome = await pruneUndeclaredRoles({ ...plugin, identity: null }, opts);
481
+ return outcome.ok ? { ok: true, revoked: outcome.revoked } : outcome;
482
+ }
483
+
484
+ /**
485
+ * How many role grants on `route` a staged identity block (`after`, null:
486
+ * none) would change — `{ rewritten, revoked }` — for the approval preview;
487
+ * null when Gatekeeper cannot be read.
488
+ *
489
+ * @param {string | null} route
490
+ * @param {{ roles: Array<{ id: string }> } | null} after
491
+ * @returns {Promise<{ rewritten: number, revoked: number } | null>}
492
+ */
493
+ export async function identityGrantImpact(route, after) {
494
+ if (!route) return { rewritten: 0, revoked: 0 };
495
+ const listed = await listPluginGrants(route);
496
+ if (!listed.ok) return null;
497
+ const { rewrite, revoke } = planRoleChanges(listed.grants, declaredRoleIds(after));
498
+ return { rewritten: rewrite.length, revoked: revoke.length };
499
+ }
500
+
501
+ /**
502
+ * A pending update's view with `identity.grantsAffected` — how many role
503
+ * grants approving it would rewrite or revoke — when it removes roles (or
504
+ * the whole block); the view as it was otherwise.
505
+ *
506
+ * @param {object | null} view as `pendingUpdateView` builds it
507
+ * @param {string | null} route
508
+ */
509
+ export async function withIdentityGrantImpact(view, route) {
510
+ const identity = view?.identity;
511
+ // A block dropped altogether removes every role it had (`identityDiff`).
512
+ if (!identity || identity.rolesRemoved.length === 0) return view;
513
+ return {
514
+ ...view,
515
+ identity: { ...identity, grantsAffected: await identityGrantImpact(route, identity.after) },
516
+ };
517
+ }