@astram/engine 0.3.0 → 0.4.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 +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/pipe/context.d.ts +17 -6
- package/dist/pipe/context.d.ts.map +1 -1
- package/dist/pipe/record-engine.d.ts +37 -5
- package/dist/pipe/record-engine.d.ts.map +1 -1
- package/dist/pipe/record-engine.js +206 -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 +54 -0
- package/dist/pipe/stages/fetch-record.d.ts.map +1 -0
- package/dist/pipe/stages/fetch-record.js +101 -0
- package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
- package/dist/pipe/stages/permission-gate.js +17 -9
- 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 +2 -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
|
@@ -24,6 +24,16 @@ export declare class RecordsService {
|
|
|
24
24
|
list(typeKey: string, query: ListQuery): Promise<RecordPage>;
|
|
25
25
|
update(typeKey: string, id: RecordId, input: RecordInput): Promise<void>;
|
|
26
26
|
delete(typeKey: string, id: RecordId): Promise<void>;
|
|
27
|
+
/** Row lock inside the open `transaction(fn)` (see `RecordEngine.lockForUpdate`). */
|
|
28
|
+
lockForUpdate(typeKey: string, id: RecordId): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Run `fn` in ONE unit of work (ADR-0034): `tx` carries the same five ops bound to the request
|
|
31
|
+
* principal, and every op on it — plus any `records.*` call made from inside `fn` — shares one
|
|
32
|
+
* transaction, each still through the full gate/validate/write/audit pipe. Commits when `fn`
|
|
33
|
+
* resolves, rolls back when it throws (and when a write inside it failed, even if swallowed).
|
|
34
|
+
* Nested calls reuse the outer transaction. Fail-closed: no principal ⇒ throw before any work.
|
|
35
|
+
*/
|
|
36
|
+
transaction<T>(fn: (tx: BoundRecords) => Promise<T>): Promise<T>;
|
|
27
37
|
/**
|
|
28
38
|
* Run subsequent ops as a declared identity (an in-request act-as, or — via a
|
|
29
39
|
* registered resolver — a named service account). Explicit + auditable; never a
|
|
@@ -33,15 +43,22 @@ export declare class RecordsService {
|
|
|
33
43
|
private actor;
|
|
34
44
|
private resolveServiceAccount;
|
|
35
45
|
}
|
|
36
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The `.as(...)` bound view — same 5 ops, fixed actor. Also the handle `transaction(fn)` passes
|
|
48
|
+
* (ADR-0034): there `engine` is the transaction-bound engine, so its ops share one unit of work.
|
|
49
|
+
*/
|
|
37
50
|
export declare class BoundRecords {
|
|
38
51
|
private readonly engine;
|
|
39
52
|
private readonly actor;
|
|
40
53
|
constructor(engine: RecordEngine, actor: Principal);
|
|
54
|
+
/** Run `fn` in one unit of work as this actor; nested inside a transaction it is reused. */
|
|
55
|
+
transaction<T>(fn: (tx: BoundRecords) => Promise<T>): Promise<T>;
|
|
41
56
|
create(typeKey: string, input: RecordInput): Promise<RecordId>;
|
|
42
57
|
get(typeKey: string, id: RecordId): Promise<RecordView | null>;
|
|
43
58
|
list(typeKey: string, query: ListQuery): Promise<RecordPage>;
|
|
44
59
|
update(typeKey: string, id: RecordId, input: RecordInput): Promise<void>;
|
|
45
60
|
delete(typeKey: string, id: RecordId): Promise<void>;
|
|
61
|
+
/** Row lock inside the open transaction (see `RecordEngine.lockForUpdate`). */
|
|
62
|
+
lockForUpdate(typeKey: string, id: RecordId): Promise<void>;
|
|
46
63
|
}
|
|
47
64
|
//# sourceMappingURL=records-service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"records-service.d.ts","sourceRoot":"","sources":["../../src/pipe/records-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"records-service.d.ts","sourceRoot":"","sources":["../../src/pipe/records-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,KAAK,EACV,YAAY,EACZ,WAAW,EACX,QAAQ,EACR,UAAU,EACV,SAAS,EACT,UAAU,EACX,MAAM,aAAa,CAAC;AAGrB,0EAA0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AACD,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;;;GAMG;AACH,qBACa,cAAc;IAEA,OAAO,CAAC,QAAQ,CAAC,MAAM;IAC7B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAGrC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC;gBAJO,MAAM,EAAE,YAAY,EAC1B,GAAG,EAAE;QAAE,SAAS,CAAC,EAAE,SAAS,CAAA;KAAE,EAG/C,eAAe,CAAC,EAAE,sBAAsB,YAAA;IAWrD,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAG9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAG9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAG5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAGxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAG1D,qFAAqF;IAC/E,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjE;;;;;;OAMG;IACG,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAKtE;;;;OAIG;IACH,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY;IAKzC,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,qBAAqB;CAS9B;AAED;;;GAGG;AACH,qBAAa,YAAY;IAErB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS;IAEnC,4FAA4F;IAC5F,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAGhE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAG9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAG9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAG5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAGxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAGpD,+EAA+E;IAC/E,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;CAG5D"}
|
|
@@ -57,6 +57,21 @@ let RecordsService = class RecordsService {
|
|
|
57
57
|
async delete(typeKey, id) {
|
|
58
58
|
return this.engine.delete(typeKey, id, this.actor());
|
|
59
59
|
}
|
|
60
|
+
/** Row lock inside the open `transaction(fn)` (see `RecordEngine.lockForUpdate`). */
|
|
61
|
+
async lockForUpdate(typeKey, id) {
|
|
62
|
+
return this.engine.lockForUpdate(typeKey, id, this.actor());
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Run `fn` in ONE unit of work (ADR-0034): `tx` carries the same five ops bound to the request
|
|
66
|
+
* principal, and every op on it — plus any `records.*` call made from inside `fn` — shares one
|
|
67
|
+
* transaction, each still through the full gate/validate/write/audit pipe. Commits when `fn`
|
|
68
|
+
* resolves, rolls back when it throws (and when a write inside it failed, even if swallowed).
|
|
69
|
+
* Nested calls reuse the outer transaction. Fail-closed: no principal ⇒ throw before any work.
|
|
70
|
+
*/
|
|
71
|
+
async transaction(fn) {
|
|
72
|
+
const actor = this.actor();
|
|
73
|
+
return this.engine.transaction((tx) => fn(new BoundRecords(tx, actor)));
|
|
74
|
+
}
|
|
60
75
|
/**
|
|
61
76
|
* Run subsequent ops as a declared identity (an in-request act-as, or — via a
|
|
62
77
|
* registered resolver — a named service account). Explicit + auditable; never a
|
|
@@ -91,7 +106,10 @@ exports.RecordsService = RecordsService = __decorate([
|
|
|
91
106
|
__param(2, (0, common_1.Inject)(exports.SERVICE_ACCOUNT_RESOLVER)),
|
|
92
107
|
__metadata("design:paramtypes", [Object, Object, Object])
|
|
93
108
|
], RecordsService);
|
|
94
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* The `.as(...)` bound view — same 5 ops, fixed actor. Also the handle `transaction(fn)` passes
|
|
111
|
+
* (ADR-0034): there `engine` is the transaction-bound engine, so its ops share one unit of work.
|
|
112
|
+
*/
|
|
95
113
|
class BoundRecords {
|
|
96
114
|
engine;
|
|
97
115
|
actor;
|
|
@@ -99,10 +117,28 @@ class BoundRecords {
|
|
|
99
117
|
this.engine = engine;
|
|
100
118
|
this.actor = actor;
|
|
101
119
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
/** Run `fn` in one unit of work as this actor; nested inside a transaction it is reused. */
|
|
121
|
+
transaction(fn) {
|
|
122
|
+
return this.engine.transaction((tx) => fn(new BoundRecords(tx, this.actor)));
|
|
123
|
+
}
|
|
124
|
+
create(typeKey, input) {
|
|
125
|
+
return this.engine.create(typeKey, input, this.actor);
|
|
126
|
+
}
|
|
127
|
+
get(typeKey, id) {
|
|
128
|
+
return this.engine.get(typeKey, id, this.actor);
|
|
129
|
+
}
|
|
130
|
+
list(typeKey, query) {
|
|
131
|
+
return this.engine.list(typeKey, query, this.actor);
|
|
132
|
+
}
|
|
133
|
+
update(typeKey, id, input) {
|
|
134
|
+
return this.engine.update(typeKey, id, input, this.actor);
|
|
135
|
+
}
|
|
136
|
+
delete(typeKey, id) {
|
|
137
|
+
return this.engine.delete(typeKey, id, this.actor);
|
|
138
|
+
}
|
|
139
|
+
/** Row lock inside the open transaction (see `RecordEngine.lockForUpdate`). */
|
|
140
|
+
lockForUpdate(typeKey, id) {
|
|
141
|
+
return this.engine.lockForUpdate(typeKey, id, this.actor);
|
|
142
|
+
}
|
|
107
143
|
}
|
|
108
144
|
exports.BoundRecords = BoundRecords;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAgC,MAAM,gBAAgB,CAAC;AAC9E,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAMlE,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,
|
|
1
|
+
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAgC,MAAM,gBAAgB,CAAC;AAC9E,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAMlE,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CA2BlD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"derive-write-context.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/derive-write-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAYzD,wBAAgB,uBAAuB,IAAI,KAAK,
|
|
1
|
+
{"version":3,"file":"derive-write-context.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/derive-write-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAYzD,wBAAgB,uBAAuB,IAAI,KAAK,CAiC/C"}
|
|
@@ -52,6 +52,12 @@ function deriveWriteContextStage() {
|
|
|
52
52
|
ctx.columns[col] = trustedValue(field.trusted, ctx);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
else if (field.immutable === true && ctx.op !== 'create') {
|
|
56
|
+
// ADR-0034: a CREATE-ONLY field. Like a trusted field on update, any client value is
|
|
57
|
+
// IGNORED — the column is not written, so the stored value stands. Moving such a link
|
|
58
|
+
// (a company's parent, a membership's user/company) is a dedicated, checked operation,
|
|
59
|
+
// never a side effect of a generic patch.
|
|
60
|
+
}
|
|
55
61
|
else if (Object.prototype.hasOwnProperty.call(input, field.key)) {
|
|
56
62
|
ctx.columns[col] = input[field.key];
|
|
57
63
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage: fetch-record (update/delete on EVERY type — runs BEFORE the permission gate).
|
|
3
|
+
* Security hardening block D (#10); replaces the owner-only `fetch-owner` stage.
|
|
4
|
+
*
|
|
5
|
+
* Core's decision authority is zero-dep (no DB), so it cannot read the record it judges. This
|
|
6
|
+
* stage reads the EXISTING row, inside the pipe's unit of work, with
|
|
7
|
+
*
|
|
8
|
+
* SELECT * FROM <table> WHERE <whereById> LIMIT 1 FOR UPDATE
|
|
9
|
+
*
|
|
10
|
+
* through the SAME `whereById` the write stage uses (pk + actor-tenant clause + ADR-0030 write
|
|
11
|
+
* scope), and stashes:
|
|
12
|
+
* - `ctx.existing` — the row projected to registered field keys (the PRE-image, D9),
|
|
13
|
+
* - `ctx.ownerId` — the `trusted:'actorId'` column, when it is a string,
|
|
14
|
+
* - `ctx.recordTenantId` — the `trusted:'tenantId'` column, when it is a string.
|
|
15
|
+
* The gate passes `attributes = { ...existing, ownerId }` and `resource.tenantId =
|
|
16
|
+
* recordTenantId ?? actor.tenantId` to `decide()`, so field-conditioned rules (e.g. deny
|
|
17
|
+
* `status eq locked`) fire on update/delete exactly as they do in `list()`.
|
|
18
|
+
*
|
|
19
|
+
* ── SECURITY (fail-closed, no existence oracle):
|
|
20
|
+
* - No row ⇒ `AuthorizationDeniedError('no_record_permit')`, thrown inside the transaction
|
|
21
|
+
* (rollback: no write, no junction sync, no audit). A missing id and a foreign-tenant or
|
|
22
|
+
* foreign-subtree id both match zero rows here, so they are the same error. It is also the
|
|
23
|
+
* reason core returns for an owned record the actor does not own, which is what the
|
|
24
|
+
* previous owner pre-fetch produced for a missing owned row.
|
|
25
|
+
* - `FOR UPDATE` row-locks the pre-image until commit, so the row the gate judged is the row
|
|
26
|
+
* the write changes (no TOCTOU between gate and write).
|
|
27
|
+
* - The row is never returned to the caller; only the gate sees it.
|
|
28
|
+
* - `create` no-ops: there is no existing row (the create wall is ADR-0034, in `write.ts`).
|
|
29
|
+
*/
|
|
30
|
+
import { AuthorizationDeniedError } from '../../errors.js';
|
|
31
|
+
import type { ObjectType } from '../../types.js';
|
|
32
|
+
import type { Stage } from '../context.js';
|
|
33
|
+
/** The decision-relevant facts of one fetched row. */
|
|
34
|
+
export interface RecordFacts {
|
|
35
|
+
/** Registered fields present in the row, keyed by FIELD KEY (never the column). */
|
|
36
|
+
readonly fields: Record<string, unknown>;
|
|
37
|
+
/** The `trusted:'actorId'` column value, when the type has one and it is a string. */
|
|
38
|
+
readonly ownerId?: string;
|
|
39
|
+
/** The `trusted:'tenantId'` column value, when the type has one and it is a string. */
|
|
40
|
+
readonly tenantId?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Project a raw row to its decision facts (shared by the write pre-fetch and `get()`). */
|
|
43
|
+
export declare function recordFacts(reg: ObjectType, row: Record<string, unknown>): RecordFacts;
|
|
44
|
+
/**
|
|
45
|
+
* The `context.attributes` bag for `decide()`: every registered field by key, plus `ownerId`
|
|
46
|
+
* (the owner baseline's input). When the type has an owner column, `ownerId` is that column's
|
|
47
|
+
* string value or absent: a null/non-string owner is never passed, so core's strict `===` owner
|
|
48
|
+
* match can never spuriously succeed.
|
|
49
|
+
*/
|
|
50
|
+
export declare function decisionAttributes(reg: ObjectType, facts: Pick<RecordFacts, 'fields' | 'ownerId'>): Record<string, unknown>;
|
|
51
|
+
/** The ONE refusal for a record the actor cannot reach (missing ≡ foreign; no ids). */
|
|
52
|
+
export declare function recordNotReachable(reg: ObjectType): AuthorizationDeniedError;
|
|
53
|
+
export declare function fetchRecordStage(): Stage;
|
|
54
|
+
//# sourceMappingURL=fetch-record.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch-record.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/fetch-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,wBAAwB,EAAqB,MAAM,iBAAiB,CAAC;AAE9E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAGzD,sDAAsD;AACtD,MAAM,WAAW,WAAW;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC,sFAAsF;IACtF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,uFAAuF;IACvF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW,CAetF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,QAAQ,GAAG,SAAS,CAAC,GAC7C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB;AAED,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,UAAU,GAAG,wBAAwB,CAK5E;AAED,wBAAgB,gBAAgB,IAAI,KAAK,CAwBxC"}
|
|
@@ -0,0 +1,101 @@
|
|
|
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.
|
|
5
|
+
*
|
|
6
|
+
* Core's decision authority is zero-dep (no DB), so it cannot read the record it judges. This
|
|
7
|
+
* stage reads the EXISTING row, inside the pipe's unit of work, with
|
|
8
|
+
*
|
|
9
|
+
* SELECT * FROM <table> WHERE <whereById> LIMIT 1 FOR UPDATE
|
|
10
|
+
*
|
|
11
|
+
* through the SAME `whereById` the write stage uses (pk + actor-tenant clause + ADR-0030 write
|
|
12
|
+
* scope), and stashes:
|
|
13
|
+
* - `ctx.existing` — the row projected to registered field keys (the PRE-image, D9),
|
|
14
|
+
* - `ctx.ownerId` — the `trusted:'actorId'` column, when it is a string,
|
|
15
|
+
* - `ctx.recordTenantId` — the `trusted:'tenantId'` column, when it is a string.
|
|
16
|
+
* The gate passes `attributes = { ...existing, ownerId }` and `resource.tenantId =
|
|
17
|
+
* recordTenantId ?? actor.tenantId` to `decide()`, so field-conditioned rules (e.g. deny
|
|
18
|
+
* `status eq locked`) fire on update/delete exactly as they do in `list()`.
|
|
19
|
+
*
|
|
20
|
+
* ── SECURITY (fail-closed, no existence oracle):
|
|
21
|
+
* - No row ⇒ `AuthorizationDeniedError('no_record_permit')`, thrown inside the transaction
|
|
22
|
+
* (rollback: no write, no junction sync, no audit). A missing id and a foreign-tenant or
|
|
23
|
+
* foreign-subtree id both match zero rows here, so they are the same error. It is also the
|
|
24
|
+
* reason core returns for an owned record the actor does not own, which is what the
|
|
25
|
+
* previous owner pre-fetch produced for a missing owned row.
|
|
26
|
+
* - `FOR UPDATE` row-locks the pre-image until commit, so the row the gate judged is the row
|
|
27
|
+
* the write changes (no TOCTOU between gate and write).
|
|
28
|
+
* - The row is never returned to the caller; only the gate sees it.
|
|
29
|
+
* - `create` no-ops: there is no existing row (the create wall is ADR-0034, in `write.ts`).
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.recordFacts = recordFacts;
|
|
33
|
+
exports.decisionAttributes = decisionAttributes;
|
|
34
|
+
exports.recordNotReachable = recordNotReachable;
|
|
35
|
+
exports.fetchRecordStage = fetchRecordStage;
|
|
36
|
+
const errors_js_1 = require("../../errors.js");
|
|
37
|
+
const registry_js_1 = require("../../registry.js");
|
|
38
|
+
const where_by_id_js_1 = require("../where-by-id.js");
|
|
39
|
+
/** Project a raw row to its decision facts (shared by the write pre-fetch and `get()`). */
|
|
40
|
+
function recordFacts(reg, row) {
|
|
41
|
+
const fields = {};
|
|
42
|
+
for (const f of reg.fields) {
|
|
43
|
+
const col = (0, registry_js_1.columnOf)(f);
|
|
44
|
+
if (Object.prototype.hasOwnProperty.call(row, col))
|
|
45
|
+
fields[f.key] = row[col];
|
|
46
|
+
}
|
|
47
|
+
const ownerCol = (0, registry_js_1.ownerColumn)(reg);
|
|
48
|
+
const owner = ownerCol ? row[ownerCol] : undefined;
|
|
49
|
+
const tenantCol = (0, registry_js_1.tenantColumnOf)(reg);
|
|
50
|
+
const tenant = tenantCol ? row[tenantCol] : undefined;
|
|
51
|
+
return {
|
|
52
|
+
fields,
|
|
53
|
+
...(typeof owner === 'string' ? { ownerId: owner } : {}),
|
|
54
|
+
...(typeof tenant === 'string' ? { tenantId: tenant } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The `context.attributes` bag for `decide()`: every registered field by key, plus `ownerId`
|
|
59
|
+
* (the owner baseline's input). When the type has an owner column, `ownerId` is that column's
|
|
60
|
+
* string value or absent: a null/non-string owner is never passed, so core's strict `===` owner
|
|
61
|
+
* match can never spuriously succeed.
|
|
62
|
+
*/
|
|
63
|
+
function decisionAttributes(reg, facts) {
|
|
64
|
+
const attrs = { ...facts.fields };
|
|
65
|
+
if ((0, registry_js_1.ownerColumn)(reg) !== null) {
|
|
66
|
+
if (facts.ownerId !== undefined)
|
|
67
|
+
attrs.ownerId = facts.ownerId;
|
|
68
|
+
else
|
|
69
|
+
delete attrs.ownerId;
|
|
70
|
+
}
|
|
71
|
+
return attrs;
|
|
72
|
+
}
|
|
73
|
+
/** The ONE refusal for a record the actor cannot reach (missing ≡ foreign; no ids). */
|
|
74
|
+
function recordNotReachable(reg) {
|
|
75
|
+
return new errors_js_1.AuthorizationDeniedError('no_record_permit', `no reachable '${reg.typeKey}' record for this operation`);
|
|
76
|
+
}
|
|
77
|
+
function fetchRecordStage() {
|
|
78
|
+
return {
|
|
79
|
+
name: 'fetch-record',
|
|
80
|
+
async run(ctx) {
|
|
81
|
+
if (ctx.op === 'create')
|
|
82
|
+
return;
|
|
83
|
+
if (ctx.id === undefined)
|
|
84
|
+
throw new errors_js_1.RegistrationError(`${ctx.op} requires an id`);
|
|
85
|
+
const reg = ctx.registration;
|
|
86
|
+
const where = (0, where_by_id_js_1.whereById)(reg, ctx.actor, ctx.id);
|
|
87
|
+
const rows = (await ctx.query(`SELECT * FROM ${(0, where_by_id_js_1.qualifiedTable)(reg)} WHERE ${where.sql} LIMIT 1 FOR UPDATE`, where.params));
|
|
88
|
+
const first = Array.isArray(rows)
|
|
89
|
+
? rows[0]
|
|
90
|
+
: undefined;
|
|
91
|
+
if (first === undefined || first === null)
|
|
92
|
+
throw recordNotReachable(reg);
|
|
93
|
+
const facts = recordFacts(reg, first);
|
|
94
|
+
ctx.existing = facts.fields;
|
|
95
|
+
if (facts.ownerId !== undefined)
|
|
96
|
+
ctx.ownerId = facts.ownerId;
|
|
97
|
+
if (facts.tenantId !== undefined)
|
|
98
|
+
ctx.recordTenantId = facts.tenantId;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -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,CA+CjE"}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.permissionGateStage = permissionGateStage;
|
|
11
11
|
const errors_js_1 = require("../../errors.js");
|
|
12
|
+
const fetch_record_js_1 = require("./fetch-record.js");
|
|
12
13
|
/** Map the pipe op to the stable authorization action key. */
|
|
13
14
|
function actionKey(op) {
|
|
14
15
|
return op; // 'create' | 'update' | 'delete' — stable action keys (core §5.2)
|
|
@@ -21,19 +22,26 @@ function permissionGateStage(decision) {
|
|
|
21
22
|
const resource = {
|
|
22
23
|
level: ctx.id !== undefined ? 'record' : 'object_type',
|
|
23
24
|
typeKey: ctx.registration.typeKey,
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
25
|
+
// Block D (#10): an existing record is judged at ITS tenant (read from the row by
|
|
26
|
+
// fetch-record). A create, or a type with no tenant column, uses the actor's tenant — a
|
|
27
|
+
// write into another tenant is denied by the tenant gate downstream in decide().
|
|
28
|
+
tenantId: ctx.recordTenantId ?? ctx.actor.tenantId,
|
|
27
29
|
...(typeof ctx.id === 'string' ? { recordId: ctx.id } : {}),
|
|
28
30
|
};
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// no owner match ⇒ deny (the missing row never leaks).
|
|
31
|
+
// Block D (#10): for update/delete, fetch-record (which ran BEFORE this one) read the
|
|
32
|
+
// existing row. Every registered field (the PRE-image, D9) plus `ownerId` reaches
|
|
33
|
+
// decide(), so field-conditioned rules (e.g. deny `status eq locked`) fire here exactly
|
|
34
|
+
// as they do in list(). A missing row never reaches this stage: fetch-record threw.
|
|
34
35
|
const context = {
|
|
35
36
|
isAuthenticated: true,
|
|
36
|
-
...(ctx.
|
|
37
|
+
...(ctx.existing !== undefined
|
|
38
|
+
? {
|
|
39
|
+
attributes: (0, fetch_record_js_1.decisionAttributes)(ctx.registration, {
|
|
40
|
+
fields: ctx.existing,
|
|
41
|
+
...(ctx.ownerId !== undefined ? { ownerId: ctx.ownerId } : {}),
|
|
42
|
+
}),
|
|
43
|
+
}
|
|
44
|
+
: {}),
|
|
37
45
|
};
|
|
38
46
|
let outcome;
|
|
39
47
|
try {
|
|
@@ -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
|
+
}
|