@astram/engine 0.3.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.
Files changed (62) hide show
  1. package/dist/decorators/access-bundle.decorator.d.ts.map +1 -1
  2. package/dist/decorators/object-type.decorator.d.ts +2 -0
  3. package/dist/decorators/object-type.decorator.d.ts.map +1 -1
  4. package/dist/decorators/object-type.decorator.js +3 -0
  5. package/dist/index.d.ts +8 -6
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +6 -6
  8. package/dist/internal.d.ts +10 -0
  9. package/dist/internal.d.ts.map +1 -0
  10. package/dist/internal.js +13 -0
  11. package/dist/pipe/context.d.ts +17 -6
  12. package/dist/pipe/context.d.ts.map +1 -1
  13. package/dist/pipe/record-engine.d.ts +38 -5
  14. package/dist/pipe/record-engine.d.ts.map +1 -1
  15. package/dist/pipe/record-engine.js +210 -75
  16. package/dist/pipe/records-service.d.ts +18 -1
  17. package/dist/pipe/records-service.d.ts.map +1 -1
  18. package/dist/pipe/records-service.js +42 -6
  19. package/dist/pipe/stages/audit.d.ts.map +1 -1
  20. package/dist/pipe/stages/derive-write-context.d.ts.map +1 -1
  21. package/dist/pipe/stages/derive-write-context.js +6 -0
  22. package/dist/pipe/stages/fetch-record.d.ts +56 -0
  23. package/dist/pipe/stages/fetch-record.d.ts.map +1 -0
  24. package/dist/pipe/stages/fetch-record.js +103 -0
  25. package/dist/pipe/stages/permission-gate.d.ts +29 -0
  26. package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
  27. package/dist/pipe/stages/permission-gate.js +80 -22
  28. package/dist/pipe/stages/write.d.ts.map +1 -1
  29. package/dist/pipe/stages/write.js +75 -52
  30. package/dist/pipe/where-by-id.d.ts +33 -0
  31. package/dist/pipe/where-by-id.d.ts.map +1 -0
  32. package/dist/pipe/where-by-id.js +69 -0
  33. package/dist/registry.d.ts +8 -1
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +25 -1
  36. package/dist/types.d.ts +59 -3
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +13 -2
  39. package/dist/adapters/mariadb-audit-sink.js.map +0 -1
  40. package/dist/adapters/mariadb-unit-of-work.js.map +0 -1
  41. package/dist/decorators/access-bundle.decorator.js.map +0 -1
  42. package/dist/decorators/object-type.decorator.js.map +0 -1
  43. package/dist/errors.js.map +0 -1
  44. package/dist/index.js.map +0 -1
  45. package/dist/module/engine.module.js.map +0 -1
  46. package/dist/module/tokens.js.map +0 -1
  47. package/dist/pipe/context.js.map +0 -1
  48. package/dist/pipe/record-engine.js.map +0 -1
  49. package/dist/pipe/records-service.js.map +0 -1
  50. package/dist/pipe/stages/audit.js.map +0 -1
  51. package/dist/pipe/stages/derive-write-context.js.map +0 -1
  52. package/dist/pipe/stages/fetch-owner.d.ts +0 -28
  53. package/dist/pipe/stages/fetch-owner.d.ts.map +0 -1
  54. package/dist/pipe/stages/fetch-owner.js +0 -99
  55. package/dist/pipe/stages/fetch-owner.js.map +0 -1
  56. package/dist/pipe/stages/junction-sync.js.map +0 -1
  57. package/dist/pipe/stages/permission-gate.js.map +0 -1
  58. package/dist/pipe/stages/validate.js.map +0 -1
  59. package/dist/pipe/stages/write.js.map +0 -1
  60. package/dist/ports.js.map +0 -1
  61. package/dist/registry.js.map +0 -1
  62. package/dist/types.js.map +0 -1
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ /**
3
+ * Stage: fetch-record (update/delete on EVERY type — runs BEFORE the permission gate).
4
+ * Security hardening block D (#10); replaces the owner-only `fetch-owner` stage. Since Phase 8
5
+ * #7 it runs AFTER `type-pre-gate` (permission-gate.ts), so an actor with no permission on the
6
+ * type is denied before this stage can take a row lock.
7
+ *
8
+ * Core's decision authority is zero-dep (no DB), so it cannot read the record it judges. This
9
+ * stage reads the EXISTING row, inside the pipe's unit of work, with
10
+ *
11
+ * SELECT * FROM <table> WHERE <whereById> LIMIT 1 FOR UPDATE
12
+ *
13
+ * through the SAME `whereById` the write stage uses (pk + actor-tenant clause + ADR-0030 write
14
+ * scope), and stashes:
15
+ * - `ctx.existing` — the row projected to registered field keys (the PRE-image, D9),
16
+ * - `ctx.ownerId` — the `trusted:'actorId'` column, when it is a string,
17
+ * - `ctx.recordTenantId` — the `trusted:'tenantId'` column, when it is a string.
18
+ * The gate passes `attributes = { ...existing, ownerId }` and `resource.tenantId =
19
+ * recordTenantId ?? actor.tenantId` to `decide()`, so field-conditioned rules (e.g. deny
20
+ * `status eq locked`) fire on update/delete exactly as they do in `list()`.
21
+ *
22
+ * ── SECURITY (fail-closed, no existence oracle):
23
+ * - No row ⇒ `AuthorizationDeniedError('no_record_permit')`, thrown inside the transaction
24
+ * (rollback: no write, no junction sync, no audit). A missing id and a foreign-tenant or
25
+ * foreign-subtree id both match zero rows here, so they are the same error. It is also the
26
+ * reason core returns for an owned record the actor does not own, which is what the
27
+ * previous owner pre-fetch produced for a missing owned row.
28
+ * - `FOR UPDATE` row-locks the pre-image until commit, so the row the gate judged is the row
29
+ * the write changes (no TOCTOU between gate and write).
30
+ * - The row is never returned to the caller; only the gate sees it.
31
+ * - `create` no-ops: there is no existing row (the create wall is ADR-0034, in `write.ts`).
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.recordFacts = recordFacts;
35
+ exports.decisionAttributes = decisionAttributes;
36
+ exports.recordNotReachable = recordNotReachable;
37
+ exports.fetchRecordStage = fetchRecordStage;
38
+ const errors_js_1 = require("../../errors.js");
39
+ const registry_js_1 = require("../../registry.js");
40
+ const where_by_id_js_1 = require("../where-by-id.js");
41
+ /** Project a raw row to its decision facts (shared by the write pre-fetch and `get()`). */
42
+ function recordFacts(reg, row) {
43
+ const fields = {};
44
+ for (const f of reg.fields) {
45
+ const col = (0, registry_js_1.columnOf)(f);
46
+ if (Object.prototype.hasOwnProperty.call(row, col))
47
+ fields[f.key] = row[col];
48
+ }
49
+ const ownerCol = (0, registry_js_1.ownerColumn)(reg);
50
+ const owner = ownerCol ? row[ownerCol] : undefined;
51
+ const tenantCol = (0, registry_js_1.tenantColumnOf)(reg);
52
+ const tenant = tenantCol ? row[tenantCol] : undefined;
53
+ return {
54
+ fields,
55
+ ...(typeof owner === 'string' ? { ownerId: owner } : {}),
56
+ ...(typeof tenant === 'string' ? { tenantId: tenant } : {}),
57
+ };
58
+ }
59
+ /**
60
+ * The `context.attributes` bag for `decide()`: every registered field by key, plus `ownerId`
61
+ * (the owner baseline's input). When the type has an owner column, `ownerId` is that column's
62
+ * string value or absent: a null/non-string owner is never passed, so core's strict `===` owner
63
+ * match can never spuriously succeed.
64
+ */
65
+ function decisionAttributes(reg, facts) {
66
+ const attrs = { ...facts.fields };
67
+ if ((0, registry_js_1.ownerColumn)(reg) !== null) {
68
+ if (facts.ownerId !== undefined)
69
+ attrs.ownerId = facts.ownerId;
70
+ else
71
+ delete attrs.ownerId;
72
+ }
73
+ return attrs;
74
+ }
75
+ /** The ONE refusal for a record the actor cannot reach (missing ≡ foreign; no ids). */
76
+ function recordNotReachable(reg) {
77
+ return new errors_js_1.AuthorizationDeniedError('no_record_permit', `no reachable '${reg.typeKey}' record for this operation`);
78
+ }
79
+ function fetchRecordStage() {
80
+ return {
81
+ name: 'fetch-record',
82
+ async run(ctx) {
83
+ if (ctx.op === 'create')
84
+ return;
85
+ if (ctx.id === undefined)
86
+ throw new errors_js_1.RegistrationError(`${ctx.op} requires an id`);
87
+ const reg = ctx.registration;
88
+ const where = (0, where_by_id_js_1.whereById)(reg, ctx.actor, ctx.id);
89
+ const rows = (await ctx.query(`SELECT * FROM ${(0, where_by_id_js_1.qualifiedTable)(reg)} WHERE ${where.sql} LIMIT 1 FOR UPDATE`, where.params));
90
+ const first = Array.isArray(rows)
91
+ ? rows[0]
92
+ : undefined;
93
+ if (first === undefined || first === null)
94
+ throw recordNotReachable(reg);
95
+ const facts = recordFacts(reg, first);
96
+ ctx.existing = facts.fields;
97
+ if (facts.ownerId !== undefined)
98
+ ctx.ownerId = facts.ownerId;
99
+ if (facts.tenantId !== undefined)
100
+ ctx.recordTenantId = facts.tenantId;
101
+ },
102
+ };
103
+ }
@@ -8,4 +8,33 @@
8
8
  import type { DecisionPort } from '../../ports.js';
