@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.
Files changed (64) hide show
  1. package/dist/auth/account.controller.d.ts.map +1 -1
  2. package/dist/auth/account.controller.js +3 -0
  3. package/dist/auth/auth.controller.d.ts +90 -1
  4. package/dist/auth/auth.controller.d.ts.map +1 -1
  5. package/dist/auth/auth.controller.js +245 -7
  6. package/dist/auth/auth.module.d.ts +21 -0
  7. package/dist/auth/auth.module.d.ts.map +1 -1
  8. package/dist/auth/auth.module.js +28 -0
  9. package/dist/auth/dto.d.ts +32 -0
  10. package/dist/auth/dto.d.ts.map +1 -1
  11. package/dist/auth/ports/membership-checker.d.ts +41 -0
  12. package/dist/auth/ports/membership-checker.d.ts.map +1 -0
  13. package/dist/auth/ports/membership-checker.js +32 -0
  14. package/dist/auth/ports/scope-subtree-resolver.d.ts +48 -0
  15. package/dist/auth/ports/scope-subtree-resolver.d.ts.map +1 -0
  16. package/dist/auth/ports/scope-subtree-resolver.js +38 -0
  17. package/dist/auth/tokens.d.ts +25 -0
  18. package/dist/auth/tokens.d.ts.map +1 -1
  19. package/dist/auth/tokens.js +26 -1
  20. package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
  21. package/dist/authentication/mariadb-profile-writer.js +10 -1
  22. package/dist/authz/list-scope-sql.d.ts +125 -0
  23. package/dist/authz/list-scope-sql.d.ts.map +1 -0
  24. package/dist/authz/list-scope-sql.js +477 -0
  25. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +28 -0
  26. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
  27. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +49 -0
  28. package/dist/authz/mariadb-list-scope-adapter.d.ts +61 -0
  29. package/dist/authz/mariadb-list-scope-adapter.d.ts.map +1 -0
  30. package/dist/authz/mariadb-list-scope-adapter.js +157 -0
  31. package/dist/authz/mariadb-policy-store.d.ts +118 -0
  32. package/dist/authz/mariadb-policy-store.d.ts.map +1 -0
  33. package/dist/authz/mariadb-policy-store.js +225 -0
  34. package/dist/authz/mariadb-record-rule-adapter.d.ts +7 -3
  35. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
  36. package/dist/authz/mariadb-record-rule-adapter.js +156 -13
  37. package/dist/authz/record-share-writer.d.ts +22 -0
  38. package/dist/authz/record-share-writer.d.ts.map +1 -1
  39. package/dist/authz/record-share-writer.js +41 -0
  40. package/dist/authz/wire-record-authorization.d.ts +35 -1
  41. package/dist/authz/wire-record-authorization.d.ts.map +1 -1
  42. package/dist/authz/wire-record-authorization.js +75 -0
  43. package/dist/identity/identity-actions.d.ts +7 -0
  44. package/dist/identity/identity-actions.d.ts.map +1 -1
  45. package/dist/identity/identity-actions.js +8 -1
  46. package/dist/index.d.ts +7 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +47 -3
  49. package/dist/module/shield.module.d.ts +33 -0
  50. package/dist/module/shield.module.d.ts.map +1 -1
  51. package/dist/module/shield.module.js +12 -0
  52. package/dist/request-auth/request-principal.d.ts.map +1 -1
  53. package/dist/request-auth/request-principal.js +12 -0
  54. package/dist/request-auth/session-token-binder.d.ts +35 -1
  55. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  56. package/dist/request-auth/session-token-binder.js +123 -8
  57. package/dist/scope/closure-builder.d.ts +75 -0
  58. package/dist/scope/closure-builder.d.ts.map +1 -0
  59. package/dist/scope/closure-builder.js +134 -0
  60. package/migrations/1710000000009-CreateRecordPermissionRule.cjs +56 -0
  61. package/migrations/1710000000014-CreateRecordShare.cjs +49 -0
  62. package/migrations/1710000000015-CreateScopeClosure.cjs +46 -0
  63. package/migrations/1710000000016-AlterCompanyParentFkRestrict.cjs +71 -0
  64. 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.5.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/core": "1.0.0",
28
- "@astram/engine": "0.1.3"
27
+ "@astram/engine": "0.2.0",
28
+ "@astram/core": "1.2.0"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@nestjs/common": "^11",