@astram/identity 0.4.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/company-membership.object.d.ts.map +1 -1
- package/dist/company-membership.object.js +3 -2
- package/dist/company.object.d.ts.map +1 -1
- package/dist/company.object.js +8 -3
- package/dist/features/access/index.d.ts.map +1 -1
- package/dist/features/access/index.js +1 -5
- package/dist/features/audit/audit-read.service.d.ts.map +1 -1
- package/dist/features/audit/audit-read.service.js +13 -6
- package/dist/features/audit/audit.controller.d.ts.map +1 -1
- package/dist/features/audit/audit.controller.js +2 -0
- package/dist/features/audit/mariadb-audit-reader.d.ts.map +1 -1
- package/dist/features/authz/access-bundle-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.d.ts.map +1 -1
- package/dist/features/authz/catalog-reconcile.job.js +1 -3
- package/dist/features/authz/db-permission-facts.adapter.d.ts +16 -0
- package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -1
- package/dist/features/authz/db-permission-facts.adapter.js +46 -2
- package/dist/features/authz/grant-admin.controller.d.ts +8 -5
- package/dist/features/authz/grant-admin.controller.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.controller.js +20 -18
- package/dist/features/authz/grant-admin.service.d.ts +72 -16
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +232 -86
- package/dist/features/authz/grant-ceiling.d.ts +100 -1
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +264 -2
- package/dist/features/authz/mariadb-catalog-store.d.ts.map +1 -1
- package/dist/features/authz/mariadb-catalog-store.js +15 -3
- package/dist/features/authz/permission-version.bumper.d.ts +52 -1
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
- package/dist/features/authz/permission-version.bumper.js +119 -20
- 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/authz/role-binding.d.ts +36 -0
- package/dist/features/authz/role-binding.d.ts.map +1 -0
- package/dist/features/authz/role-binding.js +53 -0
- package/dist/features/company/company-hierarchy.service.d.ts +108 -0
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -0
- package/dist/features/company/company-hierarchy.service.js +277 -0
- package/dist/features/company/company.controller.d.ts +23 -5
- package/dist/features/company/company.controller.d.ts.map +1 -1
- package/dist/features/company/company.controller.js +47 -12
- package/dist/features/company/company.service.d.ts +28 -12
- package/dist/features/company/company.service.d.ts.map +1 -1
- package/dist/features/company/company.service.js +61 -14
- package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -1
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js +2 -0
- 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 +72 -5
- package/dist/features/membership/membership.controller.d.ts +6 -5
- package/dist/features/membership/membership.controller.d.ts.map +1 -1
- package/dist/features/membership/membership.controller.js +34 -22
- package/dist/features/membership/membership.service.d.ts +20 -14
- package/dist/features/membership/membership.service.d.ts.map +1 -1
- package/dist/features/membership/membership.service.js +62 -29
- package/dist/features/membership/posting-version-bump.d.ts +12 -0
- package/dist/features/membership/posting-version-bump.d.ts.map +1 -1
- package/dist/features/membership/posting.service.d.ts +20 -24
- package/dist/features/membership/posting.service.d.ts.map +1 -1
- package/dist/features/membership/posting.service.js +101 -64
- package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.controller.js +2 -0
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
- package/dist/features/org-structure/org-catalog.service.js +4 -1
- package/dist/features/policy/policy.controller.d.ts.map +1 -1
- package/dist/features/policy/policy.controller.js +2 -0
- package/dist/features/role/role.controller.d.ts +3 -2
- package/dist/features/role/role.controller.d.ts.map +1 -1
- package/dist/features/role/role.controller.js +15 -10
- package/dist/features/role/role.service.d.ts +39 -8
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +77 -10
- package/dist/features/scope/scope-closure.service.d.ts +54 -4
- package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
- package/dist/features/scope/scope-closure.service.js +237 -85
- package/dist/features/service-account/service-account.controller.d.ts +3 -2
- package/dist/features/service-account/service-account.controller.d.ts.map +1 -1
- package/dist/features/service-account/service-account.controller.js +29 -15
- package/dist/features/service-account/service-account.service.d.ts +29 -7
- package/dist/features/service-account/service-account.service.d.ts.map +1 -1
- package/dist/features/service-account/service-account.service.js +73 -12
- 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/share/share.controller.d.ts.map +1 -1
- package/dist/features/share/share.controller.js +2 -0
- package/dist/features/share/share.service.d.ts.map +1 -1
- package/dist/features/shared/identity-error.interceptor.d.ts +24 -0
- package/dist/features/shared/identity-error.interceptor.d.ts.map +1 -0
- package/dist/features/shared/identity-error.interceptor.js +82 -0
- package/dist/features/shared/identity-errors.d.ts +23 -0
- package/dist/features/shared/identity-errors.d.ts.map +1 -0
- package/dist/features/shared/identity-errors.js +41 -0
- package/dist/features/user/session-revocation-check.d.ts +11 -0
- package/dist/features/user/session-revocation-check.d.ts.map +1 -0
- package/dist/features/user/session-revocation-check.js +21 -0
- package/dist/features/user/user.controller.d.ts +11 -6
- package/dist/features/user/user.controller.d.ts.map +1 -1
- package/dist/features/user/user.controller.js +35 -17
- package/dist/features/user/user.service.d.ts +55 -14
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +143 -16
- 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/dist/membership-posting.object.d.ts.map +1 -1
- package/dist/membership-posting.object.js +4 -2
- package/dist/objects.d.ts.map +1 -1
- package/dist/objects.js +13 -3
- package/dist/role.object.d.ts.map +1 -1
- package/dist/role.object.js +3 -0
- package/dist/user.object.d.ts.map +1 -1
- package/dist/user.object.js +3 -0
- package/migrations/1710000000019-AddSessionMustReenroll.cjs +35 -0
- package/package.json +15 -4
- package/dist/auth-facts-keys.js.map +0 -1
- package/dist/branch.object.js.map +0 -1
- package/dist/company-branch.object.js.map +0 -1
- package/dist/company-department.object.js.map +0 -1
- package/dist/company-membership.object.js.map +0 -1
- package/dist/company.object.js.map +0 -1
- package/dist/cross-tenant-grant.object.js.map +0 -1
- package/dist/department.object.js.map +0 -1
- package/dist/features/access/access-admin.access.js.map +0 -1
- package/dist/features/access/company-admin.access.js.map +0 -1
- package/dist/features/access/index.js.map +0 -1
- package/dist/features/access/user-management.access.js.map +0 -1
- package/dist/features/audit/audit-read.service.js.map +0 -1
- package/dist/features/audit/audit.controller.js.map +0 -1
- package/dist/features/audit/directory-label-resolver.js.map +0 -1
- package/dist/features/audit/mariadb-audit-reader.js.map +0 -1
- package/dist/features/authz/access-bundle-reconcile.job.js.map +0 -1
- package/dist/features/authz/access-bundle-store.js.map +0 -1
- package/dist/features/authz/catalog-bootstrap.provider.js.map +0 -1
- package/dist/features/authz/catalog-reconcile.job.js.map +0 -1
- package/dist/features/authz/db-permission-facts.adapter.js.map +0 -1
- package/dist/features/authz/grant-admin.controller.js.map +0 -1
- package/dist/features/authz/grant-admin.service.js.map +0 -1
- package/dist/features/authz/grant-ceiling.js.map +0 -1
- package/dist/features/authz/mariadb-catalog-store.js.map +0 -1
- package/dist/features/authz/permission-version.bumper.js.map +0 -1
- package/dist/features/authz/record-authorization.js.map +0 -1
- package/dist/features/authz/session-facts.reader.js.map +0 -1
- package/dist/features/company/company.controller.js.map +0 -1
- package/dist/features/company/company.service.js.map +0 -1
- package/dist/features/cross-tenant/cross-tenant-grant-read.service.js.map +0 -1
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js.map +0 -1
- package/dist/features/cross-tenant/grant-authorize.js.map +0 -1
- package/dist/features/identity-feature.module.js.map +0 -1
- package/dist/features/membership/membership-checker.js.map +0 -1
- package/dist/features/membership/membership.controller.js.map +0 -1
- package/dist/features/membership/membership.service.js.map +0 -1
- package/dist/features/membership/posting-version-bump.js.map +0 -1
- package/dist/features/membership/posting.service.js.map +0 -1
- package/dist/features/org-structure/org-catalog.controller.js.map +0 -1
- package/dist/features/org-structure/org-catalog.service.js.map +0 -1
- package/dist/features/policy/policy.controller.js.map +0 -1
- package/dist/features/policy/policy.service.js.map +0 -1
- package/dist/features/role/role.controller.js.map +0 -1
- package/dist/features/role/role.service.js.map +0 -1
- package/dist/features/scope/scope-closure.service.js.map +0 -1
- package/dist/features/scope/scope-subtree.resolver.js.map +0 -1
- package/dist/features/share/share.controller.js.map +0 -1
- package/dist/features/share/share.service.js.map +0 -1
- package/dist/features/shared/http.js.map +0 -1
- package/dist/features/shared/list-query.js.map +0 -1
- package/dist/features/shared/named-option.js.map +0 -1
- package/dist/features/shared/record-resolver.js.map +0 -1
- package/dist/features/user/user.controller.js.map +0 -1
- package/dist/features/user/user.service.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/membership-posting.object.js.map +0 -1
- package/dist/migrations-dir.js.map +0 -1
- package/dist/objects.js.map +0 -1
- package/dist/record-share.object.js.map +0 -1
- package/dist/role.object.js.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/shared-resource.object.js.map +0 -1
- package/dist/user.object.js.map +0 -1
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GrantCeiling = exports.GrantCeilingError = void 0;
|
|
4
|
+
exports.subtreeOf = subtreeOf;
|
|
4
5
|
exports.coversKey = coversKey;
|
|
5
6
|
const core_1 = require("@astram/core");
|
|
6
7
|
const db_permission_facts_adapter_js_1 = require("./db-permission-facts.adapter.js");
|
|
8
|
+
const role_binding_js_1 = require("./role-binding.js");
|
|
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");
|
|
7
11
|
const AUTHZ = 'shield_authz';
|
|
12
|
+
const APP = 'shield_app';
|
|
8
13
|
/** The grantor exceeds their own authority — cannot grant a key they do not hold (→ 403). */
|
|
9
14
|
class GrantCeilingError extends Error {
|
|
10
|
-
constructor(key) {
|
|
11
|
-
super(`You cannot grant '${key}' — it exceeds the permissions you hold.`);
|
|
15
|
+
constructor(key, message) {
|
|
16
|
+
super(message ?? `You cannot grant '${key}' — it exceeds the permissions you hold.`);
|
|
12
17
|
this.name = 'GrantCeilingError';
|
|
13
18
|
}
|
|
14
19
|
}
|
|
15
20
|
exports.GrantCeilingError = GrantCeilingError;
|
|
21
|
+
/** The actor's reachable subtree: the session's cached closure, else the self node (non-widening). */
|
|
22
|
+
function subtreeOf(actor) {
|
|
23
|
+
return actor.scopeSubtreeNodeIds?.length ? actor.scopeSubtreeNodeIds : [actor.tenantId];
|
|
24
|
+
}
|
|
16
25
|
/**
|
|
17
26
|
* Does `effective` (the grantor's held EXACT keys) cover `granted`?
|
|
18
27
|
*
|
|
@@ -44,12 +53,61 @@ class GrantCeiling {
|
|
|
44
53
|
pool;
|
|
45
54
|
registry;
|
|
46
55
|
now;
|
|
56
|
+
rootIds;
|
|
47
57
|
constructor(pool, registry,
|
|
58
|
+
/**
|
|
59
|
+
* The platform root allowlist (the SAME `rootIds` given to `ShieldModule.forRoot`) — REQUIRED,
|
|
60
|
+
* no default (security review F5): pass the array, or `null` to state explicitly that it is
|
|
61
|
+
* not configured. A root user is never dominated by a non-root actor; independently of this
|
|
62
|
+
* list, a user flagged `u_is_platform_superuser` in the database is never dominated either.
|
|
63
|
+
*/
|
|
64
|
+
rootIds,
|
|
48
65
|
/** Injectable clock (epoch-ms) for the posting effective-window read; defaults to Date.now. */
|
|
49
66
|
now = Date.now) {
|
|
50
67
|
this.pool = pool;
|
|
51
68
|
this.registry = registry;
|
|
52
69
|
this.now = now;
|
|
70
|
+
if (rootIds !== null && !Array.isArray(rootIds)) {
|
|
71
|
+
throw new TypeError('GrantCeiling: rootIds is required (the root allowlist, or null when none is configured)');
|
|
72
|
+
}
|
|
73
|
+
this.rootIds = rootIds ?? [];
|
|
74
|
+
}
|
|
75
|
+
/** The D1 administer rule over a binding read: used inside and nowhere outside (404 / 403). */
|
|
76
|
+
static checkAdministrable(b) {
|
|
77
|
+
if (!b.inside)
|
|
78
|
+
throw new identity_errors_js_1.IdentityNotFoundError('Role not found.');
|
|
79
|
+
if (b.outside) {
|
|
80
|
+
throw new identity_errors_js_1.IdentityForbiddenError('This role is also used outside the organisations you manage; only a platform administrator can change it.');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The #11 assign rule over a binding read: never a role also used outside (403). */
|
|
84
|
+
static checkAssignable(b) {
|
|
85
|
+
if (b.outside) {
|
|
86
|
+
throw new identity_errors_js_1.IdentityForbiddenError('This role is also used outside the organisations you manage; you cannot assign it.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The registry's exact-key universe (wildcard expansion bound). */
|
|
90
|
+
registeredKeys() {
|
|
91
|
+
return this.registry.listActions().map((a) => a.permissionKey);
|
|
92
|
+
}
|
|
93
|
+
/** The raw `role_permission` keys of `roleIds` (exact or wildcard). Store errors propagate. */
|
|
94
|
+
async rawKeysOfRoles(roleIds) {
|
|
95
|
+
if (roleIds.length === 0)
|
|
96
|
+
return [];
|
|
97
|
+
const ph = roleIds.map(() => '?').join(', ');
|
|
98
|
+
const rows = (await this.pool.query(`SELECT rp_perm_key FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid IN (${ph})`, [...roleIds]));
|
|
99
|
+
return rows.map((r) => r.rp_perm_key);
|
|
100
|
+
}
|
|
101
|
+
/** Run a store read, mapping any non-domain failure to AuthorizationUnavailableError (503). */
|
|
102
|
+
async facts(what, read) {
|
|
103
|
+
try {
|
|
104
|
+
return await read();
|
|
105
|
+
}
|
|
106
|
+
catch (cause) {
|
|
107
|
+
if (cause instanceof core_1.AuthorizationUnavailableError)
|
|
108
|
+
throw cause;
|
|
109
|
+
throw new core_1.AuthorizationUnavailableError(`${what} unavailable`, cause);
|
|
110
|
+
}
|
|
53
111
|
}
|
|
54
112
|
/**
|
|
55
113
|
* The grantor's EFFECTIVE exact keys: the keys granted to the grantor's roles (active
|
|
@@ -90,5 +148,209 @@ class GrantCeiling {
|
|
|
90
148
|
}
|
|
91
149
|
}
|
|
92
150
|
}
|
|
151
|
+
// ── Block A (security hardening Phase 1) ──────────────────────────────────────────────────
|
|
152
|
+
/**
|
|
153
|
+
* Assert the actor may ASSIGN every role in `roleIds` (post someone to it): the roles' effective
|
|
154
|
+
* keys (their `role_permission` rows, wildcards expanded against the registry) must all be held
|
|
155
|
+
* by the actor — the same "only grant what you hold" ceiling, applied to role assignment (#11).
|
|
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.
|
|
158
|
+
*/
|
|
159
|
+
async assertCanAssignRoles(actor, roleIds) {
|
|
160
|
+
if (actor.isRoot === true)
|
|
161
|
+
return;
|
|
162
|
+
const ids = [...new Set(roleIds)];
|
|
163
|
+
if (ids.length === 0)
|
|
164
|
+
return;
|
|
165
|
+
const raw = await this.facts('role grants', () => this.rawKeysOfRoles(ids));
|
|
166
|
+
if (raw.length === 0)
|
|
167
|
+
return;
|
|
168
|
+
const effective = await this.effectiveKeys(actor);
|
|
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
|
+
}
|
|
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
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Assert the actor DOMINATES the target user (#3, D2): every membership the target holds (in any
|
|
187
|
+
* tenant, active or not) is inside the actor's subtree, AND every key the target could hold
|
|
188
|
+
* through any of its postings (any membership, any window — potential authority, not just what is
|
|
189
|
+
* in effect now) is held by the actor. A platform root target is never dominated by a non-root.
|
|
190
|
+
* Root actors bypass. Store outage → 503.
|
|
191
|
+
*/
|
|
192
|
+
async assertDominates(actor, targetUserId) {
|
|
193
|
+
if (actor.isRoot === true)
|
|
194
|
+
return;
|
|
195
|
+
if (this.rootIds.includes(targetUserId)) {
|
|
196
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they are a platform administrator.');
|
|
197
|
+
}
|
|
198
|
+
const subtree = new Set(subtreeOf(actor));
|
|
199
|
+
const { companies, roleIds, superuser } = await this.facts('target memberships', async () => {
|
|
200
|
+
const su = (await this.pool.query(`SELECT u_is_platform_superuser AS su FROM \`${APP}\`.\`users\` WHERE u_uuid = ? LIMIT 1`, [targetUserId]));
|
|
201
|
+
const memberships = (await this.pool.query(`SELECT DISTINCT cm_company_uuid AS company
|
|
202
|
+
FROM \`${APP}\`.\`company_membership\`
|
|
203
|
+
WHERE cm_user_uuid = ?`, [targetUserId]));
|
|
204
|
+
const postings = (await this.pool.query(`SELECT DISTINCT mp.mp_role_uuid AS role_uuid
|
|
205
|
+
FROM \`${APP}\`.\`membership_posting\` mp
|
|
206
|
+
JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
|
|
207
|
+
WHERE cm.cm_user_uuid = ?`, [targetUserId]));
|
|
208
|
+
return {
|
|
209
|
+
companies: memberships.map((m) => m.company),
|
|
210
|
+
roleIds: postings.map((p) => p.role_uuid),
|
|
211
|
+
superuser: Number(su[0]?.su ?? 0) === 1,
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
if (superuser) {
|
|
215
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they are a platform administrator.');
|
|
216
|
+
}
|
|
217
|
+
// Security review F5: a user with NO membership is in nobody's subtree — refuse like the
|
|
218
|
+
// walled read does (404), never "vacuously dominated".
|
|
219
|
+
if (companies.length === 0)
|
|
220
|
+
throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
|
|
221
|
+
if (companies.some((c) => !subtree.has(c))) {
|
|
222
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they also belong to an organisation outside the ones you manage.');
|
|
223
|
+
}
|
|
224
|
+
const raw = await this.facts('target grants', () => this.rawKeysOfRoles(roleIds));
|
|
225
|
+
if (raw.length === 0)
|
|
226
|
+
return;
|
|
227
|
+
const effective = await this.effectiveKeys(actor);
|
|
228
|
+
// Phase 8 #4: an unknown target key counts as not held (see `firstUncovered`).
|
|
229
|
+
if (this.firstUncovered(effective, raw) !== undefined) {
|
|
230
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they hold permissions you do not hold.');
|
|
231
|
+
}
|
|
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
|
+
}
|
|
330
|
+
/** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
|
|
331
|
+
async roleBinding(actor, roleUuid) {
|
|
332
|
+
return this.facts('role binding', () => (0, role_binding_js_1.readRoleBinding)(this.pool, roleUuid, subtreeOf(actor)));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Assert the actor may ADMINISTER the role — edit its grants, rename or delete it (#1, D1
|
|
336
|
+
* interim rule): the role is bound inside the actor's subtree AND bound nowhere outside it. A
|
|
337
|
+
* role not bound in the subtree is "not found" for the actor (no existence leak); a role also
|
|
338
|
+
* used outside (a shared role) is root-only. Root bypasses.
|
|
339
|
+
*/
|
|
340
|
+
async assertRoleAdministrable(actor, roleUuid) {
|
|
341
|
+
if (actor.isRoot === true)
|
|
342
|
+
return;
|
|
343
|
+
GrantCeiling.checkAdministrable(await this.roleBinding(actor, roleUuid));
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Assert the actor may POST someone to the role (#11): the role is bound within the actor's
|
|
347
|
+
* subtree or not bound at all — never a role also used outside it (a shared role's assignment
|
|
348
|
+
* would widen another tenant's role). Pair with {@link assertCanAssignRoles}. Root bypasses.
|
|
349
|
+
*/
|
|
350
|
+
async assertRoleAssignable(actor, roleUuid) {
|
|
351
|
+
if (actor.isRoot === true)
|
|
352
|
+
return;
|
|
353
|
+
GrantCeiling.checkAssignable(await this.roleBinding(actor, roleUuid));
|
|
354
|
+
}
|
|
93
355
|
}
|
|
94
356
|
exports.GrantCeiling = GrantCeiling;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mariadb-catalog-store.d.ts","sourceRoot":"","sources":["../../../src/features/authz/mariadb-catalog-store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAIxE;;;;GAIG;AACH,qBAAa,mBAAoB,YAAW,YAAY;IAC1C,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEjC,eAAe,IAAI,OAAO,CAAC,oBAAoB,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"mariadb-catalog-store.d.ts","sourceRoot":"","sources":["../../../src/features/authz/mariadb-catalog-store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAIxE;;;;GAIG;AACH,qBAAa,mBAAoB,YAAW,YAAY;IAC1C,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEjC,eAAe,IAAI,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAwBlD,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,oBAAoB,EAAE,QAAQ,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;IAmB7E,YAAY,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IASpD,UAAU,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;CAQzD"}
|
|
@@ -16,8 +16,12 @@ class MariaDbCatalogStore {
|
|
|
16
16
|
const rows = (await this.pool.query(`SELECT perm_key, perm_module, perm_type_key, perm_action, perm_label, perm_tier, perm_status
|
|
17
17
|
FROM \`${AUTHZ}\`.\`permission\``));
|
|
18
18
|
return rows.map((r) => ({
|
|
19
|
-
permissionKey: r.perm_key,
|
|
20
|
-
|
|
19
|
+
permissionKey: r.perm_key,
|
|
20
|
+
moduleKey: r.perm_module,
|
|
21
|
+
typeKey: r.perm_type_key,
|
|
22
|
+
action: r.perm_action,
|
|
23
|
+
label: r.perm_label,
|
|
24
|
+
tier: r.perm_tier,
|
|
21
25
|
status: r.perm_status === 'orphaned' ? 'orphaned' : 'active',
|
|
22
26
|
}));
|
|
23
27
|
}
|
|
@@ -25,7 +29,15 @@ class MariaDbCatalogStore {
|
|
|
25
29
|
const res = (await this.pool.query(`INSERT INTO \`${AUTHZ}\`.\`permission\`
|
|
26
30
|
(perm_uuid, perm_key, perm_module, perm_type_key, perm_action, perm_label, perm_tier, perm_status)
|
|
27
31
|
SELECT UUID(), ?, ?, ?, ?, ?, ?, 'active'
|
|
28
|
-
WHERE NOT EXISTS (SELECT 1 FROM \`${AUTHZ}\`.\`permission\` WHERE perm_key = ?)`, [
|
|
32
|
+
WHERE NOT EXISTS (SELECT 1 FROM \`${AUTHZ}\`.\`permission\` WHERE perm_key = ?)`, [
|
|
33
|
+
row.permissionKey,
|
|
34
|
+
row.moduleKey,
|
|
35
|
+
row.typeKey,
|
|
36
|
+
row.action,
|
|
37
|
+
row.label,
|
|
38
|
+
row.tier,
|
|
39
|
+
row.permissionKey,
|
|
40
|
+
]));
|
|
29
41
|
return Number(res?.affectedRows ?? 0) > 0;
|
|
30
42
|
}
|
|
31
43
|
async markOrphaned(keys) {
|
|
@@ -20,7 +20,37 @@ export interface Queryable {
|
|
|
20
20
|
* - `bumpForRole` wraps a failure in `AuthorizationUnavailableError` and THROWS (see its own
|
|
21
21
|
* doc comment below) — do not assume it is also fail-safe.
|
|
22
22
|
*/
|
|
23
|
+
/**
|
|
24
|
+
* Where a committed version is published (ADR-0035, GAPS #6) — `@astram/guard`'s
|
|
25
|
+
* `RedisPermissionVersionCache` satisfies it structurally (monotonic, TTL'd, never throws).
|
|
26
|
+
*/
|
|
27
|
+
export interface VersionPublisher {
|
|
28
|
+
publish(tenantUuid: string, version: number): Promise<void>;
|
|
29
|
+
}
|
|
23
30
|
export declare class PermissionVersionBumper {
|
|
31
|
+
private publisher?;
|
|
32
|
+
/**
|
|
33
|
+
* Bumped versions waiting for their transaction to commit, per connection (ADR-0035). A
|
|
34
|
+
* transaction connection is a pooled object reused across requests, so callers either publish
|
|
35
|
+
* (`publishCommitted`, after `commit()`) or drop (`discardPending`, after `rollback()`) them.
|
|
36
|
+
*/
|
|
37
|
+
private readonly pending;
|
|
38
|
+
private readonly logger;
|
|
39
|
+
/**
|
|
40
|
+
* @param publisher OPTIONAL Redis publisher (ADR-0035). Absent → DB-only bumps, exactly as
|
|
41
|
+
* before (no read-back). `IdentityFeatureModule` attaches the guard's cache through DI.
|
|
42
|
+
*/
|
|
43
|
+
constructor(publisher?: VersionPublisher | undefined);
|
|
44
|
+
/** Attach the Redis publisher once it is resolved through DI (first attach wins). */
|
|
45
|
+
attachVersionPublisher(publisher: VersionPublisher): void;
|
|
46
|
+
/**
|
|
47
|
+
* Publish the versions this transaction connection bumped — call right AFTER `commit()`.
|
|
48
|
+
* Best-effort: a failure is logged (no tenant id) and swallowed; the key TTL bounds the delay.
|
|
49
|
+
*/
|
|
50
|
+
publishCommitted(conn: Queryable): Promise<void>;
|
|
51
|
+
/** Drop the versions a ROLLED-BACK transaction bumped (nothing was committed). */
|
|
52
|
+
discardPending(conn: Queryable): void;
|
|
53
|
+
private safePublish;
|
|
24
54
|
/**
|
|
25
55
|
* Increment the permission version for a SINGLE tenant — for events that change a
|
|
26
56
|
* principal's EFFECTIVE grants without touching `role_permission` directly (a
|
|
@@ -41,7 +71,28 @@ export declare class PermissionVersionBumper {
|
|
|
41
71
|
* so both roll back together).
|
|
42
72
|
*/
|
|
43
73
|
bumpForRole(roleUuid: string, conn: Queryable): Promise<string[]>;
|
|
44
|
-
/**
|
|
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[]>;
|
|
81
|
+
/**
|
|
82
|
+
* Increment the permission version for EVERY tenant where the user has a membership (any status)
|
|
83
|
+
* — for a change to the USER itself that alters its effective grants everywhere (security
|
|
84
|
+
* hardening Phase 3: disabling or re-enabling an account). Returns the bumped tenants. Like
|
|
85
|
+
* `bumpForRole`, a failure is wrapped in `AuthorizationUnavailableError` and THROWS: a silently
|
|
86
|
+
* missed bump would leave a disabled user's cached grants honoured.
|
|
87
|
+
*/
|
|
88
|
+
bumpForUser(userUuid: string, conn: Queryable): Promise<string[]>;
|
|
89
|
+
/**
|
|
90
|
+
* The raw per-tenant increment (INSERT-or-increment). With a publisher attached (ADR-0035) the
|
|
91
|
+
* new version is read back on the SAME connection (inside a transaction that is the
|
|
92
|
+
* transaction's own value) and published to Redis AFTER the commit: at once over the autocommit
|
|
93
|
+
* pool, at `publishCommitted(conn)` for a transaction. A read-back failure skips the publish
|
|
94
|
+
* (the TTL bounds it) and never fails the bump.
|
|
95
|
+
*/
|
|
45
96
|
private bumpTenant;
|
|
46
97
|
}
|
|
47
98
|
//# sourceMappingURL=permission-version.bumper.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permission-version.bumper.d.ts","sourceRoot":"","sources":["../../../src/features/authz/permission-version.bumper.ts"],"names":[],"mappings":"
|
|
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"}
|
|
@@ -1,26 +1,64 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.PermissionVersionBumper = void 0;
|
|
4
|
+
const common_1 = require("@nestjs/common");
|
|
4
5
|
const core_1 = require("@astram/core");
|
|
5
6
|
/** Schema the `authz_permission_version` table lives in (ADR-0011). */
|
|
6
7
|
const AUTHZ = 'shield_authz';
|
|
7
8
|
/** Schema the membership/posting tables live in — the join `bumpForRole` walks. */
|
|
8
9
|
const APP = 'shield_app';
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* INSERT-or-increment over a supplied connection (the sealed authz pool, or a transaction
|
|
14
|
-
* connection so the bump joins the caller's write). Fail-safe: the caller wraps a bump failure
|
|
15
|
-
* so it never fails the membership/posting write. Never logs the tenant or the query.
|
|
16
|
-
*
|
|
17
|
-
* NOTE — the two bump methods do NOT share an error contract:
|
|
18
|
-
* - `bumpForTenant` propagates the raw underlying error unwrapped; it is up to ITS caller
|
|
19
|
-
* (the Phase 3 membership/posting write) to swallow it fail-safe, per the paragraph above.
|
|
20
|
-
* - `bumpForRole` wraps a failure in `AuthorizationUnavailableError` and THROWS (see its own
|
|
21
|
-
* doc comment below) — do not assume it is also fail-safe.
|
|
22
|
-
*/
|
|
10
|
+
/** An autocommit pool (it hands out connections) rather than one transaction's connection. */
|
|
11
|
+
function isAutocommitPool(conn) {
|
|
12
|
+
return typeof conn.getConnection === 'function';
|
|
13
|
+
}
|
|
23
14
|
class PermissionVersionBumper {
|
|
15
|
+
publisher;
|
|
16
|
+
/**
|
|
17
|
+
* Bumped versions waiting for their transaction to commit, per connection (ADR-0035). A
|
|
18
|
+
* transaction connection is a pooled object reused across requests, so callers either publish
|
|
19
|
+
* (`publishCommitted`, after `commit()`) or drop (`discardPending`, after `rollback()`) them.
|
|
20
|
+
*/
|
|
21
|
+
pending = new WeakMap();
|
|
22
|
+
logger = new common_1.Logger('PermissionVersionBumper');
|
|
23
|
+
/**
|
|
24
|
+
* @param publisher OPTIONAL Redis publisher (ADR-0035). Absent → DB-only bumps, exactly as
|
|
25
|
+
* before (no read-back). `IdentityFeatureModule` attaches the guard's cache through DI.
|
|
26
|
+
*/
|
|
27
|
+
constructor(publisher) {
|
|
28
|
+
this.publisher = publisher;
|
|
29
|
+
}
|
|
30
|
+
/** Attach the Redis publisher once it is resolved through DI (first attach wins). */
|
|
31
|
+
attachVersionPublisher(publisher) {
|
|
32
|
+
if (this.publisher === undefined)
|
|
33
|
+
this.publisher = publisher;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Publish the versions this transaction connection bumped — call right AFTER `commit()`.
|
|
37
|
+
* Best-effort: a failure is logged (no tenant id) and swallowed; the key TTL bounds the delay.
|
|
38
|
+
*/
|
|
39
|
+
async publishCommitted(conn) {
|
|
40
|
+
const versions = this.pending.get(conn);
|
|
41
|
+
if (versions === undefined)
|
|
42
|
+
return;
|
|
43
|
+
this.pending.delete(conn);
|
|
44
|
+
for (const [tenantUuid, version] of versions)
|
|
45
|
+
await this.safePublish(tenantUuid, version);
|
|
46
|
+
}
|
|
47
|
+
/** Drop the versions a ROLLED-BACK transaction bumped (nothing was committed). */
|
|
48
|
+
discardPending(conn) {
|
|
49
|
+
this.pending.delete(conn);
|
|
50
|
+
}
|
|
51
|
+
async safePublish(tenantUuid, version) {
|
|
52
|
+
const publisher = this.publisher;
|
|
53
|
+
if (publisher === undefined)
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
await publisher.publish(tenantUuid, version);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
this.logger.warn('permission version publish failed; cached sessions refresh within the TTL');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
24
62
|
/**
|
|
25
63
|
* Increment the permission version for a SINGLE tenant — for events that change a
|
|
26
64
|
* principal's EFFECTIVE grants without touching `role_permission` directly (a
|
|
@@ -44,11 +82,7 @@ class PermissionVersionBumper {
|
|
|
44
82
|
*/
|
|
45
83
|
async bumpForRole(roleUuid, conn) {
|
|
46
84
|
try {
|
|
47
|
-
const
|
|
48
|
-
FROM \`${APP}\`.\`company_membership\` cm
|
|
49
|
-
JOIN \`${APP}\`.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
|
|
50
|
-
WHERE mp.mp_role_uuid = ? AND cm.cm_is_active = 1`, [roleUuid]));
|
|
51
|
-
const tenants = rows.map((r) => r.tenant);
|
|
85
|
+
const tenants = await this.tenantsForRole(roleUuid, conn);
|
|
52
86
|
for (const tenant of tenants) {
|
|
53
87
|
await this.bumpTenant(tenant, conn);
|
|
54
88
|
}
|
|
@@ -58,11 +92,76 @@ class PermissionVersionBumper {
|
|
|
58
92
|
throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
|
|
59
93
|
}
|
|
60
94
|
}
|
|
61
|
-
/**
|
|
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
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Increment the permission version for EVERY tenant where the user has a membership (any status)
|
|
115
|
+
* — for a change to the USER itself that alters its effective grants everywhere (security
|
|
116
|
+
* hardening Phase 3: disabling or re-enabling an account). Returns the bumped tenants. Like
|
|
117
|
+
* `bumpForRole`, a failure is wrapped in `AuthorizationUnavailableError` and THROWS: a silently
|
|
118
|
+
* missed bump would leave a disabled user's cached grants honoured.
|
|
119
|
+
*/
|
|
120
|
+
async bumpForUser(userUuid, conn) {
|
|
121
|
+
try {
|
|
122
|
+
const rows = (await conn.query(`SELECT DISTINCT cm.cm_company_uuid AS tenant
|
|
123
|
+
FROM \`${APP}\`.\`company_membership\` cm
|
|
124
|
+
WHERE cm.cm_user_uuid = ?`, [userUuid]));
|
|
125
|
+
const tenants = [...new Set(rows.map((r) => r.tenant))];
|
|
126
|
+
for (const tenant of tenants)
|
|
127
|
+
await this.bumpTenant(tenant, conn);
|
|
128
|
+
return tenants;
|
|
129
|
+
}
|
|
130
|
+
catch (cause) {
|
|
131
|
+
throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The raw per-tenant increment (INSERT-or-increment). With a publisher attached (ADR-0035) the
|
|
136
|
+
* new version is read back on the SAME connection (inside a transaction that is the
|
|
137
|
+
* transaction's own value) and published to Redis AFTER the commit: at once over the autocommit
|
|
138
|
+
* pool, at `publishCommitted(conn)` for a transaction. A read-back failure skips the publish
|
|
139
|
+
* (the TTL bounds it) and never fails the bump.
|
|
140
|
+
*/
|
|
62
141
|
async bumpTenant(tenantUuid, conn) {
|
|
63
142
|
await conn.query(`INSERT INTO \`${AUTHZ}\`.\`authz_permission_version\` (apv_tenant_uuid, apv_version)
|
|
64
143
|
VALUES (?, 1)
|
|
65
144
|
ON DUPLICATE KEY UPDATE apv_version = apv_version + 1`, [tenantUuid]);
|
|
145
|
+
if (this.publisher === undefined)
|
|
146
|
+
return;
|
|
147
|
+
let version;
|
|
148
|
+
try {
|
|
149
|
+
const rows = (await conn.query(`SELECT apv_version FROM \`${AUTHZ}\`.\`authz_permission_version\`
|
|
150
|
+
WHERE apv_tenant_uuid = ? LIMIT 1`, [tenantUuid]));
|
|
151
|
+
version = Number(rows[0]?.apv_version);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!Number.isSafeInteger(version) || version < 0)
|
|
157
|
+
return;
|
|
158
|
+
if (isAutocommitPool(conn)) {
|
|
159
|
+
await this.safePublish(tenantUuid, version);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const versions = this.pending.get(conn) ?? new Map();
|
|
163
|
+
versions.set(tenantUuid, Math.max(version, versions.get(tenantUuid) ?? 0));
|
|
164
|
+
this.pending.set(conn, versions);
|
|
66
165
|
}
|
|
67
166
|
}
|
|
68
167
|
exports.PermissionVersionBumper = PermissionVersionBumper;
|
|
@@ -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,
|
|
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"}
|