9
9
  import type { Stage } from '../context.js';
10
10
  export declare function permissionGateStage(decision: DecisionPort): Stage;
11
+ /**
12
+ * Stage: type pre-gate (update/delete only; runs FIRST, before fetch-record's row lock).
13
+ * Security-hardening Phase 8 #7.
14
+ *
15
+ * fetch-record takes `SELECT * … FOR UPDATE` so the gate judges the row the write changes; but
16
+ * run before any check, that let an actor with NO permission on the type take row locks. This
17
+ * stage asks the same `decide()` the coarse question first — `{ level: 'object_type', typeKey,
18
+ * tenantId: actor.tenantId }` for the op's action, no attributes — and stops the pipe on deny
19
+ * BEFORE any SQL. On allow, fetch-record locks and the record-level permission gate runs
20
+ * unchanged.
21
+ *
22
+ * ── It never denies what the record-level gate would allow. In core's one decision path the
23
+ * record-level and object-type-level calls differ only after gate 5 (record ⇒ gate 6 runs;
24
+ * object type ⇒ allow terminates), and every input gates 0–5 and 7 read is identical here:
25
+ * - typeKey and action: identical;
26
+ * - resource.tenantId: the record gate uses the row's tenant column, and `whereById`
27
+ * pins that column to `actor.tenantId` (a type without one falls back to it) — so it is
28
+ * always `actor.tenantId`, exactly what this stage sends (gates 3 and 5 agree);
29
+ * - gate 5's coarse key and root-plane rule depend only on (typeKey, action, principal);
30
+ * - gate 7 (delegation) reads only the `shield.delegation` attribute, which the engine
31
+ * never supplies (the record gate's attributes are registered field values).
32
+ * So a deny here is a deny at gate ≤ 5 (or 7) that the record-level call reaches too.
33
+ *
34
+ * ── Existence: it runs before any read, so its answer cannot depend on whether the id exists
35
+ * (a missing and an existing id give the same error). It throws the decision's own reason,
36
+ * as the record gate would for the same actor. `create` no-ops: the permission gate already
37
+ * judges a create at object-type level, and a create takes no lock.
38
+ */
39
+ export declare function typePreGateStage(decision: DecisionPort): Stage;
11
40
  //# sourceMappingURL=permission-gate.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"permission-gate.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/permission-gate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAOlE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,YAAY,GAAG,KAAK,CAwCjE"}
