@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,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
|
+
}
|