@astram/identity 0.5.0 → 0.7.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/README.md +15 -0
- package/dist/bin/sa-keys-report.d.ts +3 -0
- package/dist/bin/sa-keys-report.d.ts.map +1 -0
- package/dist/bin/sa-keys-report.js +18 -0
- package/dist/features/authz/grant-admin.service.d.ts +33 -1
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +105 -1
- package/dist/features/authz/grant-ceiling.d.ts +62 -1
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +145 -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 +16 -21
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/record-authorization.js +17 -22
- package/dist/features/company/company-hierarchy.service.d.ts +71 -4
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
- package/dist/features/company/company-hierarchy.service.js +197 -8
- package/dist/features/company/company.controller.d.ts +1 -1
- package/dist/features/company/company.controller.d.ts.map +1 -1
- package/dist/features/company/company.controller.js +9 -5
- package/dist/features/company/company.service.d.ts +9 -2
- package/dist/features/company/company.service.d.ts.map +1 -1
- package/dist/features/company/company.service.js +17 -2
- package/dist/features/identity-feature.module.d.ts +15 -6
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +79 -22
- 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/membership/posting.service.d.ts +2 -0
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +9 -0
- package/dist/features/role/role.service.d.ts +32 -2
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +64 -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/lock-service-accounts.d.ts +29 -0
- package/dist/features/service-account/lock-service-accounts.d.ts.map +1 -0
- package/dist/features/service-account/lock-service-accounts.js +35 -0
- package/dist/features/service-account/no-expiry-key-report.d.ts +39 -0
- package/dist/features/service-account/no-expiry-key-report.d.ts.map +1 -0
- package/dist/features/service-account/no-expiry-key-report.js +91 -0
- 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/features/user/user.service.d.ts +4 -4
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +9 -6
- package/dist/index.d.ts +1 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -11
- package/dist/internal.d.ts +11 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +14 -0
- package/package.json +18 -4
package/README.md
CHANGED
|
@@ -14,3 +14,18 @@ destination for the identity model currently declared in consuming applications
|
|
|
14
14
|
once relocated, apps declare only role-maps, extra columns, and session
|
|
15
15
|
extensions, while the canonical identity objects, migrations, and resolver live
|
|
16
16
|
here in the framework.
|
|
17
|
+
|
|
18
|
+
## `astram-sa-keys-report`
|
|
19
|
+
|
|
20
|
+
Service-account keys issued before an expiry became mandatory never expire. This read-only command
|
|
21
|
+
lists each agent's current, unrevoked key with no expiry (agent label, company name, status, key
|
|
22
|
+
version, issue date; never a secret, hash or id), so you can rotate it with an expiry:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
APP_DB_HOST=… APP_DB_PORT=… APP_DB_USER=… APP_DB_PASSWORD=… APP_DB_NAME=shield_app \
|
|
26
|
+
npx astram-sa-keys-report # exit 0
|
|
27
|
+
npx astram-sa-keys-report --fail-on-found # exit 1 if any key is listed (CI)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
It uses the same `APP_DB_*` variables as the identity module's authz pool. Exit code 2 means it
|
|
31
|
+
could not run (bad option, no database).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sa-keys-report.d.ts","sourceRoot":"","sources":["../../src/bin/sa-keys-report.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
/**
|
|
5
|
+
* `astram-sa-keys-report` — list service-account keys that never expire (security hardening
|
|
6
|
+
* Phase 8 #13 / Phase 9 #8). Read-only; connects with the identity authz pool's `APP_DB_*` env.
|
|
7
|
+
* `--fail-on-found` exits 1 when any key is listed. Logic: `no-expiry-key-report.ts`.
|
|
8
|
+
*/
|
|
9
|
+
const record_authorization_js_1 = require("../features/authz/record-authorization.js");
|
|
10
|
+
const no_expiry_key_report_js_1 = require("../features/service-account/no-expiry-key-report.js");
|
|
11
|
+
void (0, no_expiry_key_report_js_1.runNonExpiringKeyReport)({
|
|
12
|
+
argv: process.argv.slice(2),
|
|
13
|
+
openDb: record_authorization_js_1.createIdentityAuthzPool,
|
|
14
|
+
out: (text) => process.stdout.write(text),
|
|
15
|
+
err: (text) => process.stderr.write(text),
|
|
16
|
+
}).then((code) => {
|
|
17
|
+
process.exitCode = code;
|
|
18
|
+
});
|
|
@@ -114,9 +114,41 @@ 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,
|
|
137
|
+
* has already run the binding rule and user-holder dominance, and has already run the engine's
|
|
138
|
+
* delete (gate included, Phase 9 #5) on that still-open transaction; this method therefore does
|
|
139
|
+
* NOT lock the role row (that would wait on the caller) and reads only committed state. It
|
|
140
|
+
* commits BEFORE the caller does, so a failure in between leaves a role with no grants and no
|
|
141
|
+
* agents: fewer permissions, never live grants for a deleted role (fail closed).
|
|
142
|
+
*/
|
|
143
|
+
purgeRole(roleUuid: string, actor: Principal): Promise<string[]>;
|
|
144
|
+
/**
|
|
145
|
+
* The service accounts holding `roleUuid`, read with a row lock on the transaction `conn` (a
|
|
146
|
+
* concurrent edit of one of them waits until this transaction ends). Phase 9 #11: only those
|
|
147
|
+
* rows are locked, never the whole table (`lockServiceAccountsWhere`). An agent that gains the
|
|
148
|
+
* role after the candidate read is not seen; the outcome equals "this change, then that edit"
|
|
149
|
+
* (a role delete leaves that agent an inert uuid: the role and its grants are gone).
|
|
150
|
+
*/
|
|
151
|
+
private lockAccountsHoldingRole;
|
|
120
152
|
/** All active bundles + their resolved atomic keys, for the Access UI. */
|
|
121
153
|
listBundles(): Promise<{
|
|
122
154
|
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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"}
|
|
@@ -7,8 +7,27 @@ const db_permission_facts_adapter_js_1 = require("./db-permission-facts.adapter.
|
|
|
7
7
|
const grant_ceiling_js_1 = require("./grant-ceiling.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 lock_service_accounts_js_1 = require("../service-account/lock-service-accounts.js");
|
|
10
11
|
const AUTHZ = 'shield_authz';
|
|
11
12
|
const APP = 'shield_app';
|
|
13
|
+
/** The grant-admin actions that REMOVE authority from a role's holders (Phase 8 #2). */
|
|
14
|
+
const REVOKE_ACTIONS = new Set([
|
|
15
|
+
'role.permission_revoked',
|
|
16
|
+
'role.bundle_revoked',
|
|
17
|
+
]);
|
|
18
|
+
/** A JSON-array column (the driver returns an array OR a string) → its string items; else []. */
|
|
19
|
+
function jsonIdArray(v) {
|
|
20
|
+
let arr = v;
|
|
21
|
+
if (typeof v === 'string') {
|
|
22
|
+
try {
|
|
23
|
+
arr = JSON.parse(v);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
arr = [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
|
|
30
|
+
}
|
|
12
31
|
/** A granted key was not in the catalog and is not a recognized wildcard. The
|
|
13
32
|
* controller maps this to HTTP 400 (distinct from a store outage → 503). */
|
|
14
33
|
class BadKeyError extends Error {
|
|
@@ -133,6 +152,16 @@ class GrantAdminService {
|
|
|
133
152
|
]);
|
|
134
153
|
if (this.ceiling && actor.isRoot !== true) {
|
|
135
154
|
grant_ceiling_js_1.GrantCeiling.checkAdministrable(await (0, role_binding_js_1.readRoleBinding)(conn, roleUuid, (0, grant_ceiling_js_1.subtreeOf)(actor)));
|
|
155
|
+
// Phase 8 #2: a revocation strips authority from every holder of the role, so the actor
|
|
156
|
+
// must dominate each of them — checked under the role lock (no holder can appear unseen).
|
|
157
|
+
// Phase 9 #9: that includes the service accounts holding the role, judged as a role
|
|
158
|
+
// delete judges them (each account's tenant in the actor's subtree, the role held by the
|
|
159
|
+
// actor), over the accounts read under a row lock.
|
|
160
|
+
if (REVOKE_ACTIONS.has(action)) {
|
|
161
|
+
await this.ceiling.assertDominatesRoleHolders(actor, roleUuid);
|
|
162
|
+
const accounts = await this.lockAccountsHoldingRole(conn, roleUuid);
|
|
163
|
+
await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id), 'revoke');
|
|
164
|
+
}
|
|
136
165
|
}
|
|
137
166
|
for (const key of keys)
|
|
138
167
|
await statement(conn, key);
|
|
@@ -230,7 +259,8 @@ class GrantAdminService {
|
|
|
230
259
|
* Revoke a key from a role (no-op if absent) + bump + audit, atomically. Security hardening #1:
|
|
231
260
|
* the actor must be able to administer the role (binding rule) and must hold the key itself —
|
|
232
261
|
* "only revoke what you hold" — so a delegated admin can no longer strip a role of authority it
|
|
233
|
-
* never had.
|
|
262
|
+
* never had. Phase 8 #2: it must also dominate every user holding the role (checked under the
|
|
263
|
+
* role lock in `writeGrants`). Root bypasses.
|
|
234
264
|
*/
|
|
235
265
|
async revoke(roleUuid, key, actor) {
|
|
236
266
|
await this.assertMayChangeGrants(roleUuid, [key], actor);
|
|
@@ -238,6 +268,80 @@ class GrantAdminService {
|
|
|
238
268
|
await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?`, [roleUuid, k]);
|
|
239
269
|
});
|
|
240
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Remove a role that is being DELETED from authorization (Phase 8 #2 follow-up): every
|
|
273
|
+
* `role_permission` row of the role, and the role from every service account's `sa_role_ids`
|
|
274
|
+
* (guard's API-key store reads that column live, so a stale entry would keep the deleted role's
|
|
275
|
+
* keys on the agent). ONE transaction on the shared authz pool — the same pool, bump, audit and
|
|
276
|
+
* error mapping as {@link revoke}:
|
|
277
|
+
* 1. lock the holding accounts (`FOR UPDATE`), then check the actor may strip the role from
|
|
278
|
+
* each (`GrantCeiling.assertMayStripRoleFromServiceAccounts`, root bypasses);
|
|
279
|
+
* 2. bump every tenant using the role — postings AND the accounts' tenants — before the strip,
|
|
280
|
+
* so the account tenants are still found;
|
|
281
|
+
* 3. delete the role's grants; rewrite each account's role list without it (edit stamp moved
|
|
282
|
+
* forward, so a concurrent preserve-merge edit conflicts instead of re-adding it);
|
|
283
|
+
* 4. audit as `role.permission_revoked` (`roleDeleted: true`), commit, publish the versions.
|
|
284
|
+
* Returns the bumped tenants.
|
|
285
|
+
*
|
|
286
|
+
* The CALLER (`RoleService.deleteRole`) holds the role row lock on its own engine connection,
|
|
287
|
+
* has already run the binding rule and user-holder dominance, and has already run the engine's
|
|
288
|
+
* delete (gate included, Phase 9 #5) on that still-open transaction; this method therefore does
|
|
289
|
+
* NOT lock the role row (that would wait on the caller) and reads only committed state. It
|
|
290
|
+
* commits BEFORE the caller does, so a failure in between leaves a role with no grants and no
|
|
291
|
+
* agents: fewer permissions, never live grants for a deleted role (fail closed).
|
|
292
|
+
*/
|
|
293
|
+
async purgeRole(roleUuid, actor) {
|
|
294
|
+
const conn = await this.pool.getConnection();
|
|
295
|
+
try {
|
|
296
|
+
await conn.beginTransaction();
|
|
297
|
+
const accounts = await this.lockAccountsHoldingRole(conn, roleUuid);
|
|
298
|
+
if (this.ceiling && actor.isRoot !== true) {
|
|
299
|
+
await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id), 'delete');
|
|
300
|
+
}
|
|
301
|
+
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);
|
|
302
|
+
const bumped = await this.bumper.bumpForRole(roleUuid, conn);
|
|
303
|
+
await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ?`, [
|
|
304
|
+
roleUuid,
|
|
305
|
+
]);
|
|
306
|
+
const nowMs = Date.now();
|
|
307
|
+
for (const a of accounts) {
|
|
308
|
+
const remaining = jsonIdArray(a.sa_role_ids).filter((id) => id !== roleUuid);
|
|
309
|
+
const previous = a.sa_updated_at_ms === null ? 0 : Number(a.sa_updated_at_ms);
|
|
310
|
+
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]);
|
|
311
|
+
}
|
|
312
|
+
await this.auditGrantChange(conn, 'role.permission_revoked', roleUuid, keys, actor, bumped, {
|
|
313
|
+
roleDeleted: true,
|
|
314
|
+
serviceAccountsUpdated: accounts.length,
|
|
315
|
+
});
|
|
316
|
+
await conn.commit();
|
|
317
|
+
await this.bumper.publishCommitted(conn);
|
|
318
|
+
return bumped;
|
|
319
|
+
}
|
|
320
|
+
catch (cause) {
|
|
321
|
+
await conn.rollback();
|
|
322
|
+
this.bumper.discardPending(conn);
|
|
323
|
+
if (cause instanceof identity_errors_js_1.IdentityForbiddenError || cause instanceof identity_errors_js_1.IdentityNotFoundError) {
|
|
324
|
+
throw cause;
|
|
325
|
+
}
|
|
326
|
+
throw new core_1.AuthorizationUnavailableError('role grant purge failed', cause);
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
await conn.release();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The service accounts holding `roleUuid`, read with a row lock on the transaction `conn` (a
|
|
334
|
+
* concurrent edit of one of them waits until this transaction ends). Phase 9 #11: only those
|
|
335
|
+
* rows are locked, never the whole table (`lockServiceAccountsWhere`). An agent that gains the
|
|
336
|
+
* role after the candidate read is not seen; the outcome equals "this change, then that edit"
|
|
337
|
+
* (a role delete leaves that agent an inert uuid: the role and its grants are gone).
|
|
338
|
+
*/
|
|
339
|
+
lockAccountsHoldingRole(conn, roleUuid) {
|
|
340
|
+
return (0, lock_service_accounts_js_1.lockServiceAccountsWhere)(conn, 'sa_id, sa_tenant_id, sa_role_ids, sa_updated_at_ms', {
|
|
341
|
+
sql: 'JSON_CONTAINS(sa_role_ids, JSON_QUOTE(?))',
|
|
342
|
+
params: [roleUuid],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
241
345
|
// ── Access bundles ────────────────────────────────────────────────────────
|
|
242
346
|
/** All active bundles + their resolved atomic keys, for the Access UI. */
|
|
243
347
|
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,58 @@ 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 are checked separately by {@link assertMayStripRoleFromServiceAccounts}
|
|
111
|
+
* (a role delete strips the role from each account; a revoke narrows what each account holds —
|
|
112
|
+
* Phase 9 #9). Root bypasses. Store outage → 503.
|
|
113
|
+
*
|
|
114
|
+
* Callers serialise against a concurrent posting by locking the role row first (the posting
|
|
115
|
+
* path locks the same row before it inserts), then call this — so no holder appears unseen.
|
|
116
|
+
*/
|
|
117
|
+
assertDominatesRoleHolders(actor: Principal, roleUuid: string): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* Assert the actor may REMOVE `roleUuid` from every service account holding it (Phase 8 #2
|
|
120
|
+
* follow-up: a role delete strips the role from each holding account). Judged by the SAME rules
|
|
121
|
+
* as editing the account directly (`ServiceAccountService.update`, ADR-0029 ceiling via
|
|
122
|
+
* {@link ceilingOf}): each account's tenant is inside the actor's subtree, and the role is one
|
|
123
|
+
* the actor holds itself — a non-root admin's edit preserves (cannot strip) a role it does not
|
|
124
|
+
* hold. `accountTenantIds` are the holding accounts' tenants, read by the caller under a row lock.
|
|
125
|
+
* Phase 9 #9: a grant revoke (`operation: 'revoke'`) narrows every holding account the same way
|
|
126
|
+
* and is judged by the same rule. Root bypasses. Refusals name no account. Store outage → 503.
|
|
127
|
+
*/
|
|
128
|
+
assertMayStripRoleFromServiceAccounts(actor: Principal, roleUuid: string, accountTenantIds: readonly string[], operation?: 'delete' | 'revoke'): 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>;
|
|
143
|
+
/**
|
|
144
|
+
* Assert every department and branch a posting names belongs to the membership's company
|
|
145
|
+
* (Phase 9 #13b, review F4): a `company_departments` / `company_branches` junction row links it
|
|
146
|
+
* to `companyUuid`. Otherwise a posting could carry another tenant's department or branch.
|
|
147
|
+
* Applies to every actor, root included (it is the data's shape, not a privilege). A missing
|
|
148
|
+
* company (`null`) admits none. A foreign and a nonexistent unit give the same 400. Read through
|
|
149
|
+
* the shared authz pool, not the engine: the posting admin need not hold `company_department` /
|
|
150
|
+
* `company_branch` read. Store outage → 503.
|
|
151
|
+
*/
|
|
152
|
+
assertOrgUnitsInCompany(companyUuid: string | null, units: {
|
|
153
|
+
readonly departmentUuids: readonly string[];
|
|
154
|
+
readonly branchUuids: readonly string[];
|
|
155
|
+
}): Promise<void>;
|
|
95
156
|
/** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
|
|
96
157
|
roleBinding(actor: Principal, roleUuid: string): Promise<RoleBinding>;
|
|
97
158
|
/**
|
|
@@ -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,138 @@ 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 are checked separately by {@link assertMayStripRoleFromServiceAccounts}
|
|
240
|
+
* (a role delete strips the role from each account; a revoke narrows what each account holds —
|
|
241
|
+
* Phase 9 #9). Root bypasses. Store outage → 503.
|
|
242
|
+
*
|
|
243
|
+
* Callers serialise against a concurrent posting by locking the role row first (the posting
|
|
244
|
+
* path locks the same row before it inserts), then call this — so no holder appears unseen.
|
|
245
|
+
*/
|
|
246
|
+
async assertDominatesRoleHolders(actor, roleUuid) {
|
|
247
|
+
if (actor.isRoot === true)
|
|
248
|
+
return;
|
|
249
|
+
const holders = await this.facts('role holders', async () => {
|
|
250
|
+
const rows = (await this.pool.query(`SELECT DISTINCT cm.cm_user_uuid AS holder
|
|
251
|
+
FROM \`${APP}\`.\`membership_posting\` mp
|
|
252
|
+
JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
|
|
253
|
+
WHERE mp.mp_role_uuid = ?`, [roleUuid]));
|
|
254
|
+
return rows.map((r) => r.holder);
|
|
255
|
+
});
|
|
256
|
+
for (const holder of [...new Set(holders)].sort()) {
|
|
257
|
+
if (holder === actor.id)
|
|
258
|
+
continue;
|
|
259
|
+
try {
|
|
260
|
+
await this.assertDominates(actor, holder);
|
|
261
|
+
}
|
|
262
|
+
catch (cause) {
|
|
263
|
+
if (cause instanceof identity_errors_js_1.IdentityForbiddenError || cause instanceof identity_errors_js_1.IdentityNotFoundError) {
|
|
264
|
+
// Names no holder (who holds a role is not the caller's to learn from a refusal).
|
|
265
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this role: it is held by someone outside your authority.');
|
|
266
|
+
}
|
|
267
|
+
throw cause;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Assert the actor may REMOVE `roleUuid` from every service account holding it (Phase 8 #2
|
|
273
|
+
* follow-up: a role delete strips the role from each holding account). Judged by the SAME rules
|
|
274
|
+
* as editing the account directly (`ServiceAccountService.update`, ADR-0029 ceiling via
|
|
275
|
+
* {@link ceilingOf}): each account's tenant is inside the actor's subtree, and the role is one
|
|
276
|
+
* the actor holds itself — a non-root admin's edit preserves (cannot strip) a role it does not
|
|
277
|
+
* hold. `accountTenantIds` are the holding accounts' tenants, read by the caller under a row lock.
|
|
278
|
+
* Phase 9 #9: a grant revoke (`operation: 'revoke'`) narrows every holding account the same way
|
|
279
|
+
* and is judged by the same rule. Root bypasses. Refusals name no account. Store outage → 503.
|
|
280
|
+
*/
|
|
281
|
+
async assertMayStripRoleFromServiceAccounts(actor, roleUuid, accountTenantIds, operation = 'delete') {
|
|
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(operation === 'revoke'
|
|
291
|
+
? 'You cannot change this role: it is assigned to an agent you cannot change.'
|
|
292
|
+
: 'You cannot delete this role: it is assigned to an agent you cannot change.');
|
|
293
|
+
const nodes = (0, service_account_service_js_1.ceilingOf)(admin, []);
|
|
294
|
+
if (accountTenantIds.some((t) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, t)))
|
|
295
|
+
throw refuse();
|
|
296
|
+
const held = await this.facts('held roles', () => (0, db_permission_facts_adapter_js_1.readRoleIdsForTenant)(this.pool, actor.id, actor.tenantId, this.now));
|
|
297
|
+
if (!(0, service_account_service_js_1.ceilingAllowsRole)((0, service_account_service_js_1.ceilingOf)(admin, held), roleUuid))
|
|
298
|
+
throw refuse();
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Assert the actor may RETIRE (disable + revoke the keys of) each of these service accounts
|
|
302
|
+
* (Phase 8 #3: a company delete retires the deleted company's own agents). Judged by the SAME
|
|
303
|
+
* ADR-0029 ceiling an admin needs to have made the agent ({@link ceilingOf}): the account's
|
|
304
|
+
* tenant and every node of its scope are inside the actor's subtree, and every role it holds is
|
|
305
|
+
* one the actor holds itself — so a sub-admin can never switch off an agent more powerful than
|
|
306
|
+
* itself by deleting its company. `accounts` are read by the caller under a row lock. Root
|
|
307
|
+
* bypasses. Refusals name no account. Store outage → 503.
|
|
308
|
+
*/
|
|
309
|
+
async assertMayRetireServiceAccounts(actor, accounts) {
|
|
310
|
+
if (actor.isRoot === true || accounts.length === 0)
|
|
311
|
+
return;
|
|
312
|
+
const admin = {
|
|
313
|
+
id: actor.id,
|
|
314
|
+
tenantId: actor.tenantId,
|
|
315
|
+
scopeSubtreeNodeIds: subtreeOf(actor),
|
|
316
|
+
isRoot: false,
|
|
317
|
+
};
|
|
318
|
+
const refuse = () => new identity_errors_js_1.IdentityForbiddenError('You cannot delete this company: one of its agents is outside your authority.');
|
|
319
|
+
const nodes = (0, service_account_service_js_1.ceilingOf)(admin, []);
|
|
320
|
+
const outsideNodes = accounts.some((a) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, a.tenantId) ||
|
|
321
|
+
a.scopeSubtreeNodeIds.some((n) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, n)));
|
|
322
|
+
if (outsideNodes)
|
|
323
|
+
throw refuse();
|
|
324
|
+
if (accounts.every((a) => a.roleIds.length === 0))
|
|
325
|
+
return;
|
|
326
|
+
const held = await this.facts('held roles', () => (0, db_permission_facts_adapter_js_1.readRoleIdsForTenant)(this.pool, actor.id, actor.tenantId, this.now));
|
|
327
|
+
const ceiling = (0, service_account_service_js_1.ceilingOf)(admin, held);
|
|
328
|
+
if (accounts.some((a) => a.roleIds.some((r) => !(0, service_account_service_js_1.ceilingAllowsRole)(ceiling, r)))) {
|
|
329
|
+
throw refuse();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Assert every department and branch a posting names belongs to the membership's company
|
|
334
|
+
* (Phase 9 #13b, review F4): a `company_departments` / `company_branches` junction row links it
|
|
335
|
+
* to `companyUuid`. Otherwise a posting could carry another tenant's department or branch.
|
|
336
|
+
* Applies to every actor, root included (it is the data's shape, not a privilege). A missing
|
|
337
|
+
* company (`null`) admits none. A foreign and a nonexistent unit give the same 400. Read through
|
|
338
|
+
* the shared authz pool, not the engine: the posting admin need not hold `company_department` /
|
|
339
|
+
* `company_branch` read. Store outage → 503.
|
|
340
|
+
*/
|
|
341
|
+
async assertOrgUnitsInCompany(companyUuid, units) {
|
|
342
|
+
const check = async (ids, table, companyCol, unitCol, message) => {
|
|
343
|
+
const wanted = [...new Set(ids)];
|
|
344
|
+
if (wanted.length === 0)
|
|
345
|
+
return;
|
|
346
|
+
if (companyUuid === null)
|
|
347
|
+
throw new identity_errors_js_1.IdentityBadRequestError(message);
|
|
348
|
+
const rows = await this.facts('company org units', async () => (await this.pool.query(`SELECT ${unitCol} AS unit FROM \`${APP}\`.\`${table}\`
|
|
349
|
+
WHERE ${companyCol} = ? AND ${unitCol} IN (${wanted.map(() => '?').join(', ')})`, [companyUuid, ...wanted])));
|
|
350
|
+
const linked = new Set(rows.map((r) => r.unit));
|
|
351
|
+
if (wanted.some((u) => !linked.has(u)))
|
|
352
|
+
throw new identity_errors_js_1.IdentityBadRequestError(message);
|
|
353
|
+
};
|
|
354
|
+
await check(units.departmentUuids, 'company_departments', 'cd_company_uuid', 'cd_department_uuid', 'The department is not one of this company’s departments.');
|
|
355
|
+
await check(units.branchUuids, 'company_branches', 'cb_company_uuid', 'cb_branch_uuid', 'The branch is not one of this company’s branches.');
|
|
356
|
+
}
|
|
222
357
|
/** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
|
|
223
358
|
async roleBinding(actor, roleUuid) {
|
|
224
359
|
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
|