1
+ {"version":3,"file":"permission-gate.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/permission-gate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAQlE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,YAAY,GAAG,KAAK,CAiCjE;AA2BD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,YAAY,GAAG,KAAK,CAe9D"}
@@ -8,7 +8,9 @@
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.permissionGateStage = permissionGateStage;
11
+ exports.typePreGateStage = typePreGateStage;
11
12
  const errors_js_1 = require("../../errors.js");
13
+ const fetch_record_js_1 = require("./fetch-record.js");
12
14
  /** Map the pipe op to the stable authorization action key. */
13
15
  function actionKey(op) {
14
16
  return op; // 'create' | 'update' | 'delete' — stable action keys (core §5.2)
@@ -21,33 +23,89 @@ function permissionGateStage(decision) {
21
23
  const resource = {
22
24
  level: ctx.id !== undefined ? 'record' : 'object_type',
23
25
  typeKey: ctx.registration.typeKey,
24
- // The resource's owning tenant is the ACTOR's tenant for a write — a write
25
- // into another tenant is denied by the tenant gate downstream in decide().
26
- tenantId: ctx.actor.tenantId,
26
+ // Block D (#10): an existing record is judged at ITS tenant (read from the row by
27
+ // fetch-record). A create, or a type with no tenant column, uses the actor's tenant — a
28
+ // write into another tenant is denied by the tenant gate downstream in decide().
29
+ tenantId: ctx.recordTenantId ?? ctx.actor.tenantId,
27
30
  ...(typeof ctx.id === 'string' ? { recordId: ctx.id } : {}),
28
31
  };
29
- // For an OWNED update/delete, the fetch-owner stage (which ran BEFORE this one)
30
- // read the existing row's owner tenant-scoped and stashed it on ctx.ownerId. Pass
31
- // it to decide() so core's owner baseline can compare it to the actor. Absent (not
32
- // owned, or the row is missing/other-tenant) ⇒ no attribute ⇒ the baseline finds
33
- // no owner match ⇒ deny (the missing row never leaks).
32
+ // Block D (#10): for update/delete, fetch-record (which ran BEFORE this one) read the
33
+ // existing row. Every registered field (the PRE-image, D9) plus `ownerId` reaches
34
+ // decide(), so field-conditioned rules (e.g. deny `status eq locked`) fire here exactly
35
+ // as they do in list(). A missing row never reaches this stage: fetch-record threw.
34
36
  const context = {
35
37
  isAuthenticated: true,
36
- ...(ctx.ownerId !== undefined ? { attributes: { ownerId: ctx.ownerId } } : {}),
38
+ ...(ctx.existing !== undefined
39
+ ? {
40
+ attributes: (0, fetch_record_js_1.decisionAttributes)(ctx.registration, {
41
+ fields: ctx.existing,
42
+ ...(ctx.ownerId !== undefined ? { ownerId: ctx.ownerId } : {}),
43
+ }),
44
+ }
45
+ : {}),
46
+ };
47
+ await decideOrThrow(decision, ctx, action, resource, context);
48
+ },
49
+ };
50
+ }
51
+ /** Ask `decide()` once; throw on deny (AuthorizationDeniedError) or a throw (unavailable). */
52
+ async function decideOrThrow(decision, ctx, action, resource, context) {
53
+ let outcome;
54
+ try {
55
+ outcome = await decision.decide(ctx.actor, action, resource, context);
56
+ }
57
+ catch (err) {
58
+ // A thrown decision = transient/unavailable (ADR-0016). Fail-closed: do NOT
59
+ // proceed; signal retryable.
60
+ throw new errors_js_1.EngineUnavailableError(`decision authority unavailable for ${ctx.op} ${ctx.registration.typeKey}: ` +
61
+ `${err.message}`);
62
+ }
63
+ if (outcome.effect !== 'allow') {
64
+ throw new errors_js_1.AuthorizationDeniedError(outcome.reason, outcome.detail);
65
+ }
66
+ }
67
+ /**
68
+ * Stage: type pre-gate (update/delete only; runs FIRST, before fetch-record's row lock).
69
+ * Security-hardening Phase 8 #7.
70
+ *
71
+ * fetch-record takes `SELECT * … FOR UPDATE` so the gate judges the row the write changes; but
72
+ * run before any check, that let an actor with NO permission on the type take row locks. This
73
+ * stage asks the same `decide()` the coarse question first — `{ level: 'object_type', typeKey,
74
+ * tenantId: actor.tenantId }` for the op's action, no attributes — and stops the pipe on deny
75
+ * BEFORE any SQL. On allow, fetch-record locks and the record-level permission gate runs
76
+ * unchanged.
77
+ *
78
+ * ── It never denies what the record-level gate would allow. In core's one decision path the
79
+ * record-level and object-type-level calls differ only after gate 5 (record ⇒ gate 6 runs;
80
+ * object type ⇒ allow terminates), and every input gates 0–5 and 7 read is identical here:
81
+ * - typeKey and action: identical;
82
+ * - resource.tenantId: the record gate uses the row's tenant column, and `whereById`
83
+ * pins that column to `actor.tenantId` (a type without one falls back to it) — so it is
84
+ * always `actor.tenantId`, exactly what this stage sends (gates 3 and 5 agree);
85
+ * - gate 5's coarse key and root-plane rule depend only on (typeKey, action, principal);
86
+ * - gate 7 (delegation) reads only the `shield.delegation` attribute, which the engine
87
+ * never supplies (the record gate's attributes are registered field values).
88
+ * So a deny here is a deny at gate ≤ 5 (or 7) that the record-level call reaches too.
89
+ *
90
+ * ── Existence: it runs before any read, so its answer cannot depend on whether the id exists
91
+ * (a missing and an existing id give the same error). It throws the decision's own reason,
92
+ * as the record gate would for the same actor. `create` no-ops: the permission gate already
93
+ * judges a create at object-type level, and a create takes no lock.
94
+ */
95
+ function typePreGateStage(decision) {
96
+ return {
97
+ name: 'type-pre-gate',
98
+ async run(ctx) {
99
+ if (ctx.op === 'create')
100
+ return;
101
+ const resource = {
102
+ level: 'object_type',
103
+ typeKey: ctx.registration.typeKey,
104
+ tenantId: ctx.actor.tenantId,
37
105
  };
38
- let outcome;
39
- try {
40
- outcome = await decision.decide(ctx.actor, action, resource, context);
41
- }
42
- catch (err) {
43
- // A thrown decision = transient/unavailable (ADR-0016). Fail-closed: do NOT
44
- // proceed; signal retryable.
45
- throw new errors_js_1.EngineUnavailableError(`decision authority unavailable for ${ctx.op} ${ctx.registration.typeKey}: ` +
46
- `${err.message}`);
47
- }
48
- if (outcome.effect !== 'allow') {
49
- throw new errors_js_1.AuthorizationDeniedError(outcome.reason, outcome.detail);
50
- }
106
+ await decideOrThrow(decision, ctx, { key: actionKey(ctx.op) }, resource, {
107
+ isAuthenticated: true,
108
+ });
51
109
  },
52
110
  };
53
111
  }
