@astram/identity 0.5.0 → 0.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/dist/features/authz/grant-admin.service.d.ts +24 -1
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +88 -1
- package/dist/features/authz/grant-ceiling.d.ts +49 -1
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +118 -10
- package/dist/features/authz/permission-version.bumper.d.ts +7 -0
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
- package/dist/features/authz/permission-version.bumper.js +19 -10
- package/dist/features/authz/record-authorization.d.ts +12 -0
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/record-authorization.js +49 -0
- package/dist/features/company/company-hierarchy.service.d.ts +65 -3
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
- package/dist/features/company/company-hierarchy.service.js +183 -6
- package/dist/features/identity-feature.module.d.ts +2 -0
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +19 -9
- package/dist/features/membership/posting-version-bump.d.ts +7 -0
- package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
- package/dist/features/role/role.service.d.ts +25 -2
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +57 -4
- package/dist/features/scope/scope-closure.service.d.ts +11 -1
- package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
- package/dist/features/scope/scope-closure.service.js +23 -4
- package/dist/features/service-account/service-account.service.d.ts +15 -0
- package/dist/features/service-account/service-account.service.d.ts.map +1 -1
- package/dist/features/service-account/service-account.service.js +17 -1
- package/dist/features/service-account/service-account.store.d.ts +15 -0
- package/dist/features/service-account/service-account.store.d.ts.map +1 -1
- package/dist/features/service-account/service-account.store.js +29 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/internal.d.ts +10 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +13 -0
- package/package.json +14 -3
|
@@ -114,9 +114,32 @@ export declare class GrantAdminService {
|
|
|
114
114
|
* Revoke a key from a role (no-op if absent) + bump + audit, atomically. Security hardening #1:
|
|
115
115
|
* the actor must be able to administer the role (binding rule) and must hold the key itself —
|
|
116
116
|
* "only revoke what you hold" — so a delegated admin can no longer strip a role of authority it
|
|
117
|
-
* never had.
|
|
117
|
+
* never had. Phase 8 #2: it must also dominate every user holding the role (checked under the
|
|
118
|
+
* role lock in `writeGrants`). Root bypasses.
|
|
118
119
|
*/
|
|
119
120
|
revoke(roleUuid: string, key: string, actor: Principal): Promise<void>;
|
|
121
|
+
/**
|
|
122
|
+
* Remove a role that is being DELETED from authorization (Phase 8 #2 follow-up): every
|
|
123
|
+
* `role_permission` row of the role, and the role from every service account's `sa_role_ids`
|
|
124
|
+
* (guard's API-key store reads that column live, so a stale entry would keep the deleted role's
|
|
125
|
+
* keys on the agent). ONE transaction on the shared authz pool — the same pool, bump, audit and
|
|
126
|
+
* error mapping as {@link revoke}:
|
|
127
|
+
* 1. lock the holding accounts (`FOR UPDATE`), then check the actor may strip the role from
|
|
128
|
+
* each (`GrantCeiling.assertMayStripRoleFromServiceAccounts`, root bypasses);
|
|
129
|
+
* 2. bump every tenant using the role — postings AND the accounts' tenants — before the strip,
|
|
130
|
+
* so the account tenants are still found;
|
|
131
|
+
* 3. delete the role's grants; rewrite each account's role list without it (edit stamp moved
|
|
132
|
+
* forward, so a concurrent preserve-merge edit conflicts instead of re-adding it);
|
|
133
|
+
* 4. audit as `role.permission_revoked` (`roleDeleted: true`), commit, publish the versions.
|
|
134
|
+
* Returns the bumped tenants.
|
|
135
|
+
*
|
|
136
|
+
* The CALLER (`RoleService.deleteRole`) holds the role row lock on its own engine connection and
|
|
137
|
+
* has already run the binding rule and user-holder dominance; this method therefore does NOT
|
|
138
|
+
* lock the role row (that would wait on the caller). It commits BEFORE the caller deletes the
|
|
139
|
+
* role, so a failure in between leaves a role with no grants and no agents: fewer permissions,
|
|
140
|
+
* never live grants for a deleted role (fail closed).
|
|
141
|
+
*/
|
|
142
|
+
purgeRole(roleUuid: string, actor: Principal): Promise<string[]>;
|
|
120
143
|
/** All active bundles + their resolved atomic keys, for the Access UI. */
|
|
121
144
|
listBundles(): Promise<{
|
|
122
145
|
bundles: BundleView[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"grant-admin.service.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-admin.service.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,cAAc,CAAC;AAE1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE7D,OAAO,EAAE,YAAY,EAAa,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"grant-admin.service.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-admin.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -9,6 +9,24 @@ const role_binding_js_1 = require("./role-binding.js");
|
|
|
9
9
|
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
10
10
|
const AUTHZ = 'shield_authz';
|
|
11
11
|
const APP = 'shield_app';
|
|
12
|
+
/** The grant-admin actions that REMOVE authority from a role's holders (Phase 8 #2). */
|
|
13
|
+
const REVOKE_ACTIONS = new Set([
|
|
14
|
+
'role.permission_revoked',
|
|
15
|
+
'role.bundle_revoked',
|
|
16
|
+
]);
|
|
17
|
+
/** A JSON-array column (the driver returns an array OR a string) → its string items; else []. */
|
|
18
|
+
function jsonIdArray(v) {
|
|
19
|
+
let arr = v;
|
|
20
|
+
if (typeof v === 'string') {
|
|
21
|
+
try {
|
|
22
|
+
arr = JSON.parse(v);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
arr = [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
|
|
29
|
+
}
|
|
12
30
|
/** A granted key was not in the catalog and is not a recognized wildcard. The
|
|
13
31
|
* controller maps this to HTTP 400 (distinct from a store outage → 503). */
|
|
14
32
|
class BadKeyError extends Error {
|
|
@@ -133,6 +151,11 @@ class GrantAdminService {
|
|
|
133
151
|
]);
|
|
134
152
|
if (this.ceiling && actor.isRoot !== true) {
|
|
135
153
|
grant_ceiling_js_1.GrantCeiling.checkAdministrable(await (0, role_binding_js_1.readRoleBinding)(conn, roleUuid, (0, grant_ceiling_js_1.subtreeOf)(actor)));
|
|
154
|
+
// Phase 8 #2: a revocation strips authority from every holder of the role, so the actor
|
|
155
|
+
// must dominate each of them — checked under the role lock (no holder can appear unseen).
|
|
156
|
+
if (REVOKE_ACTIONS.has(action)) {
|
|
157
|
+
await this.ceiling.assertDominatesRoleHolders(actor, roleUuid);
|
|
158
|
+
}
|
|
136
159
|
}
|
|
137
160
|
for (const key of keys)
|
|
138
161
|
await statement(conn, key);
|
|
@@ -230,7 +253,8 @@ class GrantAdminService {
|
|
|
230
253
|
* Revoke a key from a role (no-op if absent) + bump + audit, atomically. Security hardening #1:
|
|
231
254
|
* the actor must be able to administer the role (binding rule) and must hold the key itself —
|
|
232
255
|
* "only revoke what you hold" — so a delegated admin can no longer strip a role of authority it
|
|
233
|
-
* never had.
|
|
256
|
+
* never had. Phase 8 #2: it must also dominate every user holding the role (checked under the
|
|
257
|
+
* role lock in `writeGrants`). Root bypasses.
|
|
234
258
|
*/
|
|
235
259
|
async revoke(roleUuid, key, actor) {
|
|
236
260
|
await this.assertMayChangeGrants(roleUuid, [key], actor);
|
|
@@ -238,6 +262,69 @@ class GrantAdminService {
|
|
|
238
262
|
await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?`, [roleUuid, k]);
|
|
239
263
|
});
|
|
240
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Remove a role that is being DELETED from authorization (Phase 8 #2 follow-up): every
|
|
267
|
+
* `role_permission` row of the role, and the role from every service account's `sa_role_ids`
|
|
268
|
+
* (guard's API-key store reads that column live, so a stale entry would keep the deleted role's
|
|
269
|
+
* keys on the agent). ONE transaction on the shared authz pool — the same pool, bump, audit and
|
|
270
|
+
* error mapping as {@link revoke}:
|
|
271
|
+
* 1. lock the holding accounts (`FOR UPDATE`), then check the actor may strip the role from
|
|
272
|
+
* each (`GrantCeiling.assertMayStripRoleFromServiceAccounts`, root bypasses);
|
|
273
|
+
* 2. bump every tenant using the role — postings AND the accounts' tenants — before the strip,
|
|
274
|
+
* so the account tenants are still found;
|
|
275
|
+
* 3. delete the role's grants; rewrite each account's role list without it (edit stamp moved
|
|
276
|
+
* forward, so a concurrent preserve-merge edit conflicts instead of re-adding it);
|
|
277
|
+
* 4. audit as `role.permission_revoked` (`roleDeleted: true`), commit, publish the versions.
|
|
278
|
+
* Returns the bumped tenants.
|
|
279
|
+
*
|
|
280
|
+
* The CALLER (`RoleService.deleteRole`) holds the role row lock on its own engine connection and
|
|
281
|
+
* has already run the binding rule and user-holder dominance; this method therefore does NOT
|
|
282
|
+
* lock the role row (that would wait on the caller). It commits BEFORE the caller deletes the
|
|
283
|
+
* role, so a failure in between leaves a role with no grants and no agents: fewer permissions,
|
|
284
|
+
* never live grants for a deleted role (fail closed).
|
|
285
|
+
*/
|
|
286
|
+
async purgeRole(roleUuid, actor) {
|
|
287
|
+
const conn = await this.pool.getConnection();
|
|
288
|
+
try {
|
|
289
|
+
await conn.beginTransaction();
|
|
290
|
+
const accounts = (await conn.query(`SELECT sa_id, sa_tenant_id, sa_role_ids, sa_updated_at_ms
|
|
291
|
+
FROM \`${APP}\`.\`service_account\`
|
|
292
|
+
WHERE JSON_CONTAINS(sa_role_ids, JSON_QUOTE(?))
|
|
293
|
+
FOR UPDATE`, [roleUuid]));
|
|
294
|
+
if (this.ceiling && actor.isRoot !== true) {
|
|
295
|
+
await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id));
|
|
296
|
+
}
|
|
297
|
+
const keys = (await conn.query(`SELECT rp_perm_key FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? ORDER BY rp_perm_key`, [roleUuid])).map((r) => r.rp_perm_key);
|
|
298
|
+
const bumped = await this.bumper.bumpForRole(roleUuid, conn);
|
|
299
|
+
await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ?`, [
|
|
300
|
+
roleUuid,
|
|
301
|
+
]);
|
|
302
|
+
const nowMs = Date.now();
|
|
303
|
+
for (const a of accounts) {
|
|
304
|
+
const remaining = jsonIdArray(a.sa_role_ids).filter((id) => id !== roleUuid);
|
|
305
|
+
const previous = a.sa_updated_at_ms === null ? 0 : Number(a.sa_updated_at_ms);
|
|
306
|
+
await conn.query(`UPDATE \`${APP}\`.\`service_account\` SET sa_role_ids = ?, sa_updated_at_ms = ? WHERE sa_id = ?`, [JSON.stringify(remaining), Math.max(nowMs, previous + 1), a.sa_id]);
|
|
307
|
+
}
|
|
308
|
+
await this.auditGrantChange(conn, 'role.permission_revoked', roleUuid, keys, actor, bumped, {
|
|
309
|
+
roleDeleted: true,
|
|
310
|
+
serviceAccountsUpdated: accounts.length,
|
|
311
|
+
});
|
|
312
|
+
await conn.commit();
|
|
313
|
+
await this.bumper.publishCommitted(conn);
|
|
314
|
+
return bumped;
|
|
315
|
+
}
|
|
316
|
+
catch (cause) {
|
|
317
|
+
await conn.rollback();
|
|
318
|
+
this.bumper.discardPending(conn);
|
|
319
|
+
if (cause instanceof identity_errors_js_1.IdentityForbiddenError || cause instanceof identity_errors_js_1.IdentityNotFoundError) {
|
|
320
|
+
throw cause;
|
|
321
|
+
}
|
|
322
|
+
throw new core_1.AuthorizationUnavailableError('role grant purge failed', cause);
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
await conn.release();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
241
328
|
// ── Access bundles ────────────────────────────────────────────────────────
|
|
242
329
|
/** All active bundles + their resolved atomic keys, for the Access UI. */
|
|
243
330
|
async listBundles() {
|
|
@@ -81,9 +81,18 @@ export declare class GrantCeiling {
|
|
|
81
81
|
* Assert the actor may ASSIGN every role in `roleIds` (post someone to it): the roles' effective
|
|
82
82
|
* keys (their `role_permission` rows, wildcards expanded against the registry) must all be held
|
|
83
83
|
* by the actor — the same "only grant what you hold" ceiling, applied to role assignment (#11).
|
|
84
|
-
* A role holding nothing assigns nothing and passes
|
|
84
|
+
* A role holding nothing assigns nothing and passes; a role key the registry does not know
|
|
85
|
+
* counts as not held (Phase 8 #4). Root bypasses. Store outage → 503.
|
|
85
86
|
*/
|
|
86
87
|
assertCanAssignRoles(actor: Principal, roleIds: readonly string[]): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* The first of a TARGET's raw keys (a role's `role_permission` rows) the actor does not cover,
|
|
90
|
+
* or `undefined` when all are covered. Phase 8 #4: judged per RAW key with `coversKey`, so a key
|
|
91
|
+
* the registry does not know — an exact key that is not registered, or a wildcard expanding to
|
|
92
|
+
* nothing — counts as NOT held and denies. Expanding first would silently drop it and could
|
|
93
|
+
* pass vacuously (fail open) when this registry is narrower than the decision path's.
|
|
94
|
+
*/
|
|
95
|
+
private firstUncovered;
|
|
87
96
|
/**
|
|
88
97
|
* Assert the actor DOMINATES the target user (#3, D2): every membership the target holds (in any
|
|
89
98
|
* tenant, active or not) is inside the actor's subtree, AND every key the target could hold
|
|
@@ -92,6 +101,45 @@ export declare class GrantCeiling {
|
|
|
92
101
|
* Root actors bypass. Store outage → 503.
|
|
93
102
|
*/
|
|
94
103
|
assertDominates(actor: Principal, targetUserId: string): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Assert the actor DOMINATES every user who holds the role (Phase 8 #2): deleting a role, or
|
|
106
|
+
* revoking a key from it, strips authority from each holder, so it needs the same
|
|
107
|
+
* {@link assertDominates} the actor would need to change any one of them directly. Holders are
|
|
108
|
+
* the users with a posting of the role in any membership and any window (potential authority).
|
|
109
|
+
* The actor itself is skipped: giving up your own authority stays allowed (self-demotion).
|
|
110
|
+
* Service-account holders: a revoke is covered by the binding rule (`assertRoleAdministrable`:
|
|
111
|
+
* no binding outside the actor's subtree) plus "only revoke what you hold"; a role DELETE also
|
|
112
|
+
* strips the role from each account and checks {@link assertMayStripRoleFromServiceAccounts}.
|
|
113
|
+
* Root bypasses. Store outage → 503.
|
|
114
|
+
*
|
|
115
|
+
* Callers serialise against a concurrent posting by locking the role row first (the posting
|
|
116
|
+
* path locks the same row before it inserts), then call this — so no holder appears unseen.
|
|
117
|
+
*/
|
|
118
|
+
assertDominatesRoleHolders(actor: Principal, roleUuid: string): Promise<void>;
|
|
119
|
+
/**
|
|
120
|
+
* Assert the actor may REMOVE `roleUuid` from every service account holding it (Phase 8 #2
|
|
121
|
+
* follow-up: a role delete strips the role from each holding account). Judged by the SAME rules
|
|
122
|
+
* as editing the account directly (`ServiceAccountService.update`, ADR-0029 ceiling via
|
|
123
|
+
* {@link ceilingOf}): each account's tenant is inside the actor's subtree, and the role is one
|
|
124
|
+
* the actor holds itself — a non-root admin's edit preserves (cannot strip) a role it does not
|
|
125
|
+
* hold. `accountTenantIds` are the holding accounts' tenants, read by the caller under a row lock.
|
|
126
|
+
* Root bypasses. Refusals name no account. Store outage → 503.
|
|
127
|
+
*/
|
|
128
|
+
assertMayStripRoleFromServiceAccounts(actor: Principal, roleUuid: string, accountTenantIds: readonly string[]): Promise<void>;
|
|
129
|
+
/**
|
|
130
|
+
* Assert the actor may RETIRE (disable + revoke the keys of) each of these service accounts
|
|
131
|
+
* (Phase 8 #3: a company delete retires the deleted company's own agents). Judged by the SAME
|
|
132
|
+
* ADR-0029 ceiling an admin needs to have made the agent ({@link ceilingOf}): the account's
|
|
133
|
+
* tenant and every node of its scope are inside the actor's subtree, and every role it holds is
|
|
134
|
+
* one the actor holds itself — so a sub-admin can never switch off an agent more powerful than
|
|
135
|
+
* itself by deleting its company. `accounts` are read by the caller under a row lock. Root
|
|
136
|
+
* bypasses. Refusals name no account. Store outage → 503.
|
|
137
|
+
*/
|
|
138
|
+
assertMayRetireServiceAccounts(actor: Principal, accounts: ReadonlyArray<{
|
|
139
|
+
readonly tenantId: string;
|
|
140
|
+
readonly scopeSubtreeNodeIds: readonly string[];
|
|
141
|
+
readonly roleIds: readonly string[];
|
|
142
|
+
}>): Promise<void>;
|
|
95
143
|
/** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
|
|
96
144
|
roleBinding(actor: Principal, roleUuid: string): Promise<RoleBinding>;
|
|
97
145
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"grant-ceiling.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-ceiling.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9D,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"grant-ceiling.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-ceiling.ts"],"names":[],"mappings":"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"}
|
|
@@ -7,6 +7,7 @@ const core_1 = require("@astram/core");
|
|
|
7
7
|
const db_permission_facts_adapter_js_1 = require("./db-permission-facts.adapter.js");
|
|
8
8
|
const role_binding_js_1 = require("./role-binding.js");
|
|
9
9
|
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
10
|
+
const service_account_service_js_1 = require("../service-account/service-account.service.js");
|
|
10
11
|
const AUTHZ = 'shield_authz';
|
|
11
12
|
const APP = 'shield_app';
|
|
12
13
|
/** The grantor exceeds their own authority — cannot grant a key they do not hold (→ 403). */
|
|
@@ -152,7 +153,8 @@ class GrantCeiling {
|
|
|
152
153
|
* Assert the actor may ASSIGN every role in `roleIds` (post someone to it): the roles' effective
|
|
153
154
|
* keys (their `role_permission` rows, wildcards expanded against the registry) must all be held
|
|
154
155
|
* by the actor — the same "only grant what you hold" ceiling, applied to role assignment (#11).
|
|
155
|
-
* A role holding nothing assigns nothing and passes
|
|
156
|
+
* A role holding nothing assigns nothing and passes; a role key the registry does not know
|
|
157
|
+
* counts as not held (Phase 8 #4). Root bypasses. Store outage → 503.
|
|
156
158
|
*/
|
|
157
159
|
async assertCanAssignRoles(actor, roleIds) {
|
|
158
160
|
if (actor.isRoot === true)
|
|
@@ -161,16 +163,25 @@ class GrantCeiling {
|
|
|
161
163
|
if (ids.length === 0)
|
|
162
164
|
return;
|
|
163
165
|
const raw = await this.facts('role grants', () => this.rawKeysOfRoles(ids));
|
|
164
|
-
|
|
165
|
-
if (roleKeys.length === 0)
|
|
166
|
+
if (raw.length === 0)
|
|
166
167
|
return;
|
|
167
168
|
const effective = await this.effectiveKeys(actor);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
169
|
+
const key = this.firstUncovered(effective, raw);
|
|
170
|
+
if (key !== undefined) {
|
|
171
|
+
throw new GrantCeilingError(key, `You cannot assign this role — it grants '${key}', which exceeds the permissions you hold.`);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* The first of a TARGET's raw keys (a role's `role_permission` rows) the actor does not cover,
|
|
176
|
+
* or `undefined` when all are covered. Phase 8 #4: judged per RAW key with `coversKey`, so a key
|
|
177
|
+
* the registry does not know — an exact key that is not registered, or a wildcard expanding to
|
|
178
|
+
* nothing — counts as NOT held and denies. Expanding first would silently drop it and could
|
|
179
|
+
* pass vacuously (fail open) when this registry is narrower than the decision path's.
|
|
180
|
+
*/
|
|
181
|
+
firstUncovered(effective, raw) {
|
|
182
|
+
const registered = this.registeredKeys();
|
|
183
|
+
return [...new Set(raw)].sort().find((k) => !coversKey(effective, registered, k));
|
|
184
|
+
}
|
|
174
185
|
/**
|
|
175
186
|
* Assert the actor DOMINATES the target user (#3, D2): every membership the target holds (in any
|
|
176
187
|
* tenant, active or not) is inside the actor's subtree, AND every key the target could hold
|
|
@@ -211,14 +222,111 @@ class GrantCeiling {
|
|
|
211
222
|
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they also belong to an organisation outside the ones you manage.');
|
|
212
223
|
}
|
|
213
224
|
const raw = await this.facts('target grants', () => this.rawKeysOfRoles(roleIds));
|
|
214
|
-
|
|
215
|
-
if (targetKeys.length === 0)
|
|
225
|
+
if (raw.length === 0)
|
|
216
226
|
return;
|
|
217
227
|
const effective = await this.effectiveKeys(actor);
|
|
218
|
-
|
|
228
|
+
// Phase 8 #4: an unknown target key counts as not held (see `firstUncovered`).
|
|
229
|
+
if (this.firstUncovered(effective, raw) !== undefined) {
|
|
219
230
|
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they hold permissions you do not hold.');
|
|
220
231
|
}
|
|
221
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Assert the actor DOMINATES every user who holds the role (Phase 8 #2): deleting a role, or
|
|
235
|
+
* revoking a key from it, strips authority from each holder, so it needs the same
|
|
236
|
+
* {@link assertDominates} the actor would need to change any one of them directly. Holders are
|
|
237
|
+
* the users with a posting of the role in any membership and any window (potential authority).
|
|
238
|
+
* The actor itself is skipped: giving up your own authority stays allowed (self-demotion).
|
|
239
|
+
* Service-account holders: a revoke is covered by the binding rule (`assertRoleAdministrable`:
|
|
240
|
+
* no binding outside the actor's subtree) plus "only revoke what you hold"; a role DELETE also
|
|
241
|
+
* strips the role from each account and checks {@link assertMayStripRoleFromServiceAccounts}.
|
|
242
|
+
* Root bypasses. Store outage → 503.
|
|
243
|
+
*
|
|
244
|
+
* Callers serialise against a concurrent posting by locking the role row first (the posting
|
|
245
|
+
* path locks the same row before it inserts), then call this — so no holder appears unseen.
|
|
246
|
+
*/
|
|
247
|
+
async assertDominatesRoleHolders(actor, roleUuid) {
|
|
248
|
+
if (actor.isRoot === true)
|
|
249
|
+
return;
|
|
250
|
+
const holders = await this.facts('role holders', async () => {
|
|
251
|
+
const rows = (await this.pool.query(`SELECT DISTINCT cm.cm_user_uuid AS holder
|
|
252
|
+
FROM \`${APP}\`.\`membership_posting\` mp
|
|
253
|
+
JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
|
|
254
|
+
WHERE mp.mp_role_uuid = ?`, [roleUuid]));
|
|
255
|
+
return rows.map((r) => r.holder);
|
|
256
|
+
});
|
|
257
|
+
for (const holder of [...new Set(holders)].sort()) {
|
|
258
|
+
if (holder === actor.id)
|
|
259
|
+
continue;
|
|
260
|
+
try {
|
|
261
|
+
await this.assertDominates(actor, holder);
|
|
262
|
+
}
|
|
263
|
+
catch (cause) {
|
|
264
|
+
if (cause instanceof identity_errors_js_1.IdentityForbiddenError || cause instanceof identity_errors_js_1.IdentityNotFoundError) {
|
|
265
|
+
// Names no holder (who holds a role is not the caller's to learn from a refusal).
|
|
266
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this role: it is held by someone outside your authority.');
|
|
267
|
+
}
|
|
268
|
+
throw cause;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Assert the actor may REMOVE `roleUuid` from every service account holding it (Phase 8 #2
|
|
274
|
+
* follow-up: a role delete strips the role from each holding account). Judged by the SAME rules
|
|
275
|
+
* as editing the account directly (`ServiceAccountService.update`, ADR-0029 ceiling via
|
|
276
|
+
* {@link ceilingOf}): each account's tenant is inside the actor's subtree, and the role is one
|
|
277
|
+
* the actor holds itself — a non-root admin's edit preserves (cannot strip) a role it does not
|
|
278
|
+
* hold. `accountTenantIds` are the holding accounts' tenants, read by the caller under a row lock.
|
|
279
|
+
* Root bypasses. Refusals name no account. Store outage → 503.
|
|
280
|
+
*/
|
|
281
|
+
async assertMayStripRoleFromServiceAccounts(actor, roleUuid, accountTenantIds) {
|
|
282
|
+
if (actor.isRoot === true || accountTenantIds.length === 0)
|
|
283
|
+
return;
|
|
284
|
+
const admin = {
|
|
285
|
+
id: actor.id,
|
|
286
|
+
tenantId: actor.tenantId,
|
|
287
|
+
scopeSubtreeNodeIds: subtreeOf(actor),
|
|
288
|
+
isRoot: false,
|
|
289
|
+
};
|
|
290
|
+
const refuse = () => new identity_errors_js_1.IdentityForbiddenError('You cannot delete this role: it is assigned to an agent you cannot change.');
|
|
291
|
+
const nodes = (0, service_account_service_js_1.ceilingOf)(admin, []);
|
|
292
|
+
if (accountTenantIds.some((t) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, t)))
|
|
293
|
+
throw refuse();
|
|
294
|
+
const held = await this.facts('held roles', () => (0, db_permission_facts_adapter_js_1.readRoleIdsForTenant)(this.pool, actor.id, actor.tenantId, this.now));
|
|
295
|
+
if (!(0, service_account_service_js_1.ceilingAllowsRole)((0, service_account_service_js_1.ceilingOf)(admin, held), roleUuid))
|
|
296
|
+
throw refuse();
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Assert the actor may RETIRE (disable + revoke the keys of) each of these service accounts
|
|
300
|
+
* (Phase 8 #3: a company delete retires the deleted company's own agents). Judged by the SAME
|
|
301
|
+
* ADR-0029 ceiling an admin needs to have made the agent ({@link ceilingOf}): the account's
|
|
302
|
+
* tenant and every node of its scope are inside the actor's subtree, and every role it holds is
|
|
303
|
+
* one the actor holds itself — so a sub-admin can never switch off an agent more powerful than
|
|
304
|
+
* itself by deleting its company. `accounts` are read by the caller under a row lock. Root
|
|
305
|
+
* bypasses. Refusals name no account. Store outage → 503.
|
|
306
|
+
*/
|
|
307
|
+
async assertMayRetireServiceAccounts(actor, accounts) {
|
|
308
|
+
if (actor.isRoot === true || accounts.length === 0)
|
|
309
|
+
return;
|
|
310
|
+
const admin = {
|
|
311
|
+
id: actor.id,
|
|
312
|
+
tenantId: actor.tenantId,
|
|
313
|
+
scopeSubtreeNodeIds: subtreeOf(actor),
|
|
314
|
+
isRoot: false,
|
|
315
|
+
};
|
|
316
|
+
const refuse = () => new identity_errors_js_1.IdentityForbiddenError('You cannot delete this company: one of its agents is outside your authority.');
|
|
317
|
+
const nodes = (0, service_account_service_js_1.ceilingOf)(admin, []);
|
|
318
|
+
const outsideNodes = accounts.some((a) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, a.tenantId) ||
|
|
319
|
+
a.scopeSubtreeNodeIds.some((n) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, n)));
|
|
320
|
+
if (outsideNodes)
|
|
321
|
+
throw refuse();
|
|
322
|
+
if (accounts.every((a) => a.roleIds.length === 0))
|
|
323
|
+
return;
|
|
324
|
+
const held = await this.facts('held roles', () => (0, db_permission_facts_adapter_js_1.readRoleIdsForTenant)(this.pool, actor.id, actor.tenantId, this.now));
|
|
325
|
+
const ceiling = (0, service_account_service_js_1.ceilingOf)(admin, held);
|
|
326
|
+
if (accounts.some((a) => a.roleIds.some((r) => !(0, service_account_service_js_1.ceilingAllowsRole)(ceiling, r)))) {
|
|
327
|
+
throw refuse();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
222
330
|
/** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
|
|
223
331
|
async roleBinding(actor, roleUuid) {
|
|
224
332
|
return this.facts('role binding', () => (0, role_binding_js_1.readRoleBinding)(this.pool, roleUuid, subtreeOf(actor)));
|
|
@@ -71,6 +71,13 @@ export declare class PermissionVersionBumper {
|
|
|
71
71
|
* so both roll back together).
|
|
72
72
|
*/
|
|
73
73
|
bumpForRole(roleUuid: string, conn: Queryable): Promise<string[]>;
|
|
74
|
+
/**
|
|
75
|
+
* The tenants whose cached grants depend on the role: every tenant where it is ACTIVELY posted
|
|
76
|
+
* (active membership + posting) plus every service account's tenant holding it. Read-only; the
|
|
77
|
+
* tenant set `bumpForRole` bumps. A role delete reads it BEFORE the delete (Phase 8 #2), because
|
|
78
|
+
* the delete cascades the postings away. Errors propagate raw.
|
|
79
|
+
*/
|
|
80
|
+
tenantsForRole(roleUuid: string, conn: Queryable): Promise<string[]>;
|
|
74
81
|
/**
|
|
75
82
|
* Increment the permission version for EVERY tenant where the user has a membership (any status)
|
|
76
83
|
* — for a change to the USER itself that alters its effective grants everywhere (security
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permission-version.bumper.d.ts","sourceRoot":"","sources":["../../../src/features/authz/permission-version.bumper.ts"],"names":[],"mappings":"AASA;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED;;;;;;;;;;;;;GAaG;AACH;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7D;AAOD,qBAAa,uBAAuB;IAatB,OAAO,CAAC,SAAS,CAAC;IAZ9B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IACtE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAyC;IAEhE;;;OAGG;gBACiB,SAAS,CAAC,EAAE,gBAAgB,YAAA;IAEhD,qFAAqF;IACrF,sBAAsB,CAAC,SAAS,EAAE,gBAAgB,GAAG,IAAI;IAIzD;;;OAGG;IACG,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAOtD,kFAAkF;IAClF,cAAc,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI;YAIvB,WAAW;IAUzB;;;;OAIG;IACG,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvE;;;;;;;;;;;;OAYG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"permission-version.bumper.d.ts","sourceRoot":"","sources":["../../../src/features/authz/permission-version.bumper.ts"],"names":[],"mappings":"AASA;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED;;;;;;;;;;;;;GAaG;AACH;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7D;AAOD,qBAAa,uBAAuB;IAatB,OAAO,CAAC,SAAS,CAAC;IAZ9B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IACtE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAyC;IAEhE;;;OAGG;gBACiB,SAAS,CAAC,EAAE,gBAAgB,YAAA;IAEhD,qFAAqF;IACrF,sBAAsB,CAAC,SAAS,EAAE,gBAAgB,GAAG,IAAI;IAIzD;;;OAGG;IACG,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAOtD,kFAAkF;IAClF,cAAc,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI;YAIvB,WAAW;IAUzB;;;;OAIG;IACG,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvE;;;;;;;;;;;;OAYG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAYvE;;;;;OAKG;IACG,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAmB1E;;;;;;OAMG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAgBvE;;;;;;OAMG;YACW,UAAU;CA4BzB"}
|
|
@@ -82,16 +82,7 @@ class PermissionVersionBumper {
|
|
|
82
82
|
*/
|
|
83
83
|
async bumpForRole(roleUuid, conn) {
|
|
84
84
|
try {
|
|
85
|
-
const
|
|
86
|
-
FROM \`${APP}\`.\`company_membership\` cm
|
|
87
|
-
JOIN \`${APP}\`.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
|
|
88
|
-
WHERE mp.mp_role_uuid = ? AND cm.cm_is_active = 1`, [roleUuid]));
|
|
89
|
-
// Service accounts (ADR-0033) hold roles on `sa_role_ids`, not postings: a role held only by
|
|
90
|
-
// machine principals must still invalidate their tenants' cached grants (security hardening).
|
|
91
|
-
const saRows = (await conn.query(`SELECT DISTINCT sa.sa_tenant_id AS tenant
|
|
92
|
-
FROM \`${APP}\`.\`service_account\` sa
|
|
93
|
-
WHERE JSON_CONTAINS(sa.sa_role_ids, JSON_QUOTE(?))`, [roleUuid]));
|
|
94
|
-
const tenants = [...new Set([...rows, ...saRows].map((r) => r.tenant))];
|
|
85
|
+
const tenants = await this.tenantsForRole(roleUuid, conn);
|
|
95
86
|
for (const tenant of tenants) {
|
|
96
87
|
await this.bumpTenant(tenant, conn);
|
|
97
88
|
}
|
|
@@ -101,6 +92,24 @@ class PermissionVersionBumper {
|
|
|
101
92
|
throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
|
|
102
93
|
}
|
|
103
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The tenants whose cached grants depend on the role: every tenant where it is ACTIVELY posted
|
|
97
|
+
* (active membership + posting) plus every service account's tenant holding it. Read-only; the
|
|
98
|
+
* tenant set `bumpForRole` bumps. A role delete reads it BEFORE the delete (Phase 8 #2), because
|
|
99
|
+
* the delete cascades the postings away. Errors propagate raw.
|
|
100
|
+
*/
|
|
101
|
+
async tenantsForRole(roleUuid, conn) {
|
|
102
|
+
const rows = (await conn.query(`SELECT DISTINCT cm.cm_company_uuid AS tenant
|
|
103
|
+
FROM \`${APP}\`.\`company_membership\` cm
|
|
104
|
+
JOIN \`${APP}\`.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
|
|
105
|
+
WHERE mp.mp_role_uuid = ? AND cm.cm_is_active = 1`, [roleUuid]));
|
|
106
|
+
// Service accounts (ADR-0033) hold roles on `sa_role_ids`, not postings: a role held only by
|
|
107
|
+
// machine principals must still invalidate their tenants' cached grants (security hardening).
|
|
108
|
+
const saRows = (await conn.query(`SELECT DISTINCT sa.sa_tenant_id AS tenant
|
|
109
|
+
FROM \`${APP}\`.\`service_account\` sa
|
|
110
|
+
WHERE JSON_CONTAINS(sa.sa_role_ids, JSON_QUOTE(?))`, [roleUuid]));
|
|
111
|
+
return [...new Set([...rows, ...saRows].map((r) => r.tenant))];
|
|
112
|
+
}
|
|
104
113
|
/**
|
|
105
114
|
* Increment the permission version for EVERY tenant where the user has a membership (any status)
|
|
106
115
|
* — for a change to the USER itself that alters its effective grants everywhere (security
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { type Pool } from 'mariadb';
|
|
13
13
|
import { type RecordAuthorizationInputs } from '@astram/guard';
|
|
14
14
|
import type { ObjectTypeInput } from '@astram/engine';
|
|
15
|
+
import type { ModuleRegistry } from '@astram/core';
|
|
15
16
|
/**
|
|
16
17
|
* A lean `mariadb` pool for the authz grant-reads (built from `APP_DB_*` env via guard's
|
|
17
18
|
* `loadDbConfig`). Kept small — these are pre-gate coarse-grant reads, not the main CRUD pool.
|
|
@@ -37,4 +38,15 @@ export declare function createIdentityAuthzPool(): Pool;
|
|
|
37
38
|
* those keys now project, and the framework still owns the four record-tier adapters + scope fn.
|
|
38
39
|
*/
|
|
39
40
|
export declare function buildIdentityRecordAuthorization(pool: Pool, extraObjects?: readonly ObjectTypeInput[]): RecordAuthorizationInputs;
|
|
41
|
+
/**
|
|
42
|
+
* The inputs builder WITHOUT the boot parity record — for the identity module's own internal
|
|
43
|
+
* authorities (they are not the decision path the consumer hands `ShieldModule`).
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildRecordAuthorizationInputs(pool: Pool, extraObjects?: readonly ObjectTypeInput[]): RecordAuthorizationInputs;
|
|
46
|
+
/** The boot error: the two registries' action-key sets differ (names the first differing keys). */
|
|
47
|
+
export declare class IdentityRegistryMismatchError extends Error {
|
|
48
|
+
constructor(onlyDecision: readonly string[], onlyCeiling: readonly string[]);
|
|
49
|
+
}
|
|
50
|
+
/** Called by `IdentityFeatureModule.forRoot` with the grant ceiling's registry (Phase 8 #4). */
|
|
51
|
+
export declare function noteIdentityCeilingRegistry(registry: ModuleRegistry): void;
|
|
40
52
|
//# sourceMappingURL=record-authorization.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"record-authorization.d.ts","sourceRoot":"","sources":["../../../src/features/authz/record-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"record-authorization.d.ts","sourceRoot":"","sources":["../../../src/features/authz/record-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAKnD;;;;GAIG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAU9C;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,IAAI,EACV,YAAY,GAAE,SAAS,eAAe,EAAO,GAC5C,yBAAyB,CAI3B;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,IAAI,EAAE,IAAI,EACV,YAAY,GAAE,SAAS,eAAe,EAAO,GAC5C,yBAAyB,CAS3B;AAmBD,mGAAmG;AACnG,qBAAa,6BAA8B,SAAQ,KAAK;gBAC1C,YAAY,EAAE,SAAS,MAAM,EAAE,EAAE,WAAW,EAAE,SAAS,MAAM,EAAE;CAW5E;AAoBD,gGAAgG;AAChG,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAE1E"}
|
|
@@ -3,8 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.IdentityRegistryMismatchError = void 0;
|
|
6
7
|
exports.createIdentityAuthzPool = createIdentityAuthzPool;
|
|
7
8
|
exports.buildIdentityRecordAuthorization = buildIdentityRecordAuthorization;
|
|
9
|
+
exports.buildRecordAuthorizationInputs = buildRecordAuthorizationInputs;
|
|
10
|
+
exports.noteIdentityCeilingRegistry = noteIdentityCeilingRegistry;
|
|
8
11
|
/**
|
|
9
12
|
* Assemble the record-tier authorization wiring for `@astram/identity`: the lean grant-read
|
|
10
13
|
* pool factory + the `recordAuthorization` inputs the consumer passes to
|
|
@@ -56,6 +59,15 @@ function createIdentityAuthzPool() {
|
|
|
56
59
|
* those keys now project, and the framework still owns the four record-tier adapters + scope fn.
|
|
57
60
|
*/
|
|
58
61
|
function buildIdentityRecordAuthorization(pool, extraObjects = []) {
|
|
62
|
+
const inputs = buildRecordAuthorizationInputs(pool, extraObjects);
|
|
63
|
+
noteRegistry('decision', inputs.registry);
|
|
64
|
+
return inputs;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The inputs builder WITHOUT the boot parity record — for the identity module's own internal
|
|
68
|
+
* authorities (they are not the decision path the consumer hands `ShieldModule`).
|
|
69
|
+
*/
|
|
70
|
+
function buildRecordAuthorizationInputs(pool, extraObjects = []) {
|
|
59
71
|
const registry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
|
|
60
72
|
return {
|
|
61
73
|
pool,
|
|
@@ -65,3 +77,40 @@ function buildIdentityRecordAuthorization(pool, extraObjects = []) {
|
|
|
65
77
|
operationManifest: [],
|
|
66
78
|
};
|
|
67
79
|
}
|
|
80
|
+
const recordedKeySets = {
|
|
81
|
+
decision: new Map(),
|
|
82
|
+
ceiling: new Map(),
|
|
83
|
+
};
|
|
84
|
+
/** The boot error: the two registries' action-key sets differ (names the first differing keys). */
|
|
85
|
+
class IdentityRegistryMismatchError extends Error {
|
|
86
|
+
constructor(onlyDecision, onlyCeiling) {
|
|
87
|
+
const sample = (keys) => keys.length === 0 ? 'none' : keys.slice(0, 10).join(', ') + (keys.length > 10 ? ', …' : '');
|
|
88
|
+
super('IdentityFeatureModule.forRoot({ extraObjects }) and buildIdentityRecordAuthorization(pool, ' +
|
|
89
|
+
'extraObjects) were given different object types. Pass the SAME array to both. ' +
|
|
90
|
+
`Only in the decision path: ${sample(onlyDecision)}. ` +
|
|
91
|
+
`Only in IdentityFeatureModule: ${sample(onlyCeiling)}.`);
|
|
92
|
+
this.name = 'IdentityRegistryMismatchError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
exports.IdentityRegistryMismatchError = IdentityRegistryMismatchError;
|
|
96
|
+
/** Record one side's registry and throw if it disagrees with any recorded registry of the other. */
|
|
97
|
+
function noteRegistry(role, registry) {
|
|
98
|
+
const keys = [...new Set(registry.listActions().map((a) => a.permissionKey))].sort();
|
|
99
|
+
const other = recordedKeySets[role === 'decision' ? 'ceiling' : 'decision'];
|
|
100
|
+
for (const otherKeys of other.values()) {
|
|
101
|
+
const mine = new Set(keys);
|
|
102
|
+
const theirs = new Set(otherKeys);
|
|
103
|
+
const onlyMine = keys.filter((k) => !theirs.has(k));
|
|
104
|
+
const onlyTheirs = otherKeys.filter((k) => !mine.has(k));
|
|
105
|
+
if (onlyMine.length > 0 || onlyTheirs.length > 0) {
|
|
106
|
+
throw role === 'decision'
|
|
107
|
+
? new IdentityRegistryMismatchError(onlyMine, onlyTheirs)
|
|
108
|
+
: new IdentityRegistryMismatchError(onlyTheirs, onlyMine);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
recordedKeySets[role].set(keys.join('\n'), keys);
|
|
112
|
+
}
|
|
113
|
+
/** Called by `IdentityFeatureModule.forRoot` with the grant ceiling's registry (Phase 8 #4). */
|
|
114
|
+
function noteIdentityCeilingRegistry(registry) {
|
|
115
|
+
noteRegistry('ceiling', registry);
|
|
116
|
+
}
|