@astram/guard 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +3 -0
- package/dist/auth/auth.controller.d.ts +90 -1
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +245 -7
- package/dist/auth/auth.module.d.ts +21 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +28 -0
- package/dist/auth/dto.d.ts +32 -0
- package/dist/auth/dto.d.ts.map +1 -1
- package/dist/auth/ports/membership-checker.d.ts +41 -0
- package/dist/auth/ports/membership-checker.d.ts.map +1 -0
- package/dist/auth/ports/membership-checker.js +32 -0
- package/dist/auth/ports/scope-subtree-resolver.d.ts +48 -0
- package/dist/auth/ports/scope-subtree-resolver.d.ts.map +1 -0
- package/dist/auth/ports/scope-subtree-resolver.js +38 -0
- package/dist/auth/tokens.d.ts +25 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +26 -1
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.js +10 -1
- package/dist/authz/list-scope-sql.d.ts +125 -0
- package/dist/authz/list-scope-sql.d.ts.map +1 -0
- package/dist/authz/list-scope-sql.js +477 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +28 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +49 -0
- package/dist/authz/mariadb-list-scope-adapter.d.ts +61 -0
- package/dist/authz/mariadb-list-scope-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-list-scope-adapter.js +157 -0
- package/dist/authz/mariadb-policy-store.d.ts +118 -0
- package/dist/authz/mariadb-policy-store.d.ts.map +1 -0
- package/dist/authz/mariadb-policy-store.js +225 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts +7 -3
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-rule-adapter.js +156 -13
- package/dist/authz/record-share-writer.d.ts +22 -0
- package/dist/authz/record-share-writer.d.ts.map +1 -1
- package/dist/authz/record-share-writer.js +41 -0
- package/dist/authz/wire-record-authorization.d.ts +35 -1
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +75 -0
- package/dist/identity/identity-actions.d.ts +7 -0
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/identity-actions.js +8 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -3
- package/dist/module/shield.module.d.ts +33 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +12 -0
- package/dist/request-auth/request-principal.d.ts.map +1 -1
- package/dist/request-auth/request-principal.js +12 -0
- package/dist/request-auth/session-token-binder.d.ts +35 -1
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +123 -8
- package/dist/scope/closure-builder.d.ts +75 -0
- package/dist/scope/closure-builder.d.ts.map +1 -0
- package/dist/scope/closure-builder.js +134 -0
- package/migrations/1710000000009-CreateRecordPermissionRule.cjs +56 -0
- package/migrations/1710000000014-CreateRecordShare.cjs +49 -0
- package/migrations/1710000000015-CreateScopeClosure.cjs +46 -0
- package/migrations/1710000000016-AlterCompanyParentFkRestrict.cjs +71 -0
- package/package.json +3 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// astram-framework/packages/guard/src/scope/closure-builder.ts
|
|
3
|
+
/**
|
|
4
|
+
* `buildClosureRows` — the PURE, data-foundation half of Task 1.1 (Phase 1 of the
|
|
5
|
+
* scoped-hierarchy tenancy program). Turns a node forest's parent edges into the flat
|
|
6
|
+
* ancestor/descendant/depth rows the `shield_authz.scope_closure` table stores, so any
|
|
7
|
+
* subtree query (`WHERE ancestor_uuid = N`) or ancestry query (`WHERE descendant_uuid = N`)
|
|
8
|
+
* is a flat, indexed lookup — NEVER a recursive walk on any request-serving query path.
|
|
9
|
+
*
|
|
10
|
+
* This module does NO I/O. The Task 1.2 reconciler is the one that calls this against real
|
|
11
|
+
* node edges (read from the DB) and writes the resulting rows back to `scope_closure`; this
|
|
12
|
+
* file only computes the correct row set from an in-memory edge list.
|
|
13
|
+
*
|
|
14
|
+
* ── SEMANTICS ───────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* • EVERY node gets a self-row `{ancestor: n, descendant: n, depth: 0}` — including a
|
|
16
|
+
* root (parent === null) and including a node with NO children.
|
|
17
|
+
* • For each node, walking UP its parent chain, every ancestor `a` at chain-distance `d`
|
|
18
|
+
* (1-based: the immediate parent is depth 1, the grandparent depth 2, ...) emits
|
|
19
|
+
* `{ancestor: a, descendant: n, depth: d}`.
|
|
20
|
+
* • A FLAT set (every node's parent is `null`) therefore degenerates to SELF-ROWS ONLY —
|
|
21
|
+
* this is the non-breaking case: a flat deployment (no hierarchy configured yet) is
|
|
22
|
+
* just a depth-1 forest of roots, and every existing single-tenant scope check
|
|
23
|
+
* (`ancestor_uuid = descendant_uuid = tenant`) continues to hold.
|
|
24
|
+
* • A FOREST (multiple independent root trees) computes each branch independently — no
|
|
25
|
+
* cross-tree rows are ever emitted.
|
|
26
|
+
*
|
|
27
|
+
* ── FAILURE MODES (all fail LOUD — a closure computed over bad data must never silently
|
|
28
|
+
* produce a wrong, over- or under-scoped row set; that would be a tenant-isolation bug) ─
|
|
29
|
+
* • CYCLE (a node reachable from itself via parent edges, direct `A→A` or indirect
|
|
30
|
+
* `A→B→A`): throws {@link ScopeCycleError}. Detected by tracking the in-progress walk
|
|
31
|
+
* set per node — if the walk revisits a node already on the CURRENT path, it's a cycle.
|
|
32
|
+
* • DEPTH EXCEEDED (a parent chain longer than `opts.maxDepth`, default 32 — a sane bound
|
|
33
|
+
* for any real org hierarchy): throws {@link ScopeDepthError}. Guards against a
|
|
34
|
+
* pathological or malformed edge set turning this into an unbounded walk.
|
|
35
|
+
* • UNKNOWN PARENT (a node's `parent` id doesn't reference any node in the SAME edge set):
|
|
36
|
+
* throws a plain `Error` — an orphaned edge is a data error (e.g. the parent was deleted
|
|
37
|
+
* without re-parenting children), never a silent skip that would misplace a subtree.
|
|
38
|
+
* • Empty input → empty output (no throw) — the vacuous case.
|
|
39
|
+
*
|
|
40
|
+
* ── DETERMINISM: output order is not semantically meaningful (the caller writes rows to a
|
|
41
|
+
* PK-indexed table), but rows are sorted (ancestor, then descendant, then depth) so tests
|
|
42
|
+
* and diffs are stable.
|
|
43
|
+
*
|
|
44
|
+
* ── COMPLEXITY: O(nodes × depth) — for each node, one iterative (non-recursive) walk up its
|
|
45
|
+
* parent chain, bounded by `maxDepth`. No recursion, so no call-stack depth risk either.
|
|
46
|
+
*/
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.ScopeUnknownParentError = exports.ScopeDepthError = exports.ScopeCycleError = void 0;
|
|
49
|
+
exports.buildClosureRows = buildClosureRows;
|
|
50
|
+
/** A node's parent chain revisits a node already on the walk — the edge set has a cycle. */
|
|
51
|
+
class ScopeCycleError extends Error {
|
|
52
|
+
constructor(node) {
|
|
53
|
+
super(`scope-closure: cycle detected while walking ancestors of node '${node}' — a node's ` +
|
|
54
|
+
`parent chain reaches back to itself. The edge set is corrupt; refusing to compute ` +
|
|
55
|
+
`a closure over it (a cycle would make ancestor/descendant scoping meaningless).`);
|
|
56
|
+
this.name = 'ScopeCycleError';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.ScopeCycleError = ScopeCycleError;
|
|
60
|
+
/** A node's parent chain exceeds the configured (or default) maximum depth. */
|
|
61
|
+
class ScopeDepthError extends Error {
|
|
62
|
+
constructor(node, maxDepth) {
|
|
63
|
+
super(`scope-closure: node '${node}' has an ancestor chain deeper than the maximum allowed ` +
|
|
64
|
+
`depth (${maxDepth}). Refusing to compute a closure — either the hierarchy is ` +
|
|
65
|
+
`pathologically deep or the edge set is malformed (e.g. hides a cycle just past the ` +
|
|
66
|
+
`bound).`);
|
|
67
|
+
this.name = 'ScopeDepthError';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.ScopeDepthError = ScopeDepthError;
|
|
71
|
+
/** A node's `parent` id does not reference any node present in the same edge set. */
|
|
72
|
+
class ScopeUnknownParentError extends Error {
|
|
73
|
+
constructor(node, parent) {
|
|
74
|
+
super(`scope-closure: node '${node}' declares parent '${parent}', which is not present in ` +
|
|
75
|
+
`the supplied edge set. An orphaned edge is a data error (e.g. the parent was ` +
|
|
76
|
+
`removed without re-parenting its children) — refusing to silently drop it from ` +
|
|
77
|
+
`the hierarchy.`);
|
|
78
|
+
this.name = 'ScopeUnknownParentError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
exports.ScopeUnknownParentError = ScopeUnknownParentError;
|
|
82
|
+
/** A sane default bound on ancestor-chain depth — comfortably beyond any real org hierarchy. */
|
|
83
|
+
const DEFAULT_MAX_DEPTH = 32;
|
|
84
|
+
/**
|
|
85
|
+
* Compute the transitive closure of a node forest from its parent edges.
|
|
86
|
+
* Pure — no I/O. See the file header for full semantics and failure modes.
|
|
87
|
+
*/
|
|
88
|
+
function buildClosureRows(edges, opts) {
|
|
89
|
+
const maxDepth = opts?.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
90
|
+
// node -> parent id (or null for a root); also doubles as the "known nodes" set.
|
|
91
|
+
const parentOf = new Map();
|
|
92
|
+
for (const edge of edges) {
|
|
93
|
+
parentOf.set(edge.node, edge.parent);
|
|
94
|
+
}
|
|
95
|
+
const rows = [];
|
|
96
|
+
for (const edge of edges) {
|
|
97
|
+
const node = edge.node;
|
|
98
|
+
// Self-row — every node is its own ancestor at depth 0.
|
|
99
|
+
rows.push({ ancestor: node, descendant: node, depth: 0 });
|
|
100
|
+
// Walk UP the parent chain, guarding against cycles (revisiting a node already on
|
|
101
|
+
// THIS walk) and excessive depth. Iterative — no recursion, no call-stack risk.
|
|
102
|
+
const onThisWalk = new Set([node]);
|
|
103
|
+
let current = node;
|
|
104
|
+
let depth = 0;
|
|
105
|
+
for (;;) {
|
|
106
|
+
const parent = parentOf.get(current);
|
|
107
|
+
if (parent === undefined) {
|
|
108
|
+
// `current` isn't a known node itself only if it was referenced as a parent but
|
|
109
|
+
// never declared as a node — guard defensively even though `current` starts as
|
|
110
|
+
// `node` (always known) and only becomes a parent id below.
|
|
111
|
+
throw new ScopeUnknownParentError(node, current);
|
|
112
|
+
}
|
|
113
|
+
if (parent === null) {
|
|
114
|
+
break; // reached a root — done walking this node's ancestors.
|
|
115
|
+
}
|
|
116
|
+
if (!parentOf.has(parent)) {
|
|
117
|
+
throw new ScopeUnknownParentError(current, parent);
|
|
118
|
+
}
|
|
119
|
+
if (onThisWalk.has(parent)) {
|
|
120
|
+
throw new ScopeCycleError(node);
|
|
121
|
+
}
|
|
122
|
+
depth += 1;
|
|
123
|
+
if (depth > maxDepth) {
|
|
124
|
+
throw new ScopeDepthError(node, maxDepth);
|
|
125
|
+
}
|
|
126
|
+
rows.push({ ancestor: parent, descendant: node, depth });
|
|
127
|
+
onThisWalk.add(parent);
|
|
128
|
+
current = parent;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return rows.sort((a, b) => a.ancestor.localeCompare(b.ancestor) ||
|
|
132
|
+
a.descendant.localeCompare(b.descendant) ||
|
|
133
|
+
a.depth - b.depth);
|
|
134
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework authz migration — `shield_authz.record_permission_rule`: the framework-defined
|
|
3
|
+
* ADMIN record-rule table (List-Scoping Engine spec §5.1 / plan Task 0.1). One row = one
|
|
4
|
+
* admin-authored (or guided-builder-authored) rule that WIDENS — or explicitly DENIES,
|
|
5
|
+
* deny-wins — the framework-owned OWNER baseline for a record type.
|
|
6
|
+
*
|
|
7
|
+
* • The OWNER baseline itself is NOT a row here — it is computed in @astram/core from
|
|
8
|
+
* each object type's access declaration (owned/system/tenantScoped). This table holds
|
|
9
|
+
* ONLY rules layered on top (e.g. "Company Admins read all inspection_reports"). There
|
|
10
|
+
* is no owner/system subject_kind row.
|
|
11
|
+
* • `rpr_policy_uuid` is a framework ADDITION beyond the ported astram3 table: it groups
|
|
12
|
+
* the N AND-composed condition-rows of one guided policy under a single policy id (a
|
|
13
|
+
* guided policy = an AND of its condition-rows, sharing this uuid). NULL for a legacy /
|
|
14
|
+
* unconditional single-row rule. Consumed by the Task 0.3 policy store + the list-scope
|
|
15
|
+
* translator (Phase 2/3) — the existing record-rule read adapter does not read this
|
|
16
|
+
* column yet, which is expected at this stage.
|
|
17
|
+
* • EMPTY today: no admin rules are seeded, so the read adapter finds nothing → the CORE
|
|
18
|
+
* owner baseline governs (owner passes; system-provenance types are grant-governed). An
|
|
19
|
+
* empty table is the correct safe default, NOT a lockout.
|
|
20
|
+
*
|
|
21
|
+
* Ported from astram3 `server/migrations/1720000000020-CreateRecordPermissionRule.js`
|
|
22
|
+
* (read-only reference — columns/indexes match exactly, plus the `rpr_policy_uuid` addition).
|
|
23
|
+
* Schema `shield_authz` is created by `1710000000006-CreateAuthzSchema.cjs` — NOT recreated
|
|
24
|
+
* here. Idempotent (CREATE TABLE IF NOT EXISTS). No hard-coded UUIDs (empty seed). `down`
|
|
25
|
+
* drops the table. Run by the DDL account.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const SCHEMA = 'shield_authz';
|
|
29
|
+
const TS = 'TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP';
|
|
30
|
+
|
|
31
|
+
async function up({ query }) {
|
|
32
|
+
await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`record_permission_rule\` (
|
|
33
|
+
rpr_uuid VARCHAR(36) NOT NULL,
|
|
34
|
+
rpr_policy_uuid VARCHAR(36) NULL, -- groups a guided policy's AND-composed condition-rows; NULL = legacy/unconditional single-row rule
|
|
35
|
+
rpr_tenant_uuid VARCHAR(36) NULL, -- NULL = global/system baseline scope
|
|
36
|
+
rpr_type_key VARCHAR(64) NOT NULL, -- object type the rule governs
|
|
37
|
+
rpr_action VARCHAR(64) NOT NULL, -- read/create/update/delete or '*'
|
|
38
|
+
rpr_effect VARCHAR(16) NOT NULL, -- 'allow' | 'deny' (deny-wins)
|
|
39
|
+
rpr_subject_kind VARCHAR(16) NOT NULL, -- 'role' | 'everyone'
|
|
40
|
+
rpr_subject_value VARCHAR(36) NULL, -- role uuid when kind='role'; else NULL
|
|
41
|
+
rpr_field VARCHAR(64) NULL, -- record field the condition tests (NULL = unconditional)
|
|
42
|
+
rpr_operator VARCHAR(32) NULL, -- one of the 14 PolicyOperator names (is_not_owner longest at 12 chars)
|
|
43
|
+
rpr_value VARCHAR(255) NULL, -- literal or token (e.g. '$actor.id')
|
|
44
|
+
rpr_created_at ${TS},
|
|
45
|
+
PRIMARY KEY (rpr_uuid),
|
|
46
|
+
INDEX idx_rpr_lookup (rpr_type_key, rpr_action),
|
|
47
|
+
INDEX idx_rpr_tenant (rpr_tenant_uuid),
|
|
48
|
+
INDEX idx_rpr_policy (rpr_policy_uuid)
|
|
49
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function down({ query }) {
|
|
53
|
+
await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`record_permission_rule\``);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { up, down };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework authz migration — `shield_authz.record_share`: the framework-defined
|
|
3
|
+
* per-record SHARE table (List-Scoping Engine spec §6.1 / plan Task 0.1). One row = one
|
|
4
|
+
* instance-level grant of an object type's record to a user/role for a single action (or
|
|
5
|
+
* `*`). A share WIDENS the record-permit tier, unioned with the owner baseline + admin
|
|
6
|
+
* rules under deny-wins.
|
|
7
|
+
*
|
|
8
|
+
* • WHO MAY CREATE a row is bounded in code (guard's RecordShareWriter, Phase 6): only
|
|
9
|
+
* the record's OWNER (fetched tenant-scoped, never trusted from input) may share an
|
|
10
|
+
* `owned`-type record. This table is the STORAGE; the write bound is code.
|
|
11
|
+
* • `rs_granted_by` records the sharer (the who-may-share audit trail).
|
|
12
|
+
* • EMPTY today: no shares are seeded — the table starts empty and the guard adapter
|
|
13
|
+
* finds no shares (the correct safe default, NOT a lockout).
|
|
14
|
+
*
|
|
15
|
+
* Ported from astram3 `server/migrations/1720000000030-CreateRecordShare.js` (read-only
|
|
16
|
+
* reference — columns/indexes match exactly). Schema `shield_authz` is created by
|
|
17
|
+
* `1710000000006-CreateAuthzSchema.cjs` — NOT recreated here. Idempotent (CREATE TABLE IF
|
|
18
|
+
* NOT EXISTS). No hard-coded UUIDs (empty seed). `down` drops the table. Run by the DDL
|
|
19
|
+
* account. No FK to `record_permission_rule` — the two tables are independent.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const SCHEMA = 'shield_authz';
|
|
23
|
+
const TS = 'TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP';
|
|
24
|
+
|
|
25
|
+
async function up({ query }) {
|
|
26
|
+
await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`record_share\` (
|
|
27
|
+
rs_uuid VARCHAR(36) NOT NULL,
|
|
28
|
+
rs_tenant_uuid VARCHAR(36) NOT NULL, -- the tenant the shared record belongs to
|
|
29
|
+
rs_type_key VARCHAR(64) NOT NULL, -- object type of the shared record
|
|
30
|
+
rs_record_uuid VARCHAR(36) NOT NULL, -- the shared record's uuid
|
|
31
|
+
rs_grantee_kind VARCHAR(16) NOT NULL, -- 'user' | 'role'
|
|
32
|
+
rs_grantee_uuid VARCHAR(36) NOT NULL, -- the grantee user/role uuid
|
|
33
|
+
rs_action VARCHAR(64) NOT NULL, -- one action per row; '*' = all actions
|
|
34
|
+
rs_granted_by VARCHAR(36) NOT NULL, -- the sharer (owner) — who-may-share audit
|
|
35
|
+
rs_created_at ${TS},
|
|
36
|
+
PRIMARY KEY (rs_uuid),
|
|
37
|
+
-- The hasShare lookup path: (tenant, type, record) narrows to a record's shares fast.
|
|
38
|
+
INDEX idx_rs_lookup (rs_tenant_uuid, rs_type_key, rs_record_uuid),
|
|
39
|
+
-- Idempotent re-share: the same (tenant, type, record, grantee, action) tuple is ONE
|
|
40
|
+
-- row — a writer's INSERT ... ON DUPLICATE KEY UPDATE keys off this unique index.
|
|
41
|
+
UNIQUE INDEX uq_rs_grant (rs_tenant_uuid, rs_type_key, rs_record_uuid, rs_grantee_kind, rs_grantee_uuid, rs_action)
|
|
42
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function down({ query }) {
|
|
46
|
+
await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`record_share\``);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { up, down };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework authz migration — `shield_authz.scope_closure`: the transitive-closure table for
|
|
3
|
+
* the N-level scoped-node hierarchy (ADR-0027 / Phase 1 of the scoped-hierarchy tenancy
|
|
4
|
+
* program, Task 1.1). One row = "descendant is reachable from ancestor at this depth" —
|
|
5
|
+
* `depth = 0` is the self-row (ancestor === descendant).
|
|
6
|
+
*
|
|
7
|
+
* • `ancestor_uuid` — a node (company) in the hierarchy.
|
|
8
|
+
* • `descendant_uuid` — a node AT OR BELOW the ancestor (self, when depth = 0).
|
|
9
|
+
* • `depth` — 0 = self, 1 = direct child, 2 = grandchild, ...
|
|
10
|
+
*
|
|
11
|
+
* WHY A CLOSURE TABLE (not a recursive walk): every scope query becomes a flat, indexed
|
|
12
|
+
* lookup instead of a recursive CTE / application-side tree walk on the request-serving
|
|
13
|
+
* path:
|
|
14
|
+
* • SUBTREE query (everything at/under node N): `WHERE ancestor_uuid = N`
|
|
15
|
+
* • ANCESTRY query (everything N is inside): `WHERE descendant_uuid = N`
|
|
16
|
+
*
|
|
17
|
+
* This table is BUILT by the Task 1.2 reconciler from the node hierarchy's parent edges
|
|
18
|
+
* (using the pure `buildClosureRows` in `src/scope/closure-builder.ts` — no raw SQL walk);
|
|
19
|
+
* it is EMPTY at creation here — no seed, no hard-coded ids. `depth` is a plain INT (not
|
|
20
|
+
* unsigned) purely to mirror the sibling migrations' column style; it is never negative in
|
|
21
|
+
* practice (enforced by the pure builder, not by a DB constraint, since MariaDB has no CHECK
|
|
22
|
+
* enforcement guarantee across all supported versions used here).
|
|
23
|
+
*
|
|
24
|
+
* Schema `shield_authz` is created by `1710000000006-CreateAuthzSchema.cjs` — NOT recreated
|
|
25
|
+
* here. Idempotent (CREATE TABLE IF NOT EXISTS); `down` drops the table. Run by the DDL
|
|
26
|
+
* account (ADR-0014 schema-change containment — this file only touches `shield_authz`, never
|
|
27
|
+
* the Application schema).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const SCHEMA = 'shield_authz';
|
|
31
|
+
|
|
32
|
+
async function up({ query }) {
|
|
33
|
+
await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`scope_closure\` (
|
|
34
|
+
ancestor_uuid VARCHAR(36) NOT NULL, -- a node (company) in the hierarchy
|
|
35
|
+
descendant_uuid VARCHAR(36) NOT NULL, -- a node at or below the ancestor (self = depth 0)
|
|
36
|
+
depth INT NOT NULL, -- 0 = self, 1 = direct child, ...
|
|
37
|
+
PRIMARY KEY (ancestor_uuid, descendant_uuid),
|
|
38
|
+
INDEX idx_scope_closure_desc (descendant_uuid)
|
|
39
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function down({ query }) {
|
|
43
|
+
await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`scope_closure\``);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { up, down };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework migration — flip `shield_app.companies.FK_companies_parent` from
|
|
3
|
+
* `ON DELETE SET NULL` to `ON DELETE RESTRICT` (Task 1.2 / E-2 of the scoped-hierarchy tenancy
|
|
4
|
+
* program, ADR-0027).
|
|
5
|
+
*
|
|
6
|
+
* WHY: the original FK (created by identity migration `...001-CreateIdentityOrgModel`) used
|
|
7
|
+
* `ON DELETE SET NULL`. Deleting a parent company would then SILENTLY null its children's
|
|
8
|
+
* `c_parent_uuid` — promoting a whole sub-org to a root of the hierarchy. That is a
|
|
9
|
+
* tenant-boundary violation (a sub-org is not a tenant root) and it desynchronizes the
|
|
10
|
+
* `scope_closure` from the tree behind the app's back. `RESTRICT` forces the DB to REFUSE a
|
|
11
|
+
* parent delete while children exist, so a deletion must go through the explicit, fail-closed
|
|
12
|
+
* `ScopeClosureService.deleteNode` path (re-parent/cascade first). Defence-in-depth: the service
|
|
13
|
+
* checks children explicitly for a clear early error; this FK makes the DB refuse regardless.
|
|
14
|
+
*
|
|
15
|
+
* The physical `companies` table lives in `shield_app` (created by identity `...001`). This
|
|
16
|
+
* migration only ALTERs a constraint — it does NOT recreate the table. Idempotent: it DROPs the
|
|
17
|
+
* constraint if present, then ADDs the RESTRICT variant (checking information_schema so a re-run
|
|
18
|
+
* is a no-op net change). `down()` restores the original SET NULL variant.
|
|
19
|
+
*
|
|
20
|
+
* Runs merged into the ONE ordered migration timeline (loader merges guard + identity dirs by
|
|
21
|
+
* timestamp); `...016` sorts strictly after `...001`, so `companies` + its FK already exist.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SCHEMA = 'shield_app';
|
|
25
|
+
const TABLE = 'companies';
|
|
26
|
+
const FK = 'FK_companies_parent';
|
|
27
|
+
const COL = 'c_parent_uuid';
|
|
28
|
+
const PK = 'c_uuid';
|
|
29
|
+
|
|
30
|
+
/** True if a FK constraint of this name exists on the table (idempotency guard). */
|
|
31
|
+
async function fkExists(query) {
|
|
32
|
+
const rows = await query(
|
|
33
|
+
`SELECT 1
|
|
34
|
+
FROM information_schema.TABLE_CONSTRAINTS
|
|
35
|
+
WHERE CONSTRAINT_SCHEMA = ?
|
|
36
|
+
AND TABLE_NAME = ?
|
|
37
|
+
AND CONSTRAINT_NAME = ?
|
|
38
|
+
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
|
39
|
+
LIMIT 1`,
|
|
40
|
+
[SCHEMA, TABLE, FK],
|
|
41
|
+
);
|
|
42
|
+
return Array.isArray(rows) && rows.length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function dropFkIfPresent(query) {
|
|
46
|
+
if (await fkExists(query)) {
|
|
47
|
+
await query(`ALTER TABLE \`${SCHEMA}\`.\`${TABLE}\` DROP FOREIGN KEY \`${FK}\``);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function up({ query }) {
|
|
52
|
+
// Drop the existing SET NULL FK (if present), re-add it as RESTRICT.
|
|
53
|
+
await dropFkIfPresent(query);
|
|
54
|
+
await query(
|
|
55
|
+
`ALTER TABLE \`${SCHEMA}\`.\`${TABLE}\`
|
|
56
|
+
ADD CONSTRAINT \`${FK}\` FOREIGN KEY (\`${COL}\`)
|
|
57
|
+
REFERENCES \`${SCHEMA}\`.\`${TABLE}\` (\`${PK}\`) ON DELETE RESTRICT`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function down({ query }) {
|
|
62
|
+
// Restore the original SET NULL behavior.
|
|
63
|
+
await dropFkIfPresent(query);
|
|
64
|
+
await query(
|
|
65
|
+
`ALTER TABLE \`${SCHEMA}\`.\`${TABLE}\`
|
|
66
|
+
ADD CONSTRAINT \`${FK}\` FOREIGN KEY (\`${COL}\`)
|
|
67
|
+
REFERENCES \`${SCHEMA}\`.\`${TABLE}\` (\`${PK}\`) ON DELETE SET NULL`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { up, down };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astram/guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Astram framework — the enforcement guard + real-world adapters (the 'hands'). One global deny-by-default gate every request passes through (ADR-0002), classification decorators, boot-pool assertion (ADR-0018), the forRoot() registration seam, and the DB/Redis/crypto adapters that connect the sealed core to a real backend. Depends on @astram/core (ADR-0003, ADR-0023).",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"argon2": "^0.41.1",
|
|
25
25
|
"ioredis": "^5.4.1",
|
|
26
26
|
"mariadb": "^3.4.0",
|
|
27
|
-
"@astram/
|
|
28
|
-
"@astram/
|
|
27
|
+
"@astram/engine": "0.2.0",
|
|
28
|
+
"@astram/core": "1.2.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"@nestjs/common": "^11",
|