@@ -1 +1 @@
1
- {"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/write.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAkEzD,wBAAgB,UAAU,IAAI,KAAK,CAkDlC"}
1
+ {"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/write.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAsEzD,wBAAgB,UAAU,IAAI,KAAK,CAsElC"}
@@ -13,62 +13,65 @@ exports.writeStage = writeStage;
13
13
  const node_crypto_1 = require("node:crypto");
14
14
  const errors_js_1 = require("../../errors.js");
15
15
  const registry_js_1 = require("../../registry.js");
16
+ const where_by_id_js_1 = require("../where-by-id.js");
17
+ const fetch_record_js_1 = require("./fetch-record.js");
16
18
  function qualified(ctx) {
17
- const schema = ctx.registration.schema;
18
- const t = ctx.registration.table;
19
- return schema ? `\`${schema}\`.\`${t}\`` : `\`${t}\``;
20
- }
21
- /** The physical column of a `tenantId`-trusted field, if the table has one. */
22
- function tenantColumn(ctx) {
23
- const f = ctx.registration.fields.find((x) => x.trusted === 'tenantId');
24
- return f ? (0, registry_js_1.columnOf)(f) : null;
19
+ return (0, where_by_id_js_1.qualifiedTable)(ctx.registration);
25
20
  }
26
21
  /**
27
- * Build a WHERE clause + params for a record id (uuid or composite). CRITICAL
28
- * cross-tenant safety: when the table is tenant-owned (has a `tenantId`-trusted
29
- * column), the WHERE is ALSO constrained to the ACTOR's tenant — so an update/
30
- * delete can NEVER touch another tenant's row even if the id is known and the gate
31
- * (which sees the actor's own tenant on a record-scoped resource) would allow it.
32
- * This is the write-path twin of the read-path scoping (defense-in-depth).
33
- *
34
- * ADR-0030 — write-scope wall: additionally, for a type declaring `readScope` (the identity
35
- * `system` types company_membership / membership_posting, which have NO tenant column so the
36
- * clause above never fires for them), a NON-ROOT actor's WHERE is constrained to rows within the
37
- * actor's reachable subtree (`writeScopeClause`). A targeted update/delete of a foreign-subtree
38
- * row then matches ZERO rows (fail-closed no-op), closing the read-wall-only asymmetry. Root
39
- * spans all. This is a FILTER, never a stamp (create is unaffected — the ADR-0027 decoupling
40
- * that lets an admin add a user to a SUB-company is preserved).
22
+ * WHERE for a record id: the ONE shared helper (`where-by-id.ts`) — pk + the ACTOR's tenant
23
+ * (when the type has a tenant column) + the ADR-0030 write-scope wall (readScope types,
24
+ * non-root). The fetch-record pre-fetch, UPDATE, DELETE and the ADR-0034 create wall all use it,
25
+ * so the row the gate judged is the row the write touches.
41
26
  */
