@astram/identity 0.4.0 → 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 +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 +49 -16
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
- package/dist/features/authz/grant-admin.service.js +155 -96
- package/dist/features/authz/grant-ceiling.d.ts +52 -1
- package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
- package/dist/features/authz/grant-ceiling.js +156 -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 +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 +60 -3
- 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 +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 +14 -7
- package/dist/features/service-account/service-account.service.d.ts.map +1 -1
- package/dist/features/service-account/service-account.service.js +56 -11
- 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/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 +4 -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
|
@@ -4,23 +4,11 @@ exports.GrantAdminService = exports.BadKeyError = void 0;
|
|
|
4
4
|
const core_1 = require("@astram/core");
|
|
5
5
|
const access_bundle_store_js_1 = require("./access-bundle-store.js");
|
|
6
6
|
const db_permission_facts_adapter_js_1 = require("./db-permission-facts.adapter.js");
|
|
7
|
+
const grant_ceiling_js_1 = require("./grant-ceiling.js");
|
|
8
|
+
const role_binding_js_1 = require("./role-binding.js");
|
|
9
|
+
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
7
10
|
const AUTHZ = 'shield_authz';
|
|
8
11
|
const APP = 'shield_app';
|
|
9
|
-
/**
|
|
10
|
-
* Build the shared EXISTS-subtree fragment for a non-root caller. A role qualifies only if it
|
|
11
|
-
* is bound (`membership_posting → company_membership`) to a node in the caller's subtree. All
|
|
12
|
-
* node ids are `?`-bound (never interpolated → injection-free); the caller splices the returned
|
|
13
|
-
* `params` at the position where `sql` sits in the surrounding query. Root / empty scope callers
|
|
14
|
-
* are handled by the caller (root ⇒ no fragment; the engine guarantees non-empty subtrees).
|
|
15
|
-
*/
|
|
16
|
-
function subtreeExists(nodeIds) {
|
|
17
|
-
const ph = nodeIds.map(() => '?').join(', ');
|
|
18
|
-
const sql = ` AND EXISTS (` +
|
|
19
|
-
`SELECT 1 FROM \`${APP}\`.\`membership_posting\` mp` +
|
|
20
|
-
` JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid` +
|
|
21
|
-
` WHERE mp.mp_role_uuid = r.r_uuid AND cm.cm_company_uuid IN (${ph}))`;
|
|
22
|
-
return { sql, params: [...nodeIds] };
|
|
23
|
-
}
|
|
24
12
|
/** A granted key was not in the catalog and is not a recognized wildcard. The
|
|
25
13
|
* controller maps this to HTTP 400 (distinct from a store outage → 503). */
|
|
26
14
|
class BadKeyError extends Error {
|
|
@@ -34,17 +22,22 @@ class GrantAdminService {
|
|
|
34
22
|
pool;
|
|
35
23
|
bumper;
|
|
36
24
|
ceiling;
|
|
25
|
+
audit;
|
|
37
26
|
bundles;
|
|
38
27
|
/**
|
|
39
|
-
* @param ceiling The ADR-0029 self-escalation ceiling ("only grant what you hold")
|
|
40
|
-
*
|
|
41
|
-
* (legacy wiring / tests that don't
|
|
42
|
-
* identity module ALWAYS wires it, so production always
|
|
28
|
+
* @param ceiling The ADR-0029 self-escalation ceiling ("only grant what you hold") plus the
|
|
29
|
+
* security-hardening role-binding rule. Enforced on grant/revoke/grantBundle/revokeBundle for a
|
|
30
|
+
* non-root actor. OPTIONAL for back-compat: when absent (legacy wiring / tests that don't
|
|
31
|
+
* exercise it), neither check runs — the identity module ALWAYS wires it, so production always
|
|
32
|
+
* enforces. Root bypasses regardless.
|
|
33
|
+
* @param audit The grant-administration audit sink (D6): written on the grant's own transaction
|
|
34
|
+
* connection, fail-closed. The identity module ALWAYS wires the engine `MariaDbAuditSink`.
|
|
43
35
|
*/
|
|
44
|
-
constructor(pool, bumper, bundleStore, ceiling) {
|
|
36
|
+
constructor(pool, bumper, bundleStore, ceiling, audit) {
|
|
45
37
|
this.pool = pool;
|
|
46
38
|
this.bumper = bumper;
|
|
47
39
|
this.ceiling = ceiling;
|
|
40
|
+
this.audit = audit;
|
|
48
41
|
this.bundles = bundleStore ?? new access_bundle_store_js_1.AccessBundleStore(pool);
|
|
49
42
|
}
|
|
50
43
|
/** All grantable exact keys (from the catalog) + the computed wildcard set. */
|
|
@@ -60,8 +53,11 @@ class GrantAdminService {
|
|
|
60
53
|
throw new core_1.AuthorizationUnavailableError('permission catalog unavailable', cause);
|
|
61
54
|
}
|
|
62
55
|
const catalog = rows.map((r) => ({
|
|
63
|
-
key: r.perm_key,
|
|
64
|
-
|
|
56
|
+
key: r.perm_key,
|
|
57
|
+
label: r.perm_label,
|
|
58
|
+
module: r.perm_module,
|
|
59
|
+
typeKey: r.perm_type_key,
|
|
60
|
+
action: r.perm_action,
|
|
65
61
|
}));
|
|
66
62
|
// Wildcards: module-wide + one per distinct type key.
|
|
67
63
|
const modules = [...new Set(catalog.map((c) => c.module))];
|
|
@@ -106,57 +102,142 @@ class GrantAdminService {
|
|
|
106
102
|
throw new BadKeyError(key);
|
|
107
103
|
}
|
|
108
104
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
105
|
+
* Security hardening #1 (D1 interim rule) + ADR-0029, in this order, BEFORE any write: the role
|
|
106
|
+
* must be administrable by the actor (bound inside its subtree and nowhere outside — a shared
|
|
107
|
+
* role is root-only), then every key must be covered by the actor's own effective keys ("only
|
|
108
|
+
* grant — or revoke — what you hold"). Root bypasses both. No ceiling wired (legacy/tests) ⇒
|
|
109
|
+
* neither check runs; the identity module always wires it.
|
|
114
110
|
*/
|
|
115
|
-
async
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await this.ceiling
|
|
119
|
-
|
|
111
|
+
async assertMayChangeGrants(roleUuid, keys, actor) {
|
|
112
|
+
if (!this.ceiling)
|
|
113
|
+
return;
|
|
114
|
+
await this.ceiling.assertRoleAdministrable(actor, roleUuid);
|
|
115
|
+
await this.ceiling.assertCanGrant(actor, keys);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Run one grant-administration write in ONE transaction: the `role_permission` statements, the
|
|
119
|
+
* permission-version bump of every tenant using the role, and the audit row(s) (D6 — audited in
|
|
120
|
+
* the SAME transaction, fail-closed: an audit failure rolls the grant back). Store failures map
|
|
121
|
+
* to AuthorizationUnavailableError (503).
|
|
122
|
+
*/
|
|
123
|
+
async writeGrants(action, roleUuid, keys, actor, statement, extra = {}) {
|
|
120
124
|
const conn = await this.pool.getConnection();
|
|
121
125
|
try {
|
|
122
126
|
await conn.beginTransaction();
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
WHERE
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
// Security review F2: serialise against a concurrent posting of this role (in any tenant).
|
|
128
|
+
// Lock the role row FIRST — the posting path locks the same row before it inserts — then
|
|
129
|
+
// re-run the binding rule on THIS connection, so a binding committed by a writer we waited
|
|
130
|
+
// for is seen, and a role that became shared in the meantime is refused (rolled back).
|
|
131
|
+
await conn.query(`SELECT r_uuid FROM \`${APP}\`.\`roles\` WHERE r_uuid = ? FOR UPDATE`, [
|
|
132
|
+
roleUuid,
|
|
133
|
+
]);
|
|
134
|
+
if (this.ceiling && actor.isRoot !== true) {
|
|
135
|
+
grant_ceiling_js_1.GrantCeiling.checkAdministrable(await (0, role_binding_js_1.readRoleBinding)(conn, roleUuid, (0, grant_ceiling_js_1.subtreeOf)(actor)));
|
|
136
|
+
}
|
|
137
|
+
for (const key of keys)
|
|
138
|
+
await statement(conn, key);
|
|
139
|
+
const bumped = await this.bumper.bumpForRole(roleUuid, conn);
|
|
140
|
+
await this.auditGrantChange(conn, action, roleUuid, keys, actor, bumped, extra);
|
|
131
141
|
await conn.commit();
|
|
142
|
+
// ADR-0035: the committed versions reach Redis now (gate 5 reads them there).
|
|
143
|
+
await this.bumper.publishCommitted(conn);
|
|
132
144
|
}
|
|
133
145
|
catch (cause) {
|
|
134
146
|
await conn.rollback();
|
|
135
|
-
|
|
147
|
+
this.bumper.discardPending(conn);
|
|
148
|
+
if (cause instanceof BadKeyError ||
|
|
149
|
+
cause instanceof identity_errors_js_1.IdentityForbiddenError ||
|
|
150
|
+
cause instanceof identity_errors_js_1.IdentityNotFoundError) {
|
|
136
151
|
throw cause;
|
|
137
|
-
|
|
152
|
+
}
|
|
153
|
+
throw new core_1.AuthorizationUnavailableError(`${action} failed`, cause);
|
|
138
154
|
}
|
|
139
155
|
finally {
|
|
140
156
|
await conn.release();
|
|
141
157
|
}
|
|
142
158
|
}
|
|
143
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Write the grant-administration audit row(s) on the transaction connection (D6). One row per
|
|
161
|
+
* distinct tenant among the actor's tenant and every tenant the change bumped, so each affected
|
|
162
|
+
* tenant's auditor sees it. Names, never ids, in the detail (ADR-0012): the role name and the
|
|
163
|
+
* bumped tenants' names; the viewer resolves the actor and row tenant itself. No sink wired
|
|
164
|
+
* (legacy/tests) ⇒ nothing is written.
|
|
165
|
+
*/
|
|
166
|
+
async auditGrantChange(conn, action, roleUuid, keys, actor, bumpedTenants, extra) {
|
|
167
|
+
if (!this.audit)
|
|
168
|
+
return;
|
|
169
|
+
const query = (sql, params) => conn.query(sql, params);
|
|
170
|
+
const roleRows = (await query(`SELECT r_name FROM \`${APP}\`.\`roles\` WHERE r_uuid = ? LIMIT 1`, [roleUuid]));
|
|
171
|
+
const tenants = [...new Set(bumpedTenants)];
|
|
172
|
+
const nameOf = new Map();
|
|
173
|
+
if (tenants.length > 0) {
|
|
174
|
+
const ph = tenants.map(() => '?').join(', ');
|
|
175
|
+
const rows = (await query(`SELECT c_uuid, c_name FROM \`${APP}\`.\`companies\` WHERE c_uuid IN (${ph})`, tenants));
|
|
176
|
+
for (const r of rows)
|
|
177
|
+
nameOf.set(r.c_uuid, r.c_name);
|
|
151
178
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
179
|
+
const labelOf = (t) => nameOf.get(t) ?? '(deleted organisation)';
|
|
180
|
+
const bumpedSet = new Set(tenants);
|
|
181
|
+
// Security review F1: each row is filed under ONE tenant and names ONLY that tenant — a
|
|
182
|
+
// tenant's auditor must never learn which OTHER tenants use the same (shared) role. The one
|
|
183
|
+
// exception is root's own row, filed under a tenant the change did NOT bump, which may carry
|
|
184
|
+
// the full list (root administers across tenants).
|
|
185
|
+
const tenantsFor = (rowTenant) => {
|
|
186
|
+
if (bumpedSet.has(rowTenant))
|
|
187
|
+
return [labelOf(rowTenant)];
|
|
188
|
+
return actor.isRoot === true ? tenants.map(labelOf) : [];
|
|
189
|
+
};
|
|
190
|
+
const occurredAtMs = Date.now();
|
|
191
|
+
for (const tenantId of new Set([actor.tenantId, ...tenants])) {
|
|
192
|
+
const tenantNames = tenantsFor(tenantId);
|
|
193
|
+
await this.audit.record({
|
|
194
|
+
type: 'admin_action',
|
|
195
|
+
occurredAtMs,
|
|
196
|
+
actorId: actor.id,
|
|
197
|
+
tenantId,
|
|
198
|
+
action: { key: action },
|
|
199
|
+
attributes: {
|
|
200
|
+
eventType: 'role_grant',
|
|
201
|
+
role: roleRows[0]?.r_name ?? '(deleted role)',
|
|
202
|
+
keys: [...keys],
|
|
203
|
+
tenants: tenantNames,
|
|
204
|
+
...extra,
|
|
205
|
+
},
|
|
206
|
+
}, query);
|
|
158
207
|
}
|
|
159
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Grant a key to a role (idempotent) + bump the version + audit, atomically. The `grantor`
|
|
211
|
+
* Principal stamps `rp_granted_at`/`rp_granted_by` on a NEWLY-inserted row (a duplicate is a
|
|
212
|
+
* no-op and its existing stamp is left as-is). Order: catalog validation (400) → role binding
|
|
213
|
+
* (404/403) → ceiling (403) → write. Root bypasses the binding rule and the ceiling.
|
|
214
|
+
*/
|
|
215
|
+
async grant(roleUuid, key, grantor) {
|
|
216
|
+
await this.assertValidKey(key); // throws BadKeyError (400) before touching the store
|
|
217
|
+
await this.assertMayChangeGrants(roleUuid, [key], grantor);
|
|
218
|
+
const actorUuid = grantor.id;
|
|
219
|
+
await this.writeGrants('role.permission_granted', roleUuid, [key], grantor, async (conn, k) => {
|
|
220
|
+
// Idempotent insert — UNIQUE (rp_role_uuid, rp_perm_key); a duplicate is a no-op.
|
|
221
|
+
await conn.query(`INSERT INTO \`${AUTHZ}\`.\`role_permission\`
|
|
222
|
+
(rp_uuid, rp_role_uuid, rp_perm_key, rp_granted_at, rp_granted_by)
|
|
223
|
+
SELECT UUID(), ?, ?, UTC_TIMESTAMP(), ?
|
|
224
|
+
WHERE NOT EXISTS (
|
|
225
|
+
SELECT 1 FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?
|
|
226
|
+
)`, [roleUuid, k, actorUuid ?? null, roleUuid, k]);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Revoke a key from a role (no-op if absent) + bump + audit, atomically. Security hardening #1:
|
|
231
|
+
* the actor must be able to administer the role (binding rule) and must hold the key itself —
|
|
232
|
+
* "only revoke what you hold" — so a delegated admin can no longer strip a role of authority it
|
|
233
|
+
* never had. Root bypasses.
|
|
234
|
+
*/
|
|
235
|
+
async revoke(roleUuid, key, actor) {
|
|
236
|
+
await this.assertMayChangeGrants(roleUuid, [key], actor);
|
|
237
|
+
await this.writeGrants('role.permission_revoked', roleUuid, [key], actor, async (conn, k) => {
|
|
238
|
+
await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?`, [roleUuid, k]);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
160
241
|
// ── Access bundles ────────────────────────────────────────────────────────
|
|
161
242
|
/** All active bundles + their resolved atomic keys, for the Access UI. */
|
|
162
243
|
async listBundles() {
|
|
@@ -177,57 +258,33 @@ class GrantAdminService {
|
|
|
177
258
|
*/
|
|
178
259
|
async grantBundle(roleUuid, bundleKey, grantor) {
|
|
179
260
|
const keys = await this.resolveBundleKeysOrThrow(bundleKey);
|
|
180
|
-
// ADR-0029 ceiling: a non-root grantor must hold EVERY atomic key the bundle
|
|
181
|
-
//
|
|
182
|
-
await this.
|
|
261
|
+
// Binding rule + ADR-0029 ceiling: a non-root grantor must hold EVERY atomic key the bundle
|
|
262
|
+
// expands to — checked before any write (no partial bundle escalation).
|
|
263
|
+
await this.assertMayChangeGrants(roleUuid, [...keys], grantor);
|
|
183
264
|
const actorUuid = grantor.id;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
await conn.
|
|
187
|
-
for (const key of keys) {
|
|
188
|
-
// Idempotent — UNIQUE (rp_role_uuid, rp_perm_key); a duplicate is a no-op.
|
|
189
|
-
await conn.query(`INSERT INTO \`${AUTHZ}\`.\`role_permission\`
|
|
265
|
+
await this.writeGrants('role.bundle_granted', roleUuid, keys, grantor, async (conn, key) => {
|
|
266
|
+
// Idempotent — UNIQUE (rp_role_uuid, rp_perm_key); a duplicate is a no-op.
|
|
267
|
+
await conn.query(`INSERT INTO \`${AUTHZ}\`.\`role_permission\`
|
|
190
268
|
(rp_uuid, rp_role_uuid, rp_perm_key, rp_granted_at, rp_granted_by)
|
|
191
269
|
SELECT UUID(), ?, ?, UTC_TIMESTAMP(), ?
|
|
192
270
|
WHERE NOT EXISTS (
|
|
193
271
|
SELECT 1 FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?
|
|
194
272
|
)`, [roleUuid, key, actorUuid ?? null, roleUuid, key]);
|
|
195
|
-
|
|
196
|
-
await this.bumper.bumpForRole(roleUuid, conn);
|
|
197
|
-
await conn.commit();
|
|
198
|
-
}
|
|
199
|
-
catch (cause) {
|
|
200
|
-
await conn.rollback();
|
|
201
|
-
throw new core_1.AuthorizationUnavailableError('grant bundle failed', cause);
|
|
202
|
-
}
|
|
203
|
-
finally {
|
|
204
|
-
await conn.release();
|
|
205
|
-
}
|
|
273
|
+
}, { bundle: bundleKey });
|
|
206
274
|
}
|
|
207
275
|
/**
|
|
208
276
|
* Revoke a bundle from a role: resolve → DELETE each of the bundle's atomic keys, in
|
|
209
|
-
* ONE transaction with ONE version bump. Caveat (design §5.1): this removes exactly the
|
|
277
|
+
* ONE transaction with ONE version bump + audit. Caveat (design §5.1): this removes exactly the
|
|
210
278
|
* bundle's keys even if a manual grant or another bundle shares one — the model is
|
|
211
|
-
* key-based, not bundle-membership-based. Unknown bundle → `BadKeyError`.
|
|
279
|
+
* key-based, not bundle-membership-based. Unknown bundle → `BadKeyError`. Security hardening
|
|
280
|
+
* #1: the binding rule + "only revoke what you hold" apply (root bypasses).
|
|
212
281
|
*/
|
|
213
|
-
async revokeBundle(roleUuid, bundleKey) {
|
|
282
|
+
async revokeBundle(roleUuid, bundleKey, actor) {
|
|
214
283
|
const keys = await this.resolveBundleKeysOrThrow(bundleKey);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
await conn.
|
|
218
|
-
|
|
219
|
-
await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?`, [roleUuid, key]);
|
|
220
|
-
}
|
|
221
|
-
await this.bumper.bumpForRole(roleUuid, conn);
|
|
222
|
-
await conn.commit();
|
|
223
|
-
}
|
|
224
|
-
catch (cause) {
|
|
225
|
-
await conn.rollback();
|
|
226
|
-
throw new core_1.AuthorizationUnavailableError('revoke bundle failed', cause);
|
|
227
|
-
}
|
|
228
|
-
finally {
|
|
229
|
-
await conn.release();
|
|
230
|
-
}
|
|
284
|
+
await this.assertMayChangeGrants(roleUuid, [...keys], actor);
|
|
285
|
+
await this.writeGrants('role.bundle_revoked', roleUuid, keys, actor, async (conn, key) => {
|
|
286
|
+
await conn.query(`DELETE FROM \`${AUTHZ}\`.\`role_permission\` WHERE rp_role_uuid = ? AND rp_perm_key = ?`, [roleUuid, key]);
|
|
287
|
+
}, { bundle: bundleKey });
|
|
231
288
|
}
|
|
232
289
|
/** Resolve a bundle key → its atomic keys; throw BadKeyError when unknown/orphaned. */
|
|
233
290
|
async resolveBundleKeysOrThrow(bundleKey) {
|
|
@@ -270,7 +327,9 @@ class GrantAdminService {
|
|
|
270
327
|
// subtree (the EXISTS predicate); root spans all tenants (no predicate). The scope clause
|
|
271
328
|
// sits FIRST in the WHERE (before the LIKE), so its ?-bound node params lead the arg list —
|
|
272
329
|
// identical on BOTH the count and page queries so they can never disagree.
|
|
273
|
-
const scope = subtreeScope.isRoot
|
|
330
|
+
const scope = subtreeScope.isRoot
|
|
331
|
+
? { sql: '', params: [] }
|
|
332
|
+
: (0, role_binding_js_1.subtreeExists)(subtreeScope.nodeIds);
|
|
274
333
|
// Distinct granted roles (a role appears iff it has ≥1 role_permission row), filtered
|
|
275
334
|
// by name search. Bind the LIKE term as a param (wildcards escaped).
|
|
276
335
|
const likeClause = search ? `AND r.r_name LIKE ?` : '';
|
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { Pool } from 'mariadb';
|
|
16
16
|
import type { ModuleRegistry, Principal } from '@astram/core';
|
|
17
|
+
import { type RoleBinding } from './role-binding.js';
|
|
17
18
|
/** The grantor exceeds their own authority — cannot grant a key they do not hold (→ 403). */
|
|
18
19
|
export declare class GrantCeilingError extends Error {
|
|
19
|
-
constructor(key: string);
|
|
20
|
+
constructor(key: string, message?: string);
|
|
20
21
|
}
|
|
22
|
+
/** The actor's reachable subtree: the session's cached closure, else the self node (non-widening). */
|
|
23
|
+
export declare function subtreeOf(actor: Principal): readonly string[];
|
|
21
24
|
/**
|
|
22
25
|
* Does `effective` (the grantor's held EXACT keys) cover `granted`?
|
|
23
26
|
*
|
|
@@ -40,9 +43,27 @@ export declare class GrantCeiling {
|
|
|
40
43
|
private readonly registry;
|
|
41
44
|
/** Injectable clock (epoch-ms) for the posting effective-window read; defaults to Date.now. */
|
|
42
45
|
private readonly now;
|
|
46
|
+
private readonly rootIds;
|
|
43
47
|
constructor(pool: Pool, registry: ModuleRegistry,
|
|
48
|
+
/**
|
|
49
|
+
* The platform root allowlist (the SAME `rootIds` given to `ShieldModule.forRoot`) — REQUIRED,
|
|
50
|
+
* no default (security review F5): pass the array, or `null` to state explicitly that it is
|
|
51
|
+
* not configured. A root user is never dominated by a non-root actor; independently of this
|
|
52
|
+
* list, a user flagged `u_is_platform_superuser` in the database is never dominated either.
|
|
53
|
+
*/
|
|
54
|
+
rootIds: readonly string[] | null,
|
|
44
55
|
/** Injectable clock (epoch-ms) for the posting effective-window read; defaults to Date.now. */
|
|
45
56
|
now?: () => number);
|
|
57
|
+
/** The D1 administer rule over a binding read: used inside and nowhere outside (404 / 403). */
|
|
58
|
+
static checkAdministrable(b: RoleBinding): void;
|
|
59
|
+
/** The #11 assign rule over a binding read: never a role also used outside (403). */
|
|
60
|
+
static checkAssignable(b: RoleBinding): void;
|
|
61
|
+
/** The registry's exact-key universe (wildcard expansion bound). */
|
|
62
|
+
private registeredKeys;
|
|
63
|
+
/** The raw `role_permission` keys of `roleIds` (exact or wildcard). Store errors propagate. */
|
|
64
|
+
private rawKeysOfRoles;
|
|
65
|
+
/** Run a store read, mapping any non-domain failure to AuthorizationUnavailableError (503). */
|
|
66
|
+
private facts;
|
|
46
67
|
/**
|
|
47
68
|
* The grantor's EFFECTIVE exact keys: the keys granted to the grantor's roles (active
|
|
48
69
|
* membership's in-effect postings in the grantor's tenant), wildcards expanded against the
|
|
@@ -56,5 +77,35 @@ export declare class GrantCeiling {
|
|
|
56
77
|
* `GrantCeilingError` on the FIRST key that exceeds the grantor's authority.
|
|
57
78
|
*/
|
|
58
79
|
assertCanGrant(grantor: Principal, keys: readonly string[]): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Assert the actor may ASSIGN every role in `roleIds` (post someone to it): the roles' effective
|
|
82
|
+
* keys (their `role_permission` rows, wildcards expanded against the registry) must all be held
|
|
83
|
+
* by the actor — the same "only grant what you hold" ceiling, applied to role assignment (#11).
|
|
84
|
+
* A role holding nothing assigns nothing and passes. Root bypasses. Store outage → 503.
|
|
85
|
+
*/
|
|
86
|
+
assertCanAssignRoles(actor: Principal, roleIds: readonly string[]): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Assert the actor DOMINATES the target user (#3, D2): every membership the target holds (in any
|
|
89
|
+
* tenant, active or not) is inside the actor's subtree, AND every key the target could hold
|
|
90
|
+
* through any of its postings (any membership, any window — potential authority, not just what is
|
|
91
|
+
* in effect now) is held by the actor. A platform root target is never dominated by a non-root.
|
|
92
|
+
* Root actors bypass. Store outage → 503.
|
|
93
|
+
*/
|
|
94
|
+
assertDominates(actor: Principal, targetUserId: string): Promise<void>;
|
|
95
|
+
/** Where `roleUuid` is bound relative to the actor's subtree (postings + service accounts). */
|
|
96
|
+
roleBinding(actor: Principal, roleUuid: string): Promise<RoleBinding>;
|
|
97
|
+
/**
|
|
98
|
+
* Assert the actor may ADMINISTER the role — edit its grants, rename or delete it (#1, D1
|
|
99
|
+
* interim rule): the role is bound inside the actor's subtree AND bound nowhere outside it. A
|
|
100
|
+
* role not bound in the subtree is "not found" for the actor (no existence leak); a role also
|
|
101
|
+
* used outside (a shared role) is root-only. Root bypasses.
|
|
102
|
+
*/
|
|
103
|
+
assertRoleAdministrable(actor: Principal, roleUuid: string): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Assert the actor may POST someone to the role (#11): the role is bound within the actor's
|
|
106
|
+
* subtree or not bound at all — never a role also used outside it (a shared role's assignment
|
|
107
|
+
* would widen another tenant's role). Pair with {@link assertCanAssignRoles}. Root bypasses.
|
|
108
|
+
*/
|
|
109
|
+
assertRoleAssignable(actor: Principal, roleUuid: string): Promise<void>;
|
|
59
110
|
}
|
|
60
111
|
//# sourceMappingURL=grant-ceiling.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"grant-ceiling.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-ceiling.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"grant-ceiling.d.ts","sourceRoot":"","sources":["../../../src/features/authz/grant-ceiling.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9D,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAMtE,6FAA6F;AAC7F,qBAAa,iBAAkB,SAAQ,KAAK;gBAC9B,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM;CAI1C;AAED,sGAAsG;AACtG,wBAAgB,SAAS,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,MAAM,EAAE,CAE7D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CACvB,SAAS,EAAE,WAAW,CAAC,MAAM,CAAC,EAC9B,cAAc,EAAE,SAAS,MAAM,EAAE,EACjC,OAAO,EAAE,MAAM,GACd,OAAO,CAST;AAED;;;GAGG;AACH,qBAAa,YAAY;IAIrB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAQzB,+FAA+F;IAC/F,OAAO,CAAC,QAAQ,CAAC,GAAG;IAbtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoB;gBAGzB,IAAI,EAAE,IAAI,EACV,QAAQ,EAAE,cAAc;IACzC;;;;;OAKG;IACH,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI;IACjC,+FAA+F;IAC9E,GAAG,GAAE,MAAM,MAAiB;IAU/C,+FAA+F;IAC/F,MAAM,CAAC,kBAAkB,CAAC,CAAC,EAAE,WAAW,GAAG,IAAI;IAS/C,qFAAqF;IACrF,MAAM,CAAC,eAAe,CAAC,CAAC,EAAE,WAAW,GAAG,IAAI;IAQ5C,oEAAoE;IACpE,OAAO,CAAC,cAAc;IAItB,+FAA+F;YACjF,cAAc;IAU5B,+FAA+F;YACjF,KAAK;IASnB;;;;;OAKG;IACG,aAAa,CAAC,OAAO,EAAE,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAkB7D;;;;OAIG;IACG,cAAc,CAAC,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAahF;;;;;OAKG;IACG,oBAAoB,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBvF;;;;;;OAMG;IACG,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAwD5E,+FAA+F;IACzF,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAI3E;;;;;OAKG;IACG,uBAAuB,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKhF;;;;OAIG;IACG,oBAAoB,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAI9E"}
|
|
@@ -1,18 +1,26 @@
|
|
|
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");
|
|
7
10
|
const AUTHZ = 'shield_authz';
|
|
11
|
+
const APP = 'shield_app';
|
|
8
12
|
/** The grantor exceeds their own authority — cannot grant a key they do not hold (→ 403). */
|
|
9
13
|
class GrantCeilingError extends Error {
|
|
10
|
-
constructor(key) {
|
|
11
|
-
super(`You cannot grant '${key}' — it exceeds the permissions you hold.`);
|
|
14
|
+
constructor(key, message) {
|
|
15
|
+
super(message ?? `You cannot grant '${key}' — it exceeds the permissions you hold.`);
|
|
12
16
|
this.name = 'GrantCeilingError';
|
|
13
17
|
}
|
|
14
18
|
}
|
|
15
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
|
+
}
|
|
16
24
|
/**
|
|
17
25
|
* Does `effective` (the grantor's held EXACT keys) cover `granted`?
|
|
18
26
|
*
|
|
@@ -44,12 +52,61 @@ class GrantCeiling {
|
|
|
44
52
|
pool;
|
|
45
53
|
registry;
|
|
46
54
|
now;
|
|
55
|
+
rootIds;
|
|
47
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,
|
|
48
64
|
/** Injectable clock (epoch-ms) for the posting effective-window read; defaults to Date.now. */
|
|
49
65
|
now = Date.now) {
|
|
50
66
|
this.pool = pool;
|
|
51
67
|
this.registry = registry;
|
|
52
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
|
+
}
|
|
53
110
|
}
|
|
54
111
|
/**
|
|
55
112
|
* The grantor's EFFECTIVE exact keys: the keys granted to the grantor's roles (active
|
|
@@ -90,5 +147,102 @@ class GrantCeiling {
|
|
|
90
147
|
}
|
|
91
148
|
}
|
|
92
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
|
+
}
|
|
93
247
|
}
|
|
94
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"}
|