@astram/identity 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +24 -4
- package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -1
- package/dist/features/authz/db-permission-facts.adapter.js +92 -5
- 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 +22 -15
- package/dist/features/authz/grant-admin.service.d.ts +57 -11
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +163 -85
- package/dist/features/authz/grant-ceiling.d.ts +111 -0
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -0
- package/dist/features/authz/grant-ceiling.js +248 -0
- 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 +45 -1
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -1
- package/dist/features/authz/permission-version.bumper.js +106 -16
- package/dist/features/authz/record-authorization.d.ts.map +1 -1
- 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 +46 -0
- package/dist/features/company/company-hierarchy.service.d.ts.map +1 -0
- package/dist/features/company/company-hierarchy.service.js +100 -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.map +1 -1
- package/dist/features/identity-feature.module.js +123 -6
- 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 +5 -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 +16 -8
- package/dist/features/role/role.service.d.ts.map +1 -1
- package/dist/features/role/role.service.js +24 -10
- package/dist/features/scope/scope-closure.service.d.ts +44 -4
- package/dist/features/scope/scope-closure.service.d.ts.map +1 -1
- package/dist/features/scope/scope-closure.service.js +215 -82
- package/dist/features/service-account/service-account.controller.d.ts +63 -0
- package/dist/features/service-account/service-account.controller.d.ts.map +1 -0
- package/dist/features/service-account/service-account.controller.js +295 -0
- package/dist/features/service-account/service-account.service.d.ts +209 -0
- package/dist/features/service-account/service-account.service.d.ts.map +1 -0
- package/dist/features/service-account/service-account.service.js +528 -0
- package/dist/features/service-account/service-account.store.d.ts +91 -0
- package/dist/features/service-account/service-account.store.d.ts.map +1 -0
- package/dist/features/service-account/service-account.store.js +158 -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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- 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 +14 -3
- package/dist/role.object.d.ts.map +1 -1
- package/dist/role.object.js +3 -0
- package/dist/service-account.object.d.ts +31 -0
- package/dist/service-account.object.d.ts.map +1 -0
- package/dist/service-account.object.js +75 -0
- package/dist/user.object.d.ts.map +1 -1
- package/dist/user.object.js +3 -0
- package/migrations/1710000000018-CreateServiceAccount.cjs +53 -0
- package/migrations/1710000000019-AddSessionMustReenroll.cjs +35 -0
- package/package.json +4 -4
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GrantCeiling = exports.GrantCeilingError = void 0;
|
|
4
|
+
exports.subtreeOf = subtreeOf;
|
|
5
|
+
exports.coversKey = coversKey;
|
|
6
|
+
const core_1 = require("@astram/core");
|
|
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 AUTHZ = 'shield_authz';
|
|
11
|
+
const APP = 'shield_app';
|
|
12
|
+
/** The grantor exceeds their own authority — cannot grant a key they do not hold (→ 403). */
|
|
13
|
+
class GrantCeilingError extends Error {
|
|
14
|
+
constructor(key, message) {
|
|
15
|
+
super(message ?? `You cannot grant '${key}' — it exceeds the permissions you hold.`);
|
|
16
|
+
this.name = 'GrantCeilingError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
exports.GrantCeilingError = GrantCeilingError;
|
|
20
|
+
/** The actor's reachable subtree: the session's cached closure, else the self node (non-widening). */
|
|
21
|
+
function subtreeOf(actor) {
|
|
22
|
+
return actor.scopeSubtreeNodeIds?.length ? actor.scopeSubtreeNodeIds : [actor.tenantId];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Does `effective` (the grantor's held EXACT keys) cover `granted`?
|
|
26
|
+
*
|
|
27
|
+
* - An EXACT key is covered iff it is in `effective`.
|
|
28
|
+
* - A WILDCARD key (`module:*`, `module:type:*`, bare `module`) is covered iff EVERY registered
|
|
29
|
+
* exact key it expands to is in `effective` — you cannot grant a wildcard you don't fully hold.
|
|
30
|
+
* A wildcard that expands to ZERO registered keys is treated as NOT covered (nothing to hold ⇒
|
|
31
|
+
* no basis to grant; also prevents a typo'd wildcard from vacuously passing).
|
|
32
|
+
*
|
|
33
|
+
* `effective` is assumed already expanded to exact keys (the grantor's wildcards resolved).
|
|
34
|
+
* PURE — no I/O; `registeredKeys` is the live registry's exact-key universe.
|
|
35
|
+
*/
|
|
36
|
+
function coversKey(effective, registeredKeys, granted) {
|
|
37
|
+
const isWildcard = granted.endsWith(':*') || !granted.includes(':');
|
|
38
|
+
if (!isWildcard) {
|
|
39
|
+
return effective.has(granted);
|
|
40
|
+
}
|
|
41
|
+
// Expand the granted wildcard against the SAME registry universe, then require full coverage.
|
|
42
|
+
const expanded = (0, db_permission_facts_adapter_js_1.expandGrantKeys)([granted], [...registeredKeys]);
|
|
43
|
+
if (expanded.length === 0)
|
|
44
|
+
return false;
|
|
45
|
+
return expanded.every((k) => effective.has(k));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolves a grantor's effective keys and enforces the ADR-0029 ceiling. Constructed in the
|
|
49
|
+
* identity module over the shared authz pool + the identity registry.
|
|
50
|
+
*/
|
|
51
|
+
class GrantCeiling {
|
|
52
|
+
pool;
|
|
53
|
+
registry;
|
|
54
|
+
now;
|
|
55
|
+
rootIds;
|
|
56
|
+
constructor(pool, registry,
|
|
57
|
+
/**
|
|
58
|
+
* The platform root allowlist (the SAME `rootIds` given to `ShieldModule.forRoot`) — REQUIRED,
|
|
59
|
+
* no default (security review F5): pass the array, or `null` to state explicitly that it is
|
|
60
|
+
* not configured. A root user is never dominated by a non-root actor; independently of this
|
|
61
|
+
* list, a user flagged `u_is_platform_superuser` in the database is never dominated either.
|
|
62
|
+
*/
|
|
63
|
+
rootIds,
|
|
64
|
+
/** Injectable clock (epoch-ms) for the posting effective-window read; defaults to Date.now. */
|
|
65
|
+
now = Date.now) {
|
|
66
|
+
this.pool = pool;
|
|
67
|
+
this.registry = registry;
|
|
68
|
+
this.now = now;
|
|
69
|
+
if (rootIds !== null && !Array.isArray(rootIds)) {
|
|
70
|
+
throw new TypeError('GrantCeiling: rootIds is required (the root allowlist, or null when none is configured)');
|
|
71
|
+
}
|
|
72
|
+
this.rootIds = rootIds ?? [];
|
|
73
|
+
}
|
|
74
|
+
/** The D1 administer rule over a binding read: used inside and nowhere outside (404 / 403). */
|
|
75
|
+
static checkAdministrable(b) {
|
|
76
|
+
if (!b.inside)
|
|
77
|
+
throw new identity_errors_js_1.IdentityNotFoundError('Role not found.');
|
|
78
|
+
if (b.outside) {
|
|
79
|
+
throw new identity_errors_js_1.IdentityForbiddenError('This role is also used outside the organisations you manage; only a platform administrator can change it.');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** The #11 assign rule over a binding read: never a role also used outside (403). */
|
|
83
|
+
static checkAssignable(b) {
|
|
84
|
+
if (b.outside) {
|
|
85
|
+
throw new identity_errors_js_1.IdentityForbiddenError('This role is also used outside the organisations you manage; you cannot assign it.');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** The registry's exact-key universe (wildcard expansion bound). */
|
|
89
|
+
registeredKeys() {
|
|
90
|
+
return this.registry.listActions().map((a) => a.permissionKey);
|
|
91
|
+
}
|
|
92
|
+
/** The raw `role_permission` keys of `roleIds` (exact or wildcard). Store errors propagate. */
|
|
93
|
+
async rawKeysOfRoles(roleIds) {
|
|
94
|
+
if (roleIds.length === 0)
|
|
95
|
+
return [];
|
|
96
|
+
const ph = roleIds.map(() => '?').join(', ');
|
|
97
|
+
const rows = (await this.pool.query(`SELECT rp_perm_key FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid IN (${ph})`, [...roleIds]));
|
|
98
|
+
return rows.map((r) => r.rp_perm_key);
|
|
99
|
+
}
|
|
100
|
+
/** Run a store read, mapping any non-domain failure to AuthorizationUnavailableError (503). */
|
|
101
|
+
async facts(what, read) {
|
|
102
|
+
try {
|
|
103
|
+
return await read();
|
|
104
|
+
}
|
|
105
|
+
catch (cause) {
|
|
106
|
+
if (cause instanceof core_1.AuthorizationUnavailableError)
|
|
107
|
+
throw cause;
|
|
108
|
+
throw new core_1.AuthorizationUnavailableError(`${what} unavailable`, cause);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The grantor's EFFECTIVE exact keys: the keys granted to the grantor's roles (active
|
|
113
|
+
* membership's in-effect postings in the grantor's tenant), wildcards expanded against the
|
|
114
|
+
* live registry. Fail-closed: a store outage throws AuthorizationUnavailableError (never an
|
|
115
|
+
* empty set masking an outage).
|
|
116
|
+
*/
|
|
117
|
+
async effectiveKeys(grantor) {
|
|
118
|
+
try {
|
|
119
|
+
const roleIds = await (0, db_permission_facts_adapter_js_1.readRoleIdsForTenant)(this.pool, grantor.id, grantor.tenantId, this.now);
|
|
120
|
+
if (roleIds.length === 0)
|
|
121
|
+
return new Set();
|
|
122
|
+
const ph = roleIds.map(() => '?').join(', ');
|
|
123
|
+
const rows = (await this.pool.query(`SELECT rp_perm_key FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid IN (${ph})`, roleIds));
|
|
124
|
+
const raw = rows.map((r) => r.rp_perm_key);
|
|
125
|
+
const registered = this.registry.listActions().map((a) => a.permissionKey);
|
|
126
|
+
return new Set((0, db_permission_facts_adapter_js_1.expandGrantKeys)(raw, registered));
|
|
127
|
+
}
|
|
128
|
+
catch (cause) {
|
|
129
|
+
if (cause instanceof core_1.AuthorizationUnavailableError)
|
|
130
|
+
throw cause;
|
|
131
|
+
throw new core_1.AuthorizationUnavailableError('grant-ceiling facts unavailable', cause);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Assert the grantor may grant EVERY key in `keys`. Root (strict) bypasses. A non-root grantor
|
|
136
|
+
* must effectively hold each key (exact) or fully hold each wildcard's expansion. Throws
|
|
137
|
+
* `GrantCeilingError` on the FIRST key that exceeds the grantor's authority.
|
|
138
|
+
*/
|
|
139
|
+
async assertCanGrant(grantor, keys) {
|
|
140
|
+
if (grantor.isRoot === true)
|
|
141
|
+
return; // strict root bypass
|
|
142
|
+
const effective = await this.effectiveKeys(grantor);
|
|
143
|
+
const registered = this.registry.listActions().map((a) => a.permissionKey);
|
|
144
|
+
for (const key of keys) {
|
|
145
|
+
if (!coversKey(effective, registered, key)) {
|
|
146
|
+
throw new GrantCeilingError(key);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// ── Block A (security hardening Phase 1) ──────────────────────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* Assert the actor may ASSIGN every role in `roleIds` (post someone to it): the roles' effective
|
|
153
|
+
* keys (their `role_permission` rows, wildcards expanded against the registry) must all be held
|
|
154
|
+
* by the actor — the same "only grant what you hold" ceiling, applied to role assignment (#11).
|
|
155
|
+
* A role holding nothing assigns nothing and passes. Root bypasses. Store outage → 503.
|
|
156
|
+
*/
|
|
157
|
+
async assertCanAssignRoles(actor, roleIds) {
|
|
158
|
+
if (actor.isRoot === true)
|
|
159
|
+
return;
|
|
160
|
+
const ids = [...new Set(roleIds)];
|
|
161
|
+
if (ids.length === 0)
|
|
162
|
+
return;
|
|
163
|
+
const raw = await this.facts('role grants', () => this.rawKeysOfRoles(ids));
|
|
164
|
+
const roleKeys = (0, db_permission_facts_adapter_js_1.expandGrantKeys)(raw, this.registeredKeys());
|
|
165
|
+
if (roleKeys.length === 0)
|
|
166
|
+
return;
|
|
167
|
+
const effective = await this.effectiveKeys(actor);
|
|
168
|
+
for (const key of roleKeys) {
|
|
169
|
+
if (!effective.has(key)) {
|
|
170
|
+
throw new GrantCeilingError(key, `You cannot assign this role — it grants '${key}', which exceeds the permissions you hold.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Assert the actor DOMINATES the target user (#3, D2): every membership the target holds (in any
|
|
176
|
+
* tenant, active or not) is inside the actor's subtree, AND every key the target could hold
|
|
177
|
+
* through any of its postings (any membership, any window — potential authority, not just what is
|
|
178
|
+
* in effect now) is held by the actor. A platform root target is never dominated by a non-root.
|
|
179
|
+
* Root actors bypass. Store outage → 503.
|
|
180
|
+
*/
|
|
181
|
+
async assertDominates(actor, targetUserId) {
|
|
182
|
+
if (actor.isRoot === true)
|
|
183
|
+
return;
|
|
184
|
+
if (this.rootIds.includes(targetUserId)) {
|
|
185
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they are a platform administrator.');
|
|
186
|
+
}
|
|
187
|
+
const subtree = new Set(subtreeOf(actor));
|
|
188
|
+
const { companies, roleIds, superuser } = await this.facts('target memberships', async () => {
|
|
189
|
+
const su = (await this.pool.query(`SELECT u_is_platform_superuser AS su FROM \`${APP}\`.\`users\` WHERE u_uuid = ? LIMIT 1`, [targetUserId]));
|
|
190
|
+
const memberships = (await this.pool.query(`SELECT DISTINCT cm_company_uuid AS company
|
|
191
|
+
FROM \`${APP}\`.\`company_membership\`
|
|
192
|
+
WHERE cm_user_uuid = ?`, [targetUserId]));
|
|
193
|
+
const postings = (await this.pool.query(`SELECT DISTINCT mp.mp_role_uuid AS role_uuid
|
|
194
|
+
FROM \`${APP}\`.\`membership_posting\` mp
|
|
195
|
+
JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
|
|
196
|
+
WHERE cm.cm_user_uuid = ?`, [targetUserId]));
|
|
197
|
+
return {
|
|
198
|
+
companies: memberships.map((m) => m.company),
|
|
199
|
+
roleIds: postings.map((p) => p.role_uuid),
|
|
200
|
+
superuser: Number(su[0]?.su ?? 0) === 1,
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
if (superuser) {
|
|
204
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they are a platform administrator.');
|
|
205
|
+
}
|
|
206
|
+
// Security review F5: a user with NO membership is in nobody's subtree — refuse like the
|
|
207
|
+
// walled read does (404), never "vacuously dominated".
|
|
208
|
+
if (companies.length === 0)
|
|
209
|
+
throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
|
|
210
|
+
if (companies.some((c) => !subtree.has(c))) {
|
|
211
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they also belong to an organisation outside the ones you manage.');
|
|
212
|
+
}
|
|
213
|
+
const raw = await this.facts('target grants', () => this.rawKeysOfRoles(roleIds));
|
|
214
|
+
const targetKeys = (0, db_permission_facts_adapter_js_1.expandGrantKeys)(raw, this.registeredKeys());
|
|
215
|
+
if (targetKeys.length === 0)
|
|
216
|
+
return;
|
|
217
|
+
const effective = await this.effectiveKeys(actor);
|
|
218
|
+
if (targetKeys.some((k) => !effective.has(k))) {
|
|
219
|
+
throw new identity_errors_js_1.IdentityForbiddenError('You cannot change this user: they hold permissions you do not hold.');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
|
|
223
|
+
async roleBinding(actor, roleUuid) {
|
|
224
|
+
return this.facts('role binding', () => (0, role_binding_js_1.readRoleBinding)(this.pool, roleUuid, subtreeOf(actor)));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Assert the actor may ADMINISTER the role — edit its grants, rename or delete it (#1, D1
|
|
228
|
+
* interim rule): the role is bound inside the actor's subtree AND bound nowhere outside it. A
|
|
229
|
+
* role not bound in the subtree is "not found" for the actor (no existence leak); a role also
|
|
230
|
+
* used outside (a shared role) is root-only. Root bypasses.
|
|
231
|
+
*/
|
|
232
|
+
async assertRoleAdministrable(actor, roleUuid) {
|
|
233
|
+
if (actor.isRoot === true)
|
|
234
|
+
return;
|
|
235
|
+
GrantCeiling.checkAdministrable(await this.roleBinding(actor, roleUuid));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Assert the actor may POST someone to the role (#11): the role is bound within the actor's
|
|
239
|
+
* subtree or not bound at all — never a role also used outside it (a shared role's assignment
|
|
240
|
+
* would widen another tenant's role). Pair with {@link assertCanAssignRoles}. Root bypasses.
|
|
241
|
+
*/
|
|
242
|
+
async assertRoleAssignable(actor, roleUuid) {
|
|
243
|
+
if (actor.isRoot === true)
|
|
244
|
+
return;
|
|
245
|
+
GrantCeiling.checkAssignable(await this.roleBinding(actor, roleUuid));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
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,21 @@ 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
|
+
* Increment the permission version for EVERY tenant where the user has a membership (any status)
|
|
76
|
+
* — for a change to the USER itself that alters its effective grants everywhere (security
|
|
77
|
+
* hardening Phase 3: disabling or re-enabling an account). Returns the bumped tenants. Like
|
|
78
|
+
* `bumpForRole`, a failure is wrapped in `AuthorizationUnavailableError` and THROWS: a silently
|
|
79
|
+
* missed bump would leave a disabled user's cached grants honoured.
|
|
80
|
+
*/
|
|
81
|
+
bumpForUser(userUuid: string, conn: Queryable): Promise<string[]>;
|
|
82
|
+
/**
|
|
83
|
+
* The raw per-tenant increment (INSERT-or-increment). With a publisher attached (ADR-0035) the
|
|
84
|
+
* new version is read back on the SAME connection (inside a transaction that is the
|
|
85
|
+
* transaction's own value) and published to Redis AFTER the commit: at once over the autocommit
|
|
86
|
+
* pool, at `publishCommitted(conn)` for a transaction. A read-back failure skips the publish
|
|
87
|
+
* (the TTL bounds it) and never fails the bump.
|
|
88
|
+
*/
|
|
45
89
|
private bumpTenant;
|
|
46
90
|
}
|
|
47
91
|
//# 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;IA2BvE;;;;;;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
|
|
@@ -48,7 +86,12 @@ class PermissionVersionBumper {
|
|
|
48
86
|
FROM \`${APP}\`.\`company_membership\` cm
|
|
49
87
|
JOIN \`${APP}\`.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
|
|
50
88
|
WHERE mp.mp_role_uuid = ? AND cm.cm_is_active = 1`, [roleUuid]));
|
|
51
|
-
|
|
89
|
+
// Service accounts (ADR-0033) hold roles on `sa_role_ids`, not postings: a role held only by
|
|
90
|
+
// machine principals must still invalidate their tenants' cached grants (security hardening).
|
|
91
|
+
const saRows = (await conn.query(`SELECT DISTINCT sa.sa_tenant_id AS tenant
|
|
92
|
+
FROM \`${APP}\`.\`service_account\` sa
|
|
93
|
+
WHERE JSON_CONTAINS(sa.sa_role_ids, JSON_QUOTE(?))`, [roleUuid]));
|
|
94
|
+
const tenants = [...new Set([...rows, ...saRows].map((r) => r.tenant))];
|
|
52
95
|
for (const tenant of tenants) {
|
|
53
96
|
await this.bumpTenant(tenant, conn);
|
|
54
97
|
}
|
|
@@ -58,11 +101,58 @@ class PermissionVersionBumper {
|
|
|
58
101
|
throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
|
|
59
102
|
}
|
|
60
103
|
}
|
|
61
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Increment the permission version for EVERY tenant where the user has a membership (any status)
|
|
106
|
+
* — for a change to the USER itself that alters its effective grants everywhere (security
|
|
107
|
+
* hardening Phase 3: disabling or re-enabling an account). Returns the bumped tenants. Like
|
|
108
|
+
* `bumpForRole`, a failure is wrapped in `AuthorizationUnavailableError` and THROWS: a silently
|
|
109
|
+
* missed bump would leave a disabled user's cached grants honoured.
|
|
110
|
+
*/
|
|
111
|
+
async bumpForUser(userUuid, conn) {
|
|
112
|
+
try {
|
|
113
|
+
const rows = (await conn.query(`SELECT DISTINCT cm.cm_company_uuid AS tenant
|
|
114
|
+
FROM \`${APP}\`.\`company_membership\` cm
|
|
115
|
+
WHERE cm.cm_user_uuid = ?`, [userUuid]));
|
|
116
|
+
const tenants = [...new Set(rows.map((r) => r.tenant))];
|
|
117
|
+
for (const tenant of tenants)
|
|
118
|
+
await this.bumpTenant(tenant, conn);
|
|
119
|
+
return tenants;
|
|
120
|
+
}
|
|
121
|
+
catch (cause) {
|
|
122
|
+
throw new core_1.AuthorizationUnavailableError('permission version bump failed', cause);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The raw per-tenant increment (INSERT-or-increment). With a publisher attached (ADR-0035) the
|
|
127
|
+
* new version is read back on the SAME connection (inside a transaction that is the
|
|
128
|
+
* transaction's own value) and published to Redis AFTER the commit: at once over the autocommit
|
|
129
|
+
* pool, at `publishCommitted(conn)` for a transaction. A read-back failure skips the publish
|
|
130
|
+
* (the TTL bounds it) and never fails the bump.
|
|
131
|
+
*/
|
|
62
132
|
async bumpTenant(tenantUuid, conn) {
|
|
63
133
|
await conn.query(`INSERT INTO \`${AUTHZ}\`.\`authz_permission_version\` (apv_tenant_uuid, apv_version)
|
|
64
134
|
VALUES (?, 1)
|
|
65
135
|
ON DUPLICATE KEY UPDATE apv_version = apv_version + 1`, [tenantUuid]);
|
|
136
|
+
if (this.publisher === undefined)
|
|
137
|
+
return;
|
|
138
|
+
let version;
|
|
139
|
+
try {
|
|
140
|
+
const rows = (await conn.query(`SELECT apv_version FROM \`${AUTHZ}\`.\`authz_permission_version\`
|
|
141
|
+
WHERE apv_tenant_uuid = ? LIMIT 1`, [tenantUuid]));
|
|
142
|
+
version = Number(rows[0]?.apv_version);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!Number.isSafeInteger(version) || version < 0)
|
|
148
|
+
return;
|
|
149
|
+
if (isAutocommitPool(conn)) {
|
|
150
|
+
await this.safePublish(tenantUuid, version);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const versions = this.pending.get(conn) ?? new Map();
|
|
154
|
+
versions.set(tenantUuid, Math.max(version, versions.get(tenantUuid) ?? 0));
|
|
155
|
+
this.pending.set(conn, versions);
|
|
66
156
|
}
|
|
67
157
|
}
|
|
68
158
|
exports.PermissionVersionBumper = PermissionVersionBumper;
|
|
@@ -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;AAKtD;;;;GAIG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAU9C;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,IAAI,EACV,YAAY,GAAE,SAAS,eAAe,EAAO,GAC5C,yBAAyB,CAS3B"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role binding — where a GLOBAL role (`shield_app.roles`) is in use, relative to a caller's subtree.
|
|
3
|
+
*
|
|
4
|
+
* Roles are a global catalog, so one `role_permission` edit changes every tenant that uses the role
|
|
5
|
+
* (review finding #1). Until roles are tenant-owned (D1 follow-up), "may this caller administer the
|
|
6
|
+
* role" is decided by WHERE the role is bound:
|
|
7
|
+
* - a human binding: a `membership_posting` on a `company_membership` of company X;
|
|
8
|
+
* - a machine binding: a `service_account` of tenant X whose `sa_role_ids` holds the role.
|
|
9
|
+
* `inside` = some binding's node is in the caller's subtree; `outside` = some binding's node is not.
|
|
10
|
+
*
|
|
11
|
+
* ONE definition, shared by grant administration (the `subtreeExists` read predicate below and the
|
|
12
|
+
* write rule), role update/delete, and posting creation, via {@link GrantCeiling}. All node ids and
|
|
13
|
+
* the role id are `?`-bound (never interpolated).
|
|
14
|
+
*/
|
|
15
|
+
import type { Queryable } from './permission-version.bumper.js';
|
|
16
|
+
/** Where a role is bound relative to a set of subtree nodes. */
|
|
17
|
+
export interface RoleBinding {
|
|
18
|
+
readonly inside: boolean;
|
|
19
|
+
readonly outside: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The shared EXISTS-subtree fragment for a non-root caller's READS (Task 4.2): a role row `r`
|
|
23
|
+
* qualifies only if it is bound (`membership_posting → company_membership`) to a node in the
|
|
24
|
+
* subtree. The caller splices `params` where `sql` sits in the surrounding query.
|
|
25
|
+
*/
|
|
26
|
+
export declare function subtreeExists(nodeIds: readonly string[]): {
|
|
27
|
+
sql: string;
|
|
28
|
+
params: string[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Read where `roleUuid` is bound relative to `nodeIds` (human postings + service accounts) in ONE
|
|
32
|
+
* query. An empty `nodeIds` is treated as "nothing is inside" (every binding is outside) — fail
|
|
33
|
+
* closed, never an `IN ()` syntax error.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readRoleBinding(q: Queryable, roleUuid: string, nodeIds: readonly string[]): Promise<RoleBinding>;
|
|
36
|
+
//# sourceMappingURL=role-binding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-binding.d.ts","sourceRoot":"","sources":["../../../src/features/authz/role-binding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAIhE,gEAAgE;AAChE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAQ3F;AASD;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,CAAC,EAAE,SAAS,EACZ,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,OAAO,CAAC,WAAW,CAAC,CAwBtB"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.subtreeExists = subtreeExists;
|
|
4
|
+
exports.readRoleBinding = readRoleBinding;
|
|
5
|
+
const APP = 'shield_app';
|
|
6
|
+
/**
|
|
7
|
+
* The shared EXISTS-subtree fragment for a non-root caller's READS (Task 4.2): a role row `r`
|
|
8
|
+
* qualifies only if it is bound (`membership_posting → company_membership`) to a node in the
|
|
9
|
+
* subtree. The caller splices `params` where `sql` sits in the surrounding query.
|
|
10
|
+
*/
|
|
11
|
+
function subtreeExists(nodeIds) {
|
|
12
|
+
const ph = nodeIds.map(() => '?').join(', ');
|
|
13
|
+
const sql = ` AND EXISTS (` +
|
|
14
|
+
`SELECT 1 FROM \`${APP}\`.\`membership_posting\` mp` +
|
|
15
|
+
` JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid` +
|
|
16
|
+
` WHERE mp.mp_role_uuid = r.r_uuid AND cm.cm_company_uuid IN (${ph}))`;
|
|
17
|
+
return { sql, params: [...nodeIds] };
|
|
18
|
+
}
|
|
19
|
+
/** Coerce a driver aggregate (number | bigint | string | null) to a boolean. */
|
|
20
|
+
function truthy(v) {
|
|
21
|
+
if (v === null || v === undefined)
|
|
22
|
+
return false;
|
|
23
|
+
if (typeof v === 'bigint')
|
|
24
|
+
return v !== 0n;
|
|
25
|
+
return Number(v) > 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read where `roleUuid` is bound relative to `nodeIds` (human postings + service accounts) in ONE
|
|
29
|
+
* query. An empty `nodeIds` is treated as "nothing is inside" (every binding is outside) — fail
|
|
30
|
+
* closed, never an `IN ()` syntax error.
|
|
31
|
+
*/
|
|
32
|
+
async function readRoleBinding(q, roleUuid, nodeIds) {
|
|
33
|
+
const ids = nodeIds.length > 0 ? [...nodeIds] : [];
|
|
34
|
+
const inList = ids.length > 0 ? ids.map(() => '?').join(', ') : 'NULL';
|
|
35
|
+
// `x IN (NULL)` is NULL (never true) and `x NOT IN (NULL)` is NULL too, so for an empty list the
|
|
36
|
+
// outside test is written as a plain existence check instead.
|
|
37
|
+
const insideExpr = ids.length > 0 ? `b.node IN (${inList})` : '0';
|
|
38
|
+
const outsideExpr = ids.length > 0 ? `b.node NOT IN (${inList})` : '1';
|
|
39
|
+
const rows = (await q.query(`SELECT COALESCE(MAX(${insideExpr}), 0) AS bound_inside,
|
|
40
|
+
COALESCE(MAX(${outsideExpr}), 0) AS bound_outside
|
|
41
|
+
FROM (
|
|
42
|
+
SELECT cm.cm_company_uuid AS node
|
|
43
|
+
FROM \`${APP}\`.\`membership_posting\` mp
|
|
44
|
+
JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
|
|
45
|
+
WHERE mp.mp_role_uuid = ?
|
|
46
|
+
UNION ALL
|
|
47
|
+
SELECT sa.sa_tenant_id AS node
|
|
48
|
+
FROM \`${APP}\`.\`service_account\` sa
|
|
49
|
+
WHERE JSON_CONTAINS(sa.sa_role_ids, JSON_QUOTE(?))
|
|
50
|
+
) b`, [...ids, ...ids, roleUuid, roleUuid]));
|
|
51
|
+
const row = rows[0];
|
|
52
|
+
return { inside: truthy(row?.bound_inside), outside: truthy(row?.bound_outside) };
|
|
53
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompanyHierarchyService — the ONLY way a company's place in the tree changes (security hardening
|
|
3
|
+
* #2, D5). The generic company patch can no longer move a company (`parentUuid`/`type` are
|
|
4
|
+
* immutable at the engine, ADR-0034, and the patch is whitelisted); every move, delete and new-node
|
|
5
|
+
* attach goes through `ScopeClosureService`, which owns the cycle check, the closure rebuild and
|
|
6
|
+
* the permission-version bump (ADR-0027 E-1/E-2).
|
|
7
|
+
*
|
|
8
|
+
* Authority (non-root; root bypasses), decided from the session's cached subtree — the SAME
|
|
9
|
+
* `scopeSubtreeNodeIds` the engine's write wall uses:
|
|
10
|
+
* - move: the node is in the actor's subtree, is NOT the actor's own root node (a sub-admin can
|
|
11
|
+
* never re-home its whole tenant), and the new parent is in the actor's subtree and non-null
|
|
12
|
+
* (only root creates or promotes top-level tenants). A cycle → ScopeCycleError (409).
|
|
13
|
+
* - delete: the caller (CompanyService) has already read the node through the subtree wall.
|
|
14
|
+
*
|
|
15
|
+
* Move and delete are audited ON their own transaction (a failed audit rolls them back). Names,
|
|
16
|
+
* never ids, in the audit detail (ADR-0012).
|
|
17
|
+
*/
|
|
18
|
+
import type { Principal } from '@astram/core';
|
|
19
|
+
import type { AuditPort } from '@astram/engine';
|
|
20
|
+
import { ScopeClosureService } from '../scope/scope-closure.service.js';
|
|
21
|
+
/** The read surface used for existence/name lookups (the shared authz pool satisfies it). */
|
|
22
|
+
export interface HierarchyQueryable {
|
|
23
|
+
query(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
export declare class CompanyHierarchyService {
|
|
26
|
+
private readonly pool;
|
|
27
|
+
private readonly closure;
|
|
28
|
+
private readonly audit?;
|
|
29
|
+
private readonly now;
|
|
30
|
+
constructor(pool: HierarchyQueryable, closure: ScopeClosureService, audit?: AuditPort | undefined, now?: () => number);
|
|
31
|
+
/** The actor's own root node (the active node its subtree hangs from). */
|
|
32
|
+
private ownRoot;
|
|
33
|
+
/** Names of the given companies (unknown → absent). */
|
|
34
|
+
private names;
|
|
35
|
+
private recordAudit;
|
|
36
|
+
/** Re-parent `nodeUuid` under `newParentUuid` (null = make it a top-level tenant; root only). */
|
|
37
|
+
move(actor: Principal, nodeUuid: string, newParentUuid: string | null): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Delete a company through `deleteNode` (refuses a node with children; closure rows + ancestor
|
|
40
|
+
* bump in one transaction). The caller has already checked reach (walled read).
|
|
41
|
+
*/
|
|
42
|
+
remove(actor: Principal, nodeUuid: string): Promise<void>;
|
|
43
|
+
/** A company was just created: give it closure rows now and bump its ancestors (D5). */
|
|
44
|
+
attached(nodeUuid: string): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=company-hierarchy.service.d.ts.map
|