42
27
  function whereById(ctx, id) {
43
- const pk = ctx.registration.primaryKey;
44
- let sql;
45
- let params;
46
- if (pk.kind === 'uuid') {
47
- if (typeof id !== 'string') {
48
- throw new errors_js_1.RegistrationError(`uuid PK table '${ctx.registration.typeKey}' needs a string id`);
49
- }
50
- sql = `\`${pk.column}\` = ?`;
51
- params = [id];
52
- }
53
- else {
54
- if (typeof id === 'string') {
55
- throw new errors_js_1.RegistrationError(`composite PK table '${ctx.registration.typeKey}' needs a key map, not a string id`);
56
- }
57
- sql = pk.columns.map((c) => `\`${c}\` = ?`).join(' AND ');
58
- params = pk.columns.map((c) => id[c]);
59
- }
60
- const tenantCol = tenantColumn(ctx);
61
- if (tenantCol) {
62
- sql += ` AND \`${tenantCol}\` = ?`;
63
- params = [...params, ctx.actor.tenantId];
64
- }
65
- // ADR-0030 write-scope wall (readScope types, non-root only; null otherwise).
66
- const scope = (0, registry_js_1.writeScopeClause)(ctx.registration, ctx.actor);
67
- if (scope) {
68
- sql += ` AND ${scope.sql}`;
69
- params = [...params, ...scope.params];
28
+ return (0, where_by_id_js_1.whereById)(ctx.registration, ctx.actor, id);
29
+ }
30
+ /** `affectedRows` of a driver OK packet, or `undefined` when the result carries none. */
31
+ function affectedRowsOf(result) {
32
+ if (typeof result !== 'object' || result === null)
33
+ return undefined;
34
+ const n = result.affectedRows;
35
+ if (typeof n === 'number')
36
+ return n;
37
+ if (typeof n === 'bigint')
38
+ return Number(n);
39
+ return undefined;
40
+ }
41
+ /**
42
+ * ADR-0034 — the CREATE-time write-scope wall (amends ADR-0030, which walled update/delete only).
43
+ * For a `readScope` type that has not opted out (`create: 'unscoped'`), a NON-ROOT create is
44
+ * re-read inside the SAME unit of work right after the INSERT, through the SAME `whereById`
45
+ * (pk + tenant clause + `writeScopeClause`) that update/delete use. Zero rows ⇒ the new row lies
46
+ * outside the actor's reachable subtree ⇒ `AuthorizationDeniedError`, and the pipe's transaction
47
+ * rolls the INSERT back (no row, no audit). A post-insert check — rather than inspecting input
48
+ * values — covers both `nodeColumn` and `subtreeSubquery` scopes with one predicate and sees the
49
+ * row exactly as stored. Root (`writeScopeClause` ⇒ null) is not checked. The error carries no
50
+ * ids (no existence leak, Invariant #10).
51
+ */
52
+ async function assertCreateInsideWriteScope(ctx, table) {
53
+ if (!(0, registry_js_1.isCreateWalled)(ctx.registration))
54
+ return;
55
+ if ((0, registry_js_1.writeScopeClause)(ctx.registration, ctx.actor) === null)
56
+ return; // root spans all
57
+ if (ctx.resultId === undefined) {
58
+ throw new errors_js_1.RegistrationError(`create '${ctx.registration.typeKey}': no id to wall-check`);
70
59
  }
71
- return { sql, params };
60
+ const where = whereById(ctx, ctx.resultId);
61
+ const rows = (await ctx.query(`SELECT 1 AS in_scope FROM ${table} WHERE ${where.sql} LIMIT 1`, where.params));
62
+ if (!Array.isArray(rows) || rows.length === 0)
63
+ throw outsideWriteScope(ctx);
64
+ }
65
+ /** The ONE create-wall refusal (same reason, same message for every cause — no oracle). */
66
+ function outsideWriteScope(ctx) {
67
+ return new errors_js_1.AuthorizationDeniedError('create_outside_write_scope', `a new '${ctx.registration.typeKey}' must lie inside the actor's reachable subtree`);
68
+ }
69
+ /** A MariaDB/MySQL "referenced row does not exist" foreign-key violation (errno 1452). */
70
+ function isMissingReferenceError(err) {
71
+ if (typeof err !== 'object' || err === null)
72
+ return false;
73
+ const e = err;
74
+ return (e.errno === 1452 || e.code === 'ER_NO_REFERENCED_ROW_2' || e.code === 'ER_NO_REFERENCED_ROW');
72
75
  }
73
76
  function writeStage() {
74
77
  return {
@@ -96,7 +99,22 @@ function writeStage() {
96
99
  }
97
100
  const placeholders = cols.map(() => '?').join(', ');
98
101
  const sql = `INSERT INTO ${table} (${cols.map((c) => `\`${c}\``).join(', ')}) VALUES (${placeholders})`;
99
- await ctx.query(sql, cols.map((c) => ctx.columns[c]));
102
+ try {
103
+ await ctx.query(sql, cols.map((c) => ctx.columns[c]));
104
+ }
105
+ catch (err) {
106
+ // Existence oracle (security review): on a WALLED non-root create, a reference to a
107
+ // node that does not exist fails the foreign key BEFORE the wall check runs. Answer it
108
+ // exactly like a node outside the subtree, so "missing" and "foreign" look the same.
109
+ // Root and unwalled types keep the driver error.
110
+ if (isMissingReferenceError(err) &&
111
+ (0, registry_js_1.isCreateWalled)(ctx.registration) &&
112
+ (0, registry_js_1.writeScopeClause)(ctx.registration, ctx.actor) !== null) {
113
+ throw outsideWriteScope(ctx);
114
+ }
115
+ throw err;
116
+ }
117
+ await assertCreateInsideWriteScope(ctx, table);
100
118
  return;
101
119
  }
102
120
  if (ctx.op === 'update') {
@@ -116,7 +134,12 @@ function writeStage() {
116
134
  if (ctx.id === undefined)
117
135
  throw new errors_js_1.RegistrationError('delete requires an id');
118
136
  const where = whereById(ctx, ctx.id);
119
- await ctx.query(`DELETE FROM ${table} WHERE ${where.sql}`, where.params);
137
+ const result = await ctx.query(`DELETE FROM ${table} WHERE ${where.sql}`, where.params);
138
+ // Review H2: a delete that removed nothing must not go on to junction sync or audit. The
139
+ // fetch-record stage already row-locked the target, so this is defence in depth; it throws
140
+ // the same no-id refusal as a missing row (rollback).
141
+ if (affectedRowsOf(result) === 0)
142
+ throw (0, fetch_record_js_1.recordNotReachable)(ctx.registration);
120
143
  ctx.resultId = ctx.id;
121
144
  },
122
145
  };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The ONE definition of "this record, as the actor may write it" (security hardening block D).
3
+ *
4
+ * Before this file the fetch-owner stage and the write stage each carried a private copy of
5
+ * `whereById`. The record pre-fetch (`fetch-record.ts`), the UPDATE/DELETE and the ADR-0034
6
+ * post-insert create wall now all build their WHERE here, so the row the gate judges is exactly
7
+ * the row the write touches.
8
+ *
9
+ * WHERE = primary key
10
+ * + `tenant_col = actor.tenantId` when the type has a `trusted:'tenantId'` column
11
+ * + `writeScopeClause` (ADR-0030) for a `readScope` type and a non-root actor.
12
+ *
13
+ * Every identifier is the type's own registered column; every value is `?`-bound.
14
+ */
15
+ import type { ObjectType, Principal, RecordId } from '../types.js';
16
+ /** A parameterized SQL fragment. */
17
+ export interface SqlFragment {
18
+ readonly sql: string;
19
+ readonly params: unknown[];
20
+ }
21
+ /** The `schema`.`table` (or `table`) identifier of a registered type. */
22
+ export declare function qualifiedTable(reg: ObjectType): string;
23
+ /** The primary-key predicate for a record id (uuid or composite). Throws on a shape mismatch. */
24
+ export declare function pkClause(reg: ObjectType, id: RecordId): SqlFragment;
25
+ /** The primary-key column(s) of a type, in declaration order (the list tiebreaker). */
26
+ export declare function pkColumns(reg: ObjectType): readonly string[];
27
+ /**
28
+ * WHERE (pk + actor-tenant clause + ADR-0030 write-scope wall) for a WRITE-path record id. A
29
+ * foreign-tenant or foreign-subtree id matches zero rows, exactly like a missing one, so the two
30
+ * are indistinguishable to the caller.
31
+ */
32
+ export declare function whereById(reg: ObjectType, actor: Principal, id: RecordId): SqlFragment;
33
+ //# sourceMappingURL=where-by-id.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"where-by-id.d.ts","sourceRoot":"","sources":["../../src/pipe/where-by-id.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEnE,oCAAoC;AACpC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;CAC5B;AAED,yEAAyE;AACzE,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAEtD;AAED,iGAAiG;AACjG,wBAAgB,QAAQ,CAAC,GAAG,EAAE,UAAU,EAAE,EAAE,EAAE,QAAQ,GAAG,WAAW,CAiBnE;AAED,uFAAuF;AACvF,wBAAgB,SAAS,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,MAAM,EAAE,CAG5D;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,UAAU,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,QAAQ,GAAG,WAAW,CAetF"}
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /**
3
+ * The ONE definition of "this record, as the actor may write it" (security hardening block D).
4
+ *
5
+ * Before this file the fetch-owner stage and the write stage each carried a private copy of
6
+ * `whereById`. The record pre-fetch (`fetch-record.ts`), the UPDATE/DELETE and the ADR-0034
7
+ * post-insert create wall now all build their WHERE here, so the row the gate judges is exactly
8
+ * the row the write touches.
9
+ *
10
+ * WHERE = primary key
11
+ * + `tenant_col = actor.tenantId` when the type has a `trusted:'tenantId'` column
12
+ * + `writeScopeClause` (ADR-0030) for a `readScope` type and a non-root actor.
13
+ *
14
+ * Every identifier is the type's own registered column; every value is `?`-bound.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.qualifiedTable = qualifiedTable;
18
+ exports.pkClause = pkClause;
19
+ exports.pkColumns = pkColumns;
20
+ exports.whereById = whereById;
21
+ const errors_js_1 = require("../errors.js");
22
+ const registry_js_1 = require("../registry.js");
23
+ /** The `schema`.`table` (or `table`) identifier of a registered type. */
24
+ function qualifiedTable(reg) {
25
+ return reg.schema ? `\`${reg.schema}\`.\`${reg.table}\`` : `\`${reg.table}\``;
26
+ }
27
+ /** The primary-key predicate for a record id (uuid or composite). Throws on a shape mismatch. */
28
+ function pkClause(reg, id) {
29
+ const pk = reg.primaryKey;
30
+ if (pk.kind === 'uuid') {
31
+ if (typeof id !== 'string') {
32
+ throw new errors_js_1.RegistrationError(`uuid PK table '${reg.typeKey}' needs a string id`);
33
+ }
34
+ return { sql: `\`${pk.column}\` = ?`, params: [id] };
35
+ }
36
+ if (typeof id === 'string') {
37
+ throw new errors_js_1.RegistrationError(`composite PK table '${reg.typeKey}' needs a key map, not a string id`);
38
+ }
39
+ return {
40
+ sql: pk.columns.map((c) => `\`${c}\` = ?`).join(' AND '),
41
+ params: pk.columns.map((c) => id[c]),
42
+ };
43
+ }
44
+ /** The primary-key column(s) of a type, in declaration order (the list tiebreaker). */
45
+ function pkColumns(reg) {
46
+ const pk = reg.primaryKey;
47
+ return pk.kind === 'uuid' ? [pk.column] : pk.columns;
48
+ }
49
+ /**
50
+ * WHERE (pk + actor-tenant clause + ADR-0030 write-scope wall) for a WRITE-path record id. A
51
+ * foreign-tenant or foreign-subtree id matches zero rows, exactly like a missing one, so the two
52
+ * are indistinguishable to the caller.
53
+ */
54
+ function whereById(reg, actor, id) {
55
+ const pk = pkClause(reg, id);
56
+ let sql = pk.sql;
57
+ let params = pk.params;
58
+ const tenantCol = (0, registry_js_1.tenantColumnOf)(reg);
59
+ if (tenantCol) {
60
+ sql += ` AND \`${tenantCol}\` = ?`;
61
+ params = [...params, actor.tenantId];
62
+ }
63
+ const scope = (0, registry_js_1.writeScopeClause)(reg, actor);
64
+ if (scope) {
65
+ sql += ` AND ${scope.sql}`;
66
+ params = [...params, ...scope.params];
67
+ }
68
+ return { sql, params };
69
+ }
@@ -43,7 +43,8 @@ export declare function columnForRole(reg: ObjectType, role: FieldSpec['role']):
43
43
  * update/delete to rows within the actor's reachable subtree, so a targeted write to a
