@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.
- package/dist/decorators/access-bundle.decorator.d.ts.map +1 -1
- package/dist/decorators/object-type.decorator.d.ts +2 -0
- package/dist/decorators/object-type.decorator.d.ts.map +1 -1
- package/dist/decorators/object-type.decorator.js +3 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/internal.d.ts +10 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +13 -0
- package/dist/pipe/context.d.ts +17 -6
- package/dist/pipe/context.d.ts.map +1 -1
- package/dist/pipe/record-engine.d.ts +38 -5
- package/dist/pipe/record-engine.d.ts.map +1 -1
- package/dist/pipe/record-engine.js +210 -75
- package/dist/pipe/records-service.d.ts +18 -1
- package/dist/pipe/records-service.d.ts.map +1 -1
- package/dist/pipe/records-service.js +42 -6
- package/dist/pipe/stages/audit.d.ts.map +1 -1
- package/dist/pipe/stages/derive-write-context.d.ts.map +1 -1
- package/dist/pipe/stages/derive-write-context.js +6 -0
- package/dist/pipe/stages/fetch-record.d.ts +56 -0
- package/dist/pipe/stages/fetch-record.d.ts.map +1 -0
- package/dist/pipe/stages/fetch-record.js +103 -0
- package/dist/pipe/stages/permission-gate.d.ts +29 -0
- package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
- package/dist/pipe/stages/permission-gate.js +80 -22
- package/dist/pipe/stages/write.d.ts.map +1 -1
- package/dist/pipe/stages/write.js +75 -52
- package/dist/pipe/where-by-id.d.ts +33 -0
- package/dist/pipe/where-by-id.d.ts.map +1 -0
- package/dist/pipe/where-by-id.js +69 -0
- package/dist/registry.d.ts +8 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +25 -1
- package/dist/types.d.ts +59 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +13 -2
- package/dist/adapters/mariadb-audit-sink.js.map +0 -1
- package/dist/adapters/mariadb-unit-of-work.js.map +0 -1
- package/dist/decorators/access-bundle.decorator.js.map +0 -1
- package/dist/decorators/object-type.decorator.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/module/engine.module.js.map +0 -1
- package/dist/module/tokens.js.map +0 -1
- package/dist/pipe/context.js.map +0 -1
- package/dist/pipe/record-engine.js.map +0 -1
- package/dist/pipe/records-service.js.map +0 -1
- package/dist/pipe/stages/audit.js.map +0 -1
- package/dist/pipe/stages/derive-write-context.js.map +0 -1
- package/dist/pipe/stages/fetch-owner.d.ts +0 -28
- package/dist/pipe/stages/fetch-owner.d.ts.map +0 -1
- package/dist/pipe/stages/fetch-owner.js +0 -99
- package/dist/pipe/stages/fetch-owner.js.map +0 -1
- package/dist/pipe/stages/junction-sync.js.map +0 -1
- package/dist/pipe/stages/permission-gate.js.map +0 -1
- package/dist/pipe/stages/validate.js.map +0 -1
- package/dist/pipe/stages/write.js.map +0 -1
- package/dist/ports.js.map +0 -1
- package/dist/registry.js.map +0 -1
- 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;
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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.
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
package/dist/registry.d.ts.map
CHANGED
|
@@ -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
|
|
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`);
|