@astram/identity 0.6.0 → 0.7.1
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 +22 -5
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +45 -10
- package/dist/features/authz/grant-ceiling.d.ts +19 -6
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +36 -8
- package/dist/features/authz/record-authorization.d.ts +15 -32
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- package/dist/features/authz/record-authorization.js +15 -69
- package/dist/features/company/company-hierarchy.service.d.ts +13 -1
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
- package/dist/features/company/company-hierarchy.service.js +36 -9
- 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 +18 -2
- package/dist/features/identity-feature.module.d.ts +15 -8
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +75 -28
- 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 +14 -7
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +15 -8
- 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/shared/http.d.ts +6 -0
- package/dist/features/shared/http.d.ts.map +1 -1
- package/dist/features/shared/http.js +9 -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 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -12
- package/dist/internal.d.ts +3 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +3 -2
- package/package.json +7 -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
|
+
});
|
|
@@ -133,13 +133,30 @@ export declare class GrantAdminService {
|
|
|
133
133
|
* 4. audit as `role.permission_revoked` (`roleDeleted: true`), commit, publish the versions.
|
|
134
134
|
* Returns the bumped tenants.
|
|
135
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
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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).
|
|
141
142
|
*/
|
|
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
|
+
/**
|
|
152
|
+
* The role's uuid as stored (Phase 10 #1): `r_uuid` is `utf8mb4_unicode_ci`, so a route uuid in
|
|
153
|
+
* another letter case reaches the row, but `JSON_CONTAINS(sa_role_ids, …)` and the `sa_role_ids`
|
|
154
|
+
* rewrite are exact — an upper-cased uuid would find no holding agent (skipping their check) and
|
|
155
|
+
* write grants under a second spelling. Unknown → the input unchanged (later steps 404). A
|
|
156
|
+
* consistent read: `purgeRole` runs while the caller's delete of this row is uncommitted.
|
|
157
|
+
*/
|
|
158
|
+
private storedRoleUuid;
|
|
159
|
+
private lockAccountsHoldingRole;
|
|
143
160
|
/** All active bundles + their resolved atomic keys, for the Access UI. */
|
|
144
161
|
listBundles(): Promise<{
|
|
145
162
|
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,6 +7,7 @@ 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';
|
|
12
13
|
/** The grant-admin actions that REMOVE authority from a role's holders (Phase 8 #2). */
|
|
@@ -153,8 +154,13 @@ class GrantAdminService {
|
|
|
153
154
|
grant_ceiling_js_1.GrantCeiling.checkAdministrable(await (0, role_binding_js_1.readRoleBinding)(conn, roleUuid, (0, grant_ceiling_js_1.subtreeOf)(actor)));
|
|
154
155
|
// Phase 8 #2: a revocation strips authority from every holder of the role, so the actor
|
|
155
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.
|
|
156
160
|
if (REVOKE_ACTIONS.has(action)) {
|
|
157
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');
|
|
158
164
|
}
|
|
159
165
|
}
|
|
160
166
|
for (const key of keys)
|
|
@@ -236,6 +242,7 @@ class GrantAdminService {
|
|
|
236
242
|
* (404/403) → ceiling (403) → write. Root bypasses the binding rule and the ceiling.
|
|
237
243
|
*/
|
|
238
244
|
async grant(roleUuid, key, grantor) {
|
|
245
|
+
roleUuid = await this.storedRoleUuid(roleUuid);
|
|
239
246
|
await this.assertValidKey(key); // throws BadKeyError (400) before touching the store
|
|
240
247
|
await this.assertMayChangeGrants(roleUuid, [key], grantor);
|
|
241
248
|
const actorUuid = grantor.id;
|
|
@@ -257,6 +264,7 @@ class GrantAdminService {
|
|
|
257
264
|
* role lock in `writeGrants`). Root bypasses.
|
|
258
265
|
*/
|
|
259
266
|
async revoke(roleUuid, key, actor) {
|
|
267
|
+
roleUuid = await this.storedRoleUuid(roleUuid);
|
|
260
268
|
await this.assertMayChangeGrants(roleUuid, [key], actor);
|
|
261
269
|
await this.writeGrants('role.permission_revoked', roleUuid, [key], actor, async (conn, k) => {
|
|
262
270
|
await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?`, [roleUuid, k]);
|
|
@@ -277,22 +285,21 @@ class GrantAdminService {
|
|
|
277
285
|
* 4. audit as `role.permission_revoked` (`roleDeleted: true`), commit, publish the versions.
|
|
278
286
|
* Returns the bumped tenants.
|
|
279
287
|
*
|
|
280
|
-
* The CALLER (`RoleService.deleteRole`) holds the role row lock on its own engine connection
|
|
281
|
-
* has already run the binding rule and user-holder dominance
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
288
|
+
* The CALLER (`RoleService.deleteRole`) holds the role row lock on its own engine connection,
|
|
289
|
+
* has already run the binding rule and user-holder dominance, and has already run the engine's
|
|
290
|
+
* delete (gate included, Phase 9 #5) on that still-open transaction; this method therefore does
|
|
291
|
+
* NOT lock the role row (that would wait on the caller) and reads only committed state. It
|
|
292
|
+
* commits BEFORE the caller does, so a failure in between leaves a role with no grants and no
|
|
293
|
+
* agents: fewer permissions, never live grants for a deleted role (fail closed).
|
|
285
294
|
*/
|
|
286
295
|
async purgeRole(roleUuid, actor) {
|
|
296
|
+
roleUuid = await this.storedRoleUuid(roleUuid);
|
|
287
297
|
const conn = await this.pool.getConnection();
|
|
288
298
|
try {
|
|
289
299
|
await conn.beginTransaction();
|
|
290
|
-
const accounts =
|
|
291
|
-
FROM \`${APP}\`.\`service_account\`
|
|
292
|
-
WHERE JSON_CONTAINS(sa_role_ids, JSON_QUOTE(?))
|
|
293
|
-
FOR UPDATE`, [roleUuid]));
|
|
300
|
+
const accounts = await this.lockAccountsHoldingRole(conn, roleUuid);
|
|
294
301
|
if (this.ceiling && actor.isRoot !== true) {
|
|
295
|
-
await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id));
|
|
302
|
+
await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id), 'delete');
|
|
296
303
|
}
|
|
297
304
|
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
305
|
const bumped = await this.bumper.bumpForRole(roleUuid, conn);
|
|
@@ -325,6 +332,32 @@ class GrantAdminService {
|
|
|
325
332
|
await conn.release();
|
|
326
333
|
}
|
|
327
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* The service accounts holding `roleUuid`, read with a row lock on the transaction `conn` (a
|
|
337
|
+
* concurrent edit of one of them waits until this transaction ends). Phase 9 #11: only those
|
|
338
|
+
* rows are locked, never the whole table (`lockServiceAccountsWhere`). An agent that gains the
|
|
339
|
+
* role after the candidate read is not seen; the outcome equals "this change, then that edit"
|
|
340
|
+
* (a role delete leaves that agent an inert uuid: the role and its grants are gone).
|
|
341
|
+
*/
|
|
342
|
+
/**
|
|
343
|
+
* The role's uuid as stored (Phase 10 #1): `r_uuid` is `utf8mb4_unicode_ci`, so a route uuid in
|
|
344
|
+
* another letter case reaches the row, but `JSON_CONTAINS(sa_role_ids, …)` and the `sa_role_ids`
|
|
345
|
+
* rewrite are exact — an upper-cased uuid would find no holding agent (skipping their check) and
|
|
346
|
+
* write grants under a second spelling. Unknown → the input unchanged (later steps 404). A
|
|
347
|
+
* consistent read: `purgeRole` runs while the caller's delete of this row is uncommitted.
|
|
348
|
+
*/
|
|
349
|
+
async storedRoleUuid(roleUuid) {
|
|
350
|
+
if (typeof roleUuid !== 'string' || roleUuid.length === 0)
|
|
351
|
+
return roleUuid;
|
|
352
|
+
const rows = (await this.pool.query(`SELECT r_uuid FROM \`${APP}\`.\`roles\` WHERE r_uuid = ? LIMIT 1`, [roleUuid]));
|
|
353
|
+
return rows[0]?.r_uuid ?? roleUuid;
|
|
354
|
+
}
|
|
355
|
+
lockAccountsHoldingRole(conn, roleUuid) {
|
|
356
|
+
return (0, lock_service_accounts_js_1.lockServiceAccountsWhere)(conn, 'sa_id, sa_tenant_id, sa_role_ids, sa_updated_at_ms', {
|
|
357
|
+
sql: 'JSON_CONTAINS(sa_role_ids, JSON_QUOTE(?))',
|
|
358
|
+
params: [roleUuid],
|
|
359
|
+
});
|
|
360
|
+
}
|
|
328
361
|
// ── Access bundles ────────────────────────────────────────────────────────
|
|
329
362
|
/** All active bundles + their resolved atomic keys, for the Access UI. */
|
|
330
363
|
async listBundles() {
|
|
@@ -344,6 +377,7 @@ class GrantAdminService {
|
|
|
344
377
|
* to; otherwise `GrantCeilingError` (403), checked before any write. Root bypasses.
|
|
345
378
|
*/
|
|
346
379
|
async grantBundle(roleUuid, bundleKey, grantor) {
|
|
380
|
+
roleUuid = await this.storedRoleUuid(roleUuid);
|
|
347
381
|
const keys = await this.resolveBundleKeysOrThrow(bundleKey);
|
|
348
382
|
// Binding rule + ADR-0029 ceiling: a non-root grantor must hold EVERY atomic key the bundle
|
|
349
383
|
// expands to — checked before any write (no partial bundle escalation).
|
|
@@ -367,6 +401,7 @@ class GrantAdminService {
|
|
|
367
401
|
* #1: the binding rule + "only revoke what you hold" apply (root bypasses).
|
|
368
402
|
*/
|
|
369
403
|
async revokeBundle(roleUuid, bundleKey, actor) {
|
|
404
|
+
roleUuid = await this.storedRoleUuid(roleUuid);
|
|
370
405
|
const keys = await this.resolveBundleKeysOrThrow(bundleKey);
|
|
371
406
|
await this.assertMayChangeGrants(roleUuid, [...keys], actor);
|
|
372
407
|
await this.writeGrants('role.bundle_revoked', roleUuid, keys, actor, async (conn, key) => {
|
|
@@ -107,10 +107,9 @@ export declare class GrantCeiling {
|
|
|
107
107
|
* {@link assertDominates} the actor would need to change any one of them directly. Holders are
|
|
108
108
|
* the users with a posting of the role in any membership and any window (potential authority).
|
|
109
109
|
* The actor itself is skipped: giving up your own authority stays allowed (self-demotion).
|
|
110
|
-
* Service-account holders
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* Root bypasses. Store outage → 503.
|
|
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.
|
|
114
113
|
*
|
|
115
114
|
* Callers serialise against a concurrent posting by locking the role row first (the posting
|
|
116
115
|
* path locks the same row before it inserts), then call this — so no holder appears unseen.
|
|
@@ -123,9 +122,10 @@ export declare class GrantCeiling {
|
|
|
123
122
|
* {@link ceilingOf}): each account's tenant is inside the actor's subtree, and the role is one
|
|
124
123
|
* the actor holds itself — a non-root admin's edit preserves (cannot strip) a role it does not
|
|
125
124
|
* hold. `accountTenantIds` are the holding accounts' tenants, read by the caller under a row lock.
|
|
126
|
-
*
|
|
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
127
|
*/
|
|
128
|
-
assertMayStripRoleFromServiceAccounts(actor: Principal, roleUuid: string, accountTenantIds: readonly string[]): Promise<void>;
|
|
128
|
+
assertMayStripRoleFromServiceAccounts(actor: Principal, roleUuid: string, accountTenantIds: readonly string[], operation?: 'delete' | 'revoke'): Promise<void>;
|
|
129
129
|
/**
|
|
130
130
|
* Assert the actor may RETIRE (disable + revoke the keys of) each of these service accounts
|
|
131
131
|
* (Phase 8 #3: a company delete retires the deleted company's own agents). Judged by the SAME
|
|
@@ -140,6 +140,19 @@ export declare class GrantCeiling {
|
|
|
140
140
|
readonly scopeSubtreeNodeIds: readonly string[];
|
|
141
141
|
readonly roleIds: readonly string[];
|
|
142
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>;
|
|
143
156
|
/** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
|
|
144
157
|
roleBinding(actor: Principal, roleUuid: string): Promise<RoleBinding>;
|
|
145
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;
|
|
1
|
+
{"version":3,"file":"grant-ceiling.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-ceiling.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,6 +4,7 @@ exports.GrantCeiling = exports.GrantCeilingError = void 0;
|
|
|
4
4
|
exports.subtreeOf = subtreeOf;
|
|
5
5
|
exports.coversKey = coversKey;
|
|
6
6
|
const core_1 = require("@astram/core");
|
|
7
|
+
const http_js_1 = require("../shared/http.js");
|
|
7
8
|
const db_permission_facts_adapter_js_1 = require("./db-permission-facts.adapter.js");
|
|
8
9
|
const role_binding_js_1 = require("./role-binding.js");
|
|
9
10
|
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
@@ -192,7 +193,7 @@ class GrantCeiling {
|
|
|
192
193
|
async assertDominates(actor, targetUserId) {
|
|
193
194
|
if (actor.isRoot === true)
|
|
194
195
|
return;
|
|
195
|
-
if (this.rootIds.
|
|
196
|
+
if (this.rootIds.some((r) => (0, http_js_1.sameUuid)(r, targetUserId))) {
|
|
196
197
|
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they are a platform administrator.');
|
|
197
198
|
}
|
|
198
199
|
const subtree = new Set(subtreeOf(actor));
|
|
@@ -236,10 +237,9 @@ class GrantCeiling {
|
|
|
236
237
|
* {@link assertDominates} the actor would need to change any one of them directly. Holders are
|
|
237
238
|
* the users with a posting of the role in any membership and any window (potential authority).
|
|
238
239
|
* The actor itself is skipped: giving up your own authority stays allowed (self-demotion).
|
|
239
|
-
* Service-account holders
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* Root bypasses. Store outage → 503.
|
|
240
|
+
* Service-account holders are checked separately by {@link assertMayStripRoleFromServiceAccounts}
|
|
241
|
+
* (a role delete strips the role from each account; a revoke narrows what each account holds —
|
|
242
|
+
* Phase 9 #9). Root bypasses. Store outage → 503.
|
|
243
243
|
*
|
|
244
244
|
* Callers serialise against a concurrent posting by locking the role row first (the posting
|
|
245
245
|
* path locks the same row before it inserts), then call this — so no holder appears unseen.
|
|
@@ -276,9 +276,10 @@ class GrantCeiling {
|
|
|
276
276
|
* {@link ceilingOf}): each account's tenant is inside the actor's subtree, and the role is one
|
|
277
277
|
* the actor holds itself — a non-root admin's edit preserves (cannot strip) a role it does not
|
|
278
278
|
* hold. `accountTenantIds` are the holding accounts' tenants, read by the caller under a row lock.
|
|
279
|
-
*
|
|
279
|
+
* Phase 9 #9: a grant revoke (`operation: 'revoke'`) narrows every holding account the same way
|
|
280
|
+
* and is judged by the same rule. Root bypasses. Refusals name no account. Store outage → 503.
|
|
280
281
|
*/
|
|
281
|
-
async assertMayStripRoleFromServiceAccounts(actor, roleUuid, accountTenantIds) {
|
|
282
|
+
async assertMayStripRoleFromServiceAccounts(actor, roleUuid, accountTenantIds, operation = 'delete') {
|
|
282
283
|
if (actor.isRoot === true || accountTenantIds.length === 0)
|
|
283
284
|
return;
|
|
284
285
|
const admin = {
|
|
@@ -287,7 +288,9 @@ class GrantCeiling {
|
|
|
287
288
|
scopeSubtreeNodeIds: subtreeOf(actor),
|
|
288
289
|
isRoot: false,
|
|
289
290
|
};
|
|
290
|
-
const refuse = () => new identity_errors_js_1.IdentityForbiddenError(
|
|
291
|
+
const refuse = () => new identity_errors_js_1.IdentityForbiddenError(operation === 'revoke'
|
|
292
|
+
? 'You cannot change this role: it is assigned to an agent you cannot change.'
|
|
293
|
+
: 'You cannot delete this role: it is assigned to an agent you cannot change.');
|
|
291
294
|
const nodes = (0, service_account_service_js_1.ceilingOf)(admin, []);
|
|
292
295
|
if (accountTenantIds.some((t) => !(0, service_account_service_js_1.ceilingAllowsNode)(nodes, t)))
|
|
293
296
|
throw refuse();
|
|
@@ -327,6 +330,31 @@ class GrantCeiling {
|
|
|
327
330
|
throw refuse();
|
|
328
331
|
}
|
|
329
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Assert every department and branch a posting names belongs to the membership's company
|
|
335
|
+
* (Phase 9 #13b, review F4): a `company_departments` / `company_branches` junction row links it
|
|
336
|
+
* to `companyUuid`. Otherwise a posting could carry another tenant's department or branch.
|
|
337
|
+
* Applies to every actor, root included (it is the data's shape, not a privilege). A missing
|
|
338
|
+
* company (`null`) admits none. A foreign and a nonexistent unit give the same 400. Read through
|
|
339
|
+
* the shared authz pool, not the engine: the posting admin need not hold `company_department` /
|
|
340
|
+
* `company_branch` read. Store outage → 503.
|
|
341
|
+
*/
|
|
342
|
+
async assertOrgUnitsInCompany(companyUuid, units) {
|
|
343
|
+
const check = async (ids, table, companyCol, unitCol, message) => {
|
|
344
|
+
const wanted = [...new Set(ids)];
|
|
345
|
+
if (wanted.length === 0)
|
|
346
|
+
return;
|
|
347
|
+
if (companyUuid === null)
|
|
348
|
+
throw new identity_errors_js_1.IdentityBadRequestError(message);
|
|
349
|
+
const rows = await this.facts('company org units', async () => (await this.pool.query(`SELECT ${unitCol} AS unit FROM \`${APP}\`.\`${table}\`
|
|
350
|
+
WHERE ${companyCol} = ? AND ${unitCol} IN (${wanted.map(() => '?').join(', ')})`, [companyUuid, ...wanted])));
|
|
351
|
+
const linked = new Set(rows.map((r) => r.unit));
|
|
352
|
+
if (wanted.some((u) => !linked.has(u)))
|
|
353
|
+
throw new identity_errors_js_1.IdentityBadRequestError(message);
|
|
354
|
+
};
|
|
355
|
+
await check(units.departmentUuids, 'company_departments', 'cd_company_uuid', 'cd_department_uuid', 'The department is not one of this company’s departments.');
|
|
356
|
+
await check(units.branchUuids, 'company_branches', 'cb_company_uuid', 'cb_branch_uuid', 'The branch is not one of this company’s branches.');
|
|
357
|
+
}
|
|
330
358
|
/** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
|
|
331
359
|
async roleBinding(actor, roleUuid) {
|
|
332
360
|
return this.facts('role binding', () => (0, role_binding_js_1.readRoleBinding)(this.pool, roleUuid, subtreeOf(actor)));
|
|
@@ -1,52 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The record-tier authorization inputs for `@astram/identity`: the lean grant-read pool factory and
|
|
3
|
+
* the `RecordAuthorizationInputs` builder. `IdentityFeatureModule.forRoot({ extraObjects })` builds
|
|
4
|
+
* the inputs ONCE and uses them everywhere (security hardening Phase 9 #7):
|
|
5
|
+
* - provided app-wide under guard's `SHIELD_RECORD_AUTHORIZATION`, so `ShieldModule` builds the
|
|
6
|
+
* request path's decision authority from them (the framework's four record-tier adapters are
|
|
7
|
+
* built inside guard);
|
|
8
|
+
* - the module's own cross-tenant / policy authority, list scope and grant ceiling.
|
|
9
|
+
* One registry, one source of `extraObjects`: nothing can disagree.
|
|
7
10
|
*
|
|
8
11
|
* Modeled on astram3's `buildAuthorizationInputs` (`server/src/features/authz/authz.wiring.ts`),
|
|
9
|
-
* adapted to `@astram/identity`'s own registrations (`identityObjects`) and env prefix
|
|
10
|
-
* (`APP_DB_*`, per the Task-7 rename).
|
|
12
|
+
* adapted to `@astram/identity`'s own registrations (`identityObjects`) and env prefix (`APP_DB_*`).
|
|
11
13
|
*/
|
|
12
14
|
import { type Pool } from 'mariadb';
|
|
13
15
|
import { type RecordAuthorizationInputs } from '@astram/guard';
|
|
14
16
|
import type { ObjectTypeInput } from '@astram/engine';
|
|
15
|
-
import type { ModuleRegistry } from '@astram/core';
|
|
16
17
|
/**
|
|
17
18
|
* A lean `mariadb` pool for the authz grant-reads (built from `APP_DB_*` env via guard's
|
|
18
19
|
* `loadDbConfig`). Kept small — these are pre-gate coarse-grant reads, not the main CRUD pool.
|
|
19
|
-
* Opens the pool at CALL time (never at import) — the
|
|
20
|
+
* Opens the pool at CALL time (never at import) — the caller owns closing it.
|
|
20
21
|
*/
|
|
21
22
|
export declare function createIdentityAuthzPool(): Pool;
|
|
22
23
|
/**
|
|
23
|
-
* Build the `RecordAuthorizationInputs`
|
|
24
|
-
*
|
|
25
|
-
* adapters internally; `@astram/identity` supplies only the app-schema ports:
|
|
24
|
+
* Build the `RecordAuthorizationInputs` over `pool`. `@astram/identity` supplies only the
|
|
25
|
+
* app-schema ports:
|
|
26
26
|
* - `permissionFacts` — coarse grants over `shield_authz.role_permission` +
|
|
27
27
|
* `company_membership`/`membership_posting`,
|
|
28
28
|
* - `sessionFacts` — the ADR-0011 ALS-backed per-request reader,
|
|
29
|
-
* - `registry` — bridged from `identityObjects` PLUS
|
|
29
|
+
* - `registry` — bridged from `identityObjects` PLUS the consumer's `extraObjects` (their
|
|
30
|
+
* `<module>:<type>:<action>` keys project into a principal's grants at gate 5; without them a
|
|
31
|
+
* consumer type is `resource_not_registered`),
|
|
30
32
|
* - `operationManifest` — empty (identity declares no trusted delegated operations).
|
|
31
|
-
*
|
|
32
|
-
* `extraObjects` (optional): a CONSUMER app that registers its OWN gated object types via
|
|
33
|
-
* `EngineModule.forFeature([...])` must ALSO pass those types here, so their actions enter the
|
|
34
|
-
* coarse-grant permission-facts registry (gate 5). Without this, a custom type's routes boot
|
|
35
|
-
* and list-scope correctly (gate 6) but their `<module>:<type>:<action>` permission keys are
|
|
36
|
-
* never projected into a principal's grants — so gate 5 denies them (deny-by-default). Passing
|
|
37
|
-
* the same object types the app registered with the engine closes that gap: the app's grants on
|
|
38
|
-
* those keys now project, and the framework still owns the four record-tier adapters + scope fn.
|
|
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
33
|
*/
|
|
45
34
|
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;
|
|
52
35
|
//# 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
|
|
1
|
+
{"version":3,"file":"record-authorization.d.ts","sourceRoot":"","sources":["../../../src/features/authz/record-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;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;AAKtD;;;;GAIG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAU9C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,8BAA8B,CAC5C,IAAI,EAAE,IAAI,EACV,YAAY,GAAE,SAAS,eAAe,EAAO,GAC5C,yBAAyB,CAS3B"}
|
|
@@ -3,21 +3,20 @@ 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;
|
|
7
6
|
exports.createIdentityAuthzPool = createIdentityAuthzPool;
|
|
8
|
-
exports.buildIdentityRecordAuthorization = buildIdentityRecordAuthorization;
|
|
9
7
|
exports.buildRecordAuthorizationInputs = buildRecordAuthorizationInputs;
|
|
10
|
-
exports.noteIdentityCeilingRegistry = noteIdentityCeilingRegistry;
|
|
11
8
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* The record-tier authorization inputs for `@astram/identity`: the lean grant-read pool factory and
|
|
10
|
+
* the `RecordAuthorizationInputs` builder. `IdentityFeatureModule.forRoot({ extraObjects })` builds
|
|
11
|
+
* the inputs ONCE and uses them everywhere (security hardening Phase 9 #7):
|
|
12
|
+
* - provided app-wide under guard's `SHIELD_RECORD_AUTHORIZATION`, so `ShieldModule` builds the
|
|
13
|
+
* request path's decision authority from them (the framework's four record-tier adapters are
|
|
14
|
+
* built inside guard);
|
|
15
|
+
* - the module's own cross-tenant / policy authority, list scope and grant ceiling.
|
|
16
|
+
* One registry, one source of `extraObjects`: nothing can disagree.
|
|
17
17
|
*
|
|
18
18
|
* Modeled on astram3's `buildAuthorizationInputs` (`server/src/features/authz/authz.wiring.ts`),
|
|
19
|
-
* adapted to `@astram/identity`'s own registrations (`identityObjects`) and env prefix
|
|
20
|
-
* (`APP_DB_*`, per the Task-7 rename).
|
|
19
|
+
* adapted to `@astram/identity`'s own registrations (`identityObjects`) and env prefix (`APP_DB_*`).
|
|
21
20
|
*/
|
|
22
21
|
const mariadb_1 = __importDefault(require("mariadb"));
|
|
23
22
|
const guard_1 = require("@astram/guard");
|
|
@@ -27,7 +26,7 @@ const session_facts_reader_js_1 = require("./session-facts.reader.js");
|
|
|
27
26
|
/**
|
|
28
27
|
* A lean `mariadb` pool for the authz grant-reads (built from `APP_DB_*` env via guard's
|
|
29
28
|
* `loadDbConfig`). Kept small — these are pre-gate coarse-grant reads, not the main CRUD pool.
|
|
30
|
-
* Opens the pool at CALL time (never at import) — the
|
|
29
|
+
* Opens the pool at CALL time (never at import) — the caller owns closing it.
|
|
31
30
|
*/
|
|
32
31
|
function createIdentityAuthzPool() {
|
|
33
32
|
const db = (0, guard_1.loadDbConfig)();
|
|
@@ -41,31 +40,15 @@ function createIdentityAuthzPool() {
|
|
|
41
40
|
});
|
|
42
41
|
}
|
|
43
42
|
/**
|
|
44
|
-
* Build the `RecordAuthorizationInputs`
|
|
45
|
-
*
|
|
46
|
-
* adapters internally; `@astram/identity` supplies only the app-schema ports:
|
|
43
|
+
* Build the `RecordAuthorizationInputs` over `pool`. `@astram/identity` supplies only the
|
|
44
|
+
* app-schema ports:
|
|
47
45
|
* - `permissionFacts` — coarse grants over `shield_authz.role_permission` +
|
|
48
46
|
* `company_membership`/`membership_posting`,
|
|
49
47
|
* - `sessionFacts` — the ADR-0011 ALS-backed per-request reader,
|
|
50
|
-
* - `registry` — bridged from `identityObjects` PLUS
|
|
48
|
+
* - `registry` — bridged from `identityObjects` PLUS the consumer's `extraObjects` (their
|
|
49
|
+
* `<module>:<type>:<action>` keys project into a principal's grants at gate 5; without them a
|
|
50
|
+
* consumer type is `resource_not_registered`),
|
|
51
51
|
* - `operationManifest` — empty (identity declares no trusted delegated operations).
|
|
52
|
-
*
|
|
53
|
-
* `extraObjects` (optional): a CONSUMER app that registers its OWN gated object types via
|
|
54
|
-
* `EngineModule.forFeature([...])` must ALSO pass those types here, so their actions enter the
|
|
55
|
-
* coarse-grant permission-facts registry (gate 5). Without this, a custom type's routes boot
|
|
56
|
-
* and list-scope correctly (gate 6) but their `<module>:<type>:<action>` permission keys are
|
|
57
|
-
* never projected into a principal's grants — so gate 5 denies them (deny-by-default). Passing
|
|
58
|
-
* the same object types the app registered with the engine closes that gap: the app's grants on
|
|
59
|
-
* those keys now project, and the framework still owns the four record-tier adapters + scope fn.
|
|
60
|
-
*/
|
|
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
52
|
*/
|
|
70
53
|
function buildRecordAuthorizationInputs(pool, extraObjects = []) {
|
|
71
54
|
const registry = (0, guard_1.buildIdentityModuleRegistry)([...objects_js_1.identityObjects, ...extraObjects]);
|
|
@@ -77,40 +60,3 @@ function buildRecordAuthorizationInputs(pool, extraObjects = []) {
|
|
|
77
60
|
operationManifest: [],
|
|
78
61
|
};
|
|
79
62
|
}
|
|
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
|
-
}
|
|
@@ -37,6 +37,11 @@ export interface MemberDominance {
|
|
|
37
37
|
readonly roleIds: readonly string[];
|
|
38
38
|
}>): Promise<void>;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The actor's own root node (the active node its subtree hangs from). A non-root actor may not
|
|
42
|
+
* move, delete (F6) or change the status of (Phase 9 #13c) this node.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ownRootOf(actor: Principal): string;
|
|
40
45
|
export declare class CompanyHierarchyService {
|
|
41
46
|
private readonly pool;
|
|
42
47
|
private readonly closure;
|
|
@@ -47,8 +52,15 @@ export declare class CompanyHierarchyService {
|
|
|
47
52
|
constructor(pool: HierarchyQueryable, closure: ScopeClosureService,
|
|
48
53
|
/** Phase 8 #3: required — a company delete must dominate every member (no silent skip). */
|
|
49
54
|
ceiling: MemberDominance, audit?: AuditPort | undefined, now?: () => number);
|
|
50
|
-
/** The actor's own root node (
|
|
55
|
+
/** The actor's own root node (see {@link ownRootOf}). */
|
|
51
56
|
private ownRoot;
|
|
57
|
+
/**
|
|
58
|
+
* The company's uuid as stored (Phase 10 #1): `c_uuid` is `utf8mb4_unicode_ci`, so a route uuid
|
|
59
|
+
* in another letter case still reaches the row, while JS-side comparisons (own root, agent
|
|
60
|
+
* tenants, `JSON_CONTAINS` over scope lists, name maps) are exact. Every entry resolves the
|
|
61
|
+
* stored spelling once and passes that down. Unknown → the input unchanged (later steps 404).
|
|
62
|
+
*/
|
|
63
|
+
private storedUuid;
|
|
52
64
|
/** Names of the given companies (unknown → absent). */
|
|
53
65
|
private names;
|
|
54
66
|
private recordAudit;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"company-hierarchy.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company-hierarchy.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"company-hierarchy.service.d.ts","sourceRoot":"","sources":["../../../src/features/company/company-hierarchy.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,EAAE,mBAAmB,EAAkB,MAAM,mCAAmC,CAAC;AAWxF,6FAA6F;AAC7F,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,8BAA8B,CAC5B,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,aAAa,CAAC;QACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B,QAAQ,CAAC,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;QAChD,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;KACrC,CAAC,GACD,OAAO,CAAC,IAAI,CAAC,CAAC;CAClB;AAuCD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAElD;AAED,qBAAa,uBAAuB;IAEhC,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,2FAA2F;IAC3F,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBALH,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,mBAAmB;IAC7C,2FAA2F;IAC1E,OAAO,EAAE,eAAe,EACxB,KAAK,CAAC,EAAE,SAAS,YAAA,EACjB,GAAG,GAAE,MAAM,MAAiB;IAG/C,yDAAyD;IACzD,OAAO,CAAC,OAAO;IAIf;;;;;OAKG;YACW,UAAU;IAQxB,uDAAuD;YACzC,KAAK;YAUL,WAAW;IAqBzB,iGAAiG;IAC3F,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAoD3F;;;;;;;;;;;;;;;;;OAiBG;IACG,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAoB/D;;;;;;;;;;;;;;;;;;;;;;;OAuBG;YACW,gBAAgB;IAiF9B,0FAA0F;YAC5E,kBAAkB;IAmDhC,wFAAwF;YAC1E,sBAAsB;IA+BpC,wFAAwF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGhD"}
|