44
44
  * foreign-subtree row matches ZERO rows (fail-closed no-op) instead of touching it. It is a
45
45
  * FILTER, never a stamp — no column value is set, so the ADR-0027 create-stamping decoupling
46
- * (add-to-sub-company) is preserved.
46
+ * (add-to-sub-company) is preserved. ADR-0034 reuses the SAME clause for the post-insert create
47
+ * wall (`write.ts`), so update, delete and create share one definition of "inside my subtree".
47
48
  *
48
49
  * Returns `null` (nothing to AND) when:
49
50
  * - the type declares no `readScope`, OR
@@ -61,6 +62,12 @@ export declare function writeScopeClause(reg: ObjectType, actor: Principal): {
61
62
  sql: string;
62
63
  params: readonly unknown[];
63
64
  } | null;
65
+ /**
66
+ * Whether a NON-ROOT create of this type must pass the post-insert create wall (ADR-0034): the
67
+ * type declares `readScope` and has not explicitly opted out with `create: 'unscoped'`. Default
68
+ * walled (fail closed). Root is handled by {@link writeScopeClause} returning `null`.
69
+ */
70
+ export declare function isCreateWalled(reg: ObjectType): boolean;
64
71
  /** Boot-enforce that exactly-one field claims `role`; throw if none. */
65
72
  export declare function requireRole(reg: ObjectType, role: NonNullable<FieldSpec['role']>): void;
66
73
  //# sourceMappingURL=registry.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE/E,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IAExD,kFAAkF;IAClF,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI;IAQ/B,uEAAuE;IACvE,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU;IAWpC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAI7B,GAAG,IAAI,SAAS,UAAU,EAAE;CAG7B;AAED,iFAAiF;AACjF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAEjD;AAED,mEAAmE;AACnE,wBAAgB,YAAY,CAAC,EAAE,EAAE,UAAU,GAAG,MAAM,CAKnD;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAEhD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG1D;AAED,6FAA6F;AAC7F,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG7D;AAED,6EAA6E;AAC7E,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAGrF;AAaD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,SAAS,GACf;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAA;CAAE,GAAG,IAAI,CAYpD;AAED,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,CAMvF"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE/E,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IAExD,kFAAkF;IAClF,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI;IAQ/B,uEAAuE;IACvE,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU;IAWpC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAI7B,GAAG,IAAI,SAAS,UAAU,EAAE;CAG7B;AAED,iFAAiF;AACjF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAEjD;AAED,mEAAmE;AACnE,wBAAgB,YAAY,CAAC,EAAE,EAAE,UAAU,GAAG,MAAM,CAKnD;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAEhD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG1D;AAED,6FAA6F;AAC7F,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG7D;AAED,6EAA6E;AAC7E,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAGrF;AAaD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,SAAS,GACf;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAA;CAAE,GAAG,IAAI,CAYpD;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAEvD;AAED,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,CAMvF"}
package/dist/registry.js CHANGED
@@ -18,6 +18,7 @@ exports.ownerColumn = ownerColumn;
18
18
  exports.tenantColumnOf = tenantColumnOf;
19
19
  exports.columnForRole = columnForRole;
20
20
  exports.writeScopeClause = writeScopeClause;
21
+ exports.isCreateWalled = isCreateWalled;
21
22
  exports.requireRole = requireRole;
22
23
  const errors_js_1 = require("./errors.js");
23
24
  const types_js_1 = require("./types.js");
@@ -101,7 +102,8 @@ function subtreeNodesOf(actor) {
101
102
  * update/delete to rows within the actor's reachable subtree, so a targeted write to a
102
103
  * foreign-subtree row matches ZERO rows (fail-closed no-op) instead of touching it. It is a
103
104
  * FILTER, never a stamp — no column value is set, so the ADR-0027 create-stamping decoupling
104
- * (add-to-sub-company) is preserved.
105
+ * (add-to-sub-company) is preserved. ADR-0034 reuses the SAME clause for the post-insert create
106
+ * wall (`write.ts`), so update, delete and create share one definition of "inside my subtree".
105
107
  *
106
108
  * Returns `null` (nothing to AND) when:
107
109
  * - the type declares no `readScope`, OR
@@ -129,6 +131,14 @@ function writeScopeClause(reg, actor) {
129
131
  const expanded = rs.subquerySql.replace(types_js_1.READ_SCOPE_NODES_TOKEN, placeholders);
130
132
  return { sql: `\`${rs.pkColumn}\` IN (${expanded})`, params: [...nodes] };
131
133
  }
134
+ /**
135
+ * Whether a NON-ROOT create of this type must pass the post-insert create wall (ADR-0034): the
136
+ * type declares `readScope` and has not explicitly opted out with `create: 'unscoped'`. Default
137
+ * walled (fail closed). Root is handled by {@link writeScopeClause} returning `null`.
138
+ */
139
+ function isCreateWalled(reg) {
140
+ return reg.readScope !== undefined && reg.readScope.create !== 'unscoped';
141
+ }
132
142
  /** Boot-enforce that exactly-one field claims `role`; throw if none. */
133
143
  function requireRole(reg, role) {
134
144
  if (!reg.fields.some((f) => f.role === role)) {
@@ -149,6 +159,20 @@ function validate(reg) {
149
159
  throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': duplicate field key '${f.key}'`);
150
160
  }
151
161
  keys.add(f.key);
162
+ // ADR-0034: `immutable` is a security posture (create-only) — a truthy non-boolean is a
163
+ // malformed declaration, never silently read as "mutable".
164
+ const immutable = f.immutable;
165
+ if (immutable !== undefined && typeof immutable !== 'boolean') {
166
+ throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': field '${f.key}' immutable must be a boolean, got ` +
167
+ `${typeof immutable} (${String(immutable)}).`);
168
+ }
169
+ }
170
+ // ADR-0034: the create-wall mode is either absent (walled), 'walled' or 'unscoped'. Anything
171
+ // else fails boot — an opt-out is an explicit decision, never a typo.
172
+ const createMode = reg.readScope?.create;
173
+ if (createMode !== undefined && createMode !== 'walled' && createMode !== 'unscoped') {
174
+ throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': readScope.create must be 'walled' or 'unscoped', got ` +
175
+ `'${String(createMode)}'.`);
152
176
  }
153
177
  if (reg.primaryKey.kind === 'composite' && reg.primaryKey.columns.length < 2) {
154
178
  throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': composite primary key needs >= 2 columns`);