@cosmicdrift/kumiko-framework 0.354.1 → 0.355.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/db/entity-field-encryption.d.ts +10 -1
- package/dist/db/entity-field-encryption.js +31 -2
- package/dist/db/event-store-executor-context.d.ts +8 -2
- package/dist/db/event-store-executor-context.js +41 -9
- package/dist/db/event-store-executor-read.js +7 -4
- package/dist/db/event-store-executor-write.js +95 -56
- package/dist/engine/build-app-schema.js +32 -5
- package/dist/engine/cross-tenant.d.ts +2 -1
- package/dist/engine/cross-tenant.js +18 -14
- package/dist/engine/entity-handlers.js +17 -16
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +7 -1
- package/dist/engine/registry-validate.js +82 -2
- package/dist/engine/registry.js +2 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/event-store/snapshot.d.ts +1 -0
- package/dist/event-store/snapshot.js +12 -3
- package/dist/jobs/job-runner.js +32 -17
- package/dist/observability/escape-hatch-report.d.ts +5 -2
- package/dist/observability/escape-hatch-report.js +52 -22
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/pipeline/dispatch-query.js +4 -0
- package/dist/pipeline/dispatch-shared.js +33 -6
- package/dist/pipeline/dispatch-stream.js +4 -0
- package/dist/pipeline/dispatcher-utils.d.ts +4 -0
- package/dist/pipeline/dispatcher-utils.js +10 -0
- package/dist/pipeline/dispatcher.js +7 -7
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/pre-handler-gates.js +4 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +4 -4
- package/src/changes.json +30 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EntityDefinition } from "../engine/types/index.js";
|
|
2
|
+
import { type Logger } from "../logging/index.js";
|
|
2
3
|
import type { EnvelopeCipher } from "../secrets/envelope-cipher.js";
|
|
3
4
|
export declare function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string>;
|
|
4
5
|
export declare function isSensitiveLabelField(entity: EntityDefinition, field: string): boolean;
|
|
@@ -6,7 +7,15 @@ export declare function hasSearchablePlaintext(entity: EntityDefinition, field:
|
|
|
6
7
|
export declare function encryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
|
|
7
8
|
onlyKeys?: Iterable<string>;
|
|
8
9
|
}): Promise<Record<string, unknown>>;
|
|
9
|
-
export declare function
|
|
10
|
+
export declare function logUndecryptableField(info: {
|
|
11
|
+
entityName: string | undefined;
|
|
12
|
+
rowId: unknown;
|
|
13
|
+
field: string;
|
|
14
|
+
error: unknown;
|
|
15
|
+
}, logger?: Logger): void;
|
|
16
|
+
export declare function decryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
|
|
17
|
+
onFieldError?: (field: string, error: unknown) => void;
|
|
18
|
+
}): Promise<Record<string, unknown>>;
|
|
10
19
|
export declare function configureEntityFieldEncryption(cipher: EnvelopeCipher | undefined): void;
|
|
11
20
|
export declare function resolveEntityFieldEncryption(): EnvelopeCipher;
|
|
12
21
|
export declare function configuredEntityFieldEncryption(): EnvelopeCipher | undefined;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
|
|
5
5
|
// the cipher's legacy fallback until re-encrypted.
|
|
6
6
|
import { collectPiiSubjectFields } from "../crypto/index.js";
|
|
7
|
+
import { createLogger } from "../logging/index.js";
|
|
7
8
|
import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider.js";
|
|
8
9
|
export function collectEncryptedFieldNames(entity) {
|
|
9
10
|
const names = new Set();
|
|
@@ -60,7 +61,25 @@ export async function encryptEntityFieldValues(row, encryptedFields, cipher, opt
|
|
|
60
61
|
}
|
|
61
62
|
return out;
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
let undecryptableFieldLogger;
|
|
65
|
+
function defaultUndecryptableFieldLogger() {
|
|
66
|
+
undecryptableFieldLogger ??= createLogger();
|
|
67
|
+
return undecryptableFieldLogger;
|
|
68
|
+
}
|
|
69
|
+
// Logs identifiers and the error class only: ciphertext, key material and the
|
|
70
|
+
// cipher's error message (which may echo envelope content) never reach the log.
|
|
71
|
+
export function logUndecryptableField(info, logger = defaultUndecryptableFieldLogger()) {
|
|
72
|
+
logger.error("encrypted field could not be decrypted; returned as null", {
|
|
73
|
+
entity: info.entityName,
|
|
74
|
+
rowId: info.rowId,
|
|
75
|
+
field: info.field,
|
|
76
|
+
errorName: info.error instanceof Error ? info.error.name : typeof info.error,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// With `onFieldError` a field that fails to decrypt (wrong key, corrupt
|
|
80
|
+
// envelope) reads as null and is reported instead of throwing, so one bad row
|
|
81
|
+
// cannot fail a whole list. Without it decrypt stays fail-loud (write paths).
|
|
82
|
+
export async function decryptEntityFieldValues(row, encryptedFields, cipher, opts) {
|
|
64
83
|
if (encryptedFields.size === 0)
|
|
65
84
|
return row;
|
|
66
85
|
const out = { ...row };
|
|
@@ -71,7 +90,17 @@ export async function decryptEntityFieldValues(row, encryptedFields, cipher) {
|
|
|
71
90
|
continue;
|
|
72
91
|
if (typeof value !== "string")
|
|
73
92
|
continue;
|
|
74
|
-
|
|
93
|
+
if (!opts?.onFieldError) {
|
|
94
|
+
out[name] = await cipher.decrypt(value, scope);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
out[name] = await cipher.decrypt(value, scope);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
out[name] = null;
|
|
102
|
+
opts.onFieldError(name, error);
|
|
103
|
+
}
|
|
75
104
|
}
|
|
76
105
|
return out;
|
|
77
106
|
}
|
|
@@ -9,11 +9,15 @@ import type { EventStoreExecutorOptions } from "./event-store-executor.js";
|
|
|
9
9
|
import type { TenantDb } from "./tenant-db.js";
|
|
10
10
|
export type Table = TableColumns<any>;
|
|
11
11
|
export declare function buildFilterWhere(field: string, op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in", value: unknown): WhereObject | null;
|
|
12
|
+
export declare function ownershipSubject(user: SessionUser, streamTenantId: TenantId | undefined): SessionUser;
|
|
12
13
|
export declare function isForeignTenantOnGlobalEntity(entity: EntityDefinition, tenantIdValue: unknown): boolean;
|
|
13
14
|
export declare function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null;
|
|
14
15
|
export declare function buildEventMetadata(user: SessionUser): EventMetadata;
|
|
15
16
|
export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
|
|
16
17
|
export declare function entityEventName(entityName: string, verb: EntityLifecycleVerb): string;
|
|
18
|
+
export type DecryptForReadOptions = {
|
|
19
|
+
readonly degradeUndecryptable?: (field: string) => void;
|
|
20
|
+
};
|
|
17
21
|
export type ExecutorContext = {
|
|
18
22
|
readonly table: Table;
|
|
19
23
|
readonly entity: EntityDefinition;
|
|
@@ -21,7 +25,8 @@ export type ExecutorContext = {
|
|
|
21
25
|
readonly entityCache?: EntityCache;
|
|
22
26
|
readonly searchAdapter?: SearchAdapter;
|
|
23
27
|
readonly softDelete: boolean;
|
|
24
|
-
readonly streamTenantFor: (user: SessionUser) => TenantId;
|
|
28
|
+
readonly streamTenantFor: (user: SessionUser, requestedStreamTenantId?: TenantId) => TenantId;
|
|
29
|
+
readonly streamTenantOverrideFailure: (db: TenantDb, row: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
|
|
25
30
|
readonly idFilter: (id: EntityId) => WhereObject;
|
|
26
31
|
readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
|
|
27
32
|
readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
|
|
@@ -53,8 +58,9 @@ export type ExecutorContext = {
|
|
|
53
58
|
readonly encryptForStorage: (row: Record<string, unknown>, user: SessionUser, opts?: {
|
|
54
59
|
onlyKeys?: Iterable<string>;
|
|
55
60
|
subjectSource?: Record<string, unknown>;
|
|
61
|
+
keyTenantId?: TenantId | undefined;
|
|
56
62
|
}) => Promise<Record<string, unknown>>;
|
|
57
|
-
readonly decryptForRead: (row: Record<string, unknown
|
|
63
|
+
readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
|
|
58
64
|
readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
|
|
59
65
|
readonly stripSensitive: (payload: Record<string, unknown> | undefined) => Record<string, unknown>;
|
|
60
66
|
};
|
|
@@ -4,11 +4,11 @@ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues
|
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
5
5
|
import { shiftParams } from "../engine/ownership.js";
|
|
6
6
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
7
|
-
import { InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
|
|
7
|
+
import { AccessDeniedError, InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
|
|
8
8
|
import { ArchivedStreamError, isStreamArchived } from "../event-store/index.js";
|
|
9
9
|
import { assertUnreachable } from "../utils/index.js";
|
|
10
10
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
11
|
-
import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
11
|
+
import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
12
12
|
import { constraintOf, isUniqueViolation } from "./pg-error.js";
|
|
13
13
|
import { toSnakeCase } from "./table-builder.js";
|
|
14
14
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
@@ -39,6 +39,13 @@ export function buildFilterWhere(field, op, value) {
|
|
|
39
39
|
return assertUnreachable(op, "filter op");
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
// Tenant-based write-ownership rules (from("user:tenantId", "tenantId")) compare the caller's
|
|
43
|
+
// tenant with the row's. An explicit cross-tenant grant (streamTenantId, checked against the
|
|
44
|
+
// loaded row) is evaluated as the operator acting inside the target tenant. Only this
|
|
45
|
+
// evaluation sees the target tenant: stream actor, metadata and PII keys keep the real identity.
|
|
46
|
+
export function ownershipSubject(user, streamTenantId) {
|
|
47
|
+
return streamTenantId === undefined ? user : { ...user, tenantId: streamTenantId };
|
|
48
|
+
}
|
|
42
49
|
// Returns the scalar default of a field, or undefined if the field's type
|
|
43
50
|
// doesn't carry a default or no default was declared. Only scalar types
|
|
44
51
|
// (text/number/boolean/select) support creation-time defaults — money/date/
|
|
@@ -110,7 +117,24 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
110
117
|
// user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
|
|
111
118
|
// there. Everything else stays on the caller's tenant (byte-identical to the
|
|
112
119
|
// old hardcoded user.tenantId). Single source of truth for the stream key.
|
|
113
|
-
const streamTenantFor = (user) => entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
120
|
+
const streamTenantFor = (user, requestedStreamTenantId) => entity.systemStream ? SYSTEM_TENANT_ID : (requestedStreamTenantId ?? user.tenantId);
|
|
121
|
+
// Only a system-mode db (r.systemScope() / acknowledged cross-tenant) may address another
|
|
122
|
+
// tenant's stream, and only the tenant of the row it just loaded.
|
|
123
|
+
const streamTenantOverrideFailure = (db, row, requestedStreamTenantId) => {
|
|
124
|
+
if (requestedStreamTenantId === undefined)
|
|
125
|
+
return undefined;
|
|
126
|
+
if (db.mode !== "system") {
|
|
127
|
+
return writeFailure(new AccessDeniedError({
|
|
128
|
+
message: `${entityName}: streamTenantId requires a system-mode db`,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
if (row["tenantId"] !== requestedStreamTenantId) {
|
|
132
|
+
return writeFailure(new AccessDeniedError({
|
|
133
|
+
message: `${entityName}: streamTenantId does not match the tenant of the addressed row`,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
};
|
|
114
138
|
// idType default (undefined) is now "uuid" — the ES-pivot made UUID the
|
|
115
139
|
// only valid aggregate-id type. Explicit `idType: "serial"` is the only
|
|
116
140
|
// shape that's incompatible with the event-store and still rejected.
|
|
@@ -156,10 +180,10 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
156
180
|
function piiKms() {
|
|
157
181
|
return options.kms ?? configuredPiiSubjectKms();
|
|
158
182
|
}
|
|
159
|
-
function kmsContextFor(user) {
|
|
183
|
+
function kmsContextFor(user, keyTenantId) {
|
|
160
184
|
return {
|
|
161
185
|
requestId: requestContext.get()?.requestId ?? "event-store-executor",
|
|
162
|
-
...(user && { tenantId: user.tenantId, userId: String(user.id) }),
|
|
186
|
+
...(user && { tenantId: keyTenantId ?? user.tenantId, userId: String(user.id) }),
|
|
163
187
|
};
|
|
164
188
|
}
|
|
165
189
|
// Async on purpose: the envelope cipher wraps/unwraps DEKs via the
|
|
@@ -176,8 +200,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
176
200
|
const kms = piiKms();
|
|
177
201
|
if (hasPiiFields && kms) {
|
|
178
202
|
try {
|
|
179
|
-
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
180
|
-
tenantId: user.tenantId,
|
|
203
|
+
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user, opts?.keyTenantId), {
|
|
204
|
+
tenantId: opts?.keyTenantId ?? user.tenantId,
|
|
181
205
|
entityName,
|
|
182
206
|
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
183
207
|
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
@@ -198,14 +222,21 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
198
222
|
// unwrap before envelope decrypt, or the envelope cipher chokes on a
|
|
199
223
|
// still-PII-wrapped string (auth-mfa.totpSecret/recoveryCodes combine both
|
|
200
224
|
// markers; see pii-subject-encryption.integration.test.ts).
|
|
201
|
-
async function decryptForRead(row) {
|
|
225
|
+
async function decryptForRead(row, opts) {
|
|
202
226
|
let out = row;
|
|
203
227
|
const kms = piiKms();
|
|
204
228
|
if (hasPiiFields && kms) {
|
|
205
229
|
out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
|
|
206
230
|
}
|
|
207
231
|
if (hasEncryptedFields) {
|
|
208
|
-
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher()
|
|
232
|
+
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
|
|
233
|
+
? {
|
|
234
|
+
onFieldError: (field, error) => {
|
|
235
|
+
logUndecryptableField({ entityName, rowId: row["id"], field, error });
|
|
236
|
+
opts.degradeUndecryptable?.(field);
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
: undefined);
|
|
209
240
|
}
|
|
210
241
|
return out;
|
|
211
242
|
}
|
|
@@ -384,6 +415,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
384
415
|
searchAdapter,
|
|
385
416
|
softDelete,
|
|
386
417
|
streamTenantFor,
|
|
418
|
+
streamTenantOverrideFailure,
|
|
387
419
|
idFilter,
|
|
388
420
|
loadById,
|
|
389
421
|
assertStreamWritable,
|
|
@@ -664,7 +664,7 @@ export function createReadVerbs(ctx) {
|
|
|
664
664
|
// field (ciphertext leaked to the caller).
|
|
665
665
|
const tableInfo = extractTableInfo(table);
|
|
666
666
|
const encryptedRows = rawRows.map((r) => rehydrateCompoundTypes(coerceRow(r, tableInfo), entity));
|
|
667
|
-
const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
|
|
667
|
+
const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r, { degradeUndecryptable: () => { } })));
|
|
668
668
|
// Stream version is authoritative (see detail()): ctx.appendEvent bumps the
|
|
669
669
|
// stream without touching row.version, so the raw row version would hand
|
|
670
670
|
// clients a stale optimistic-lock base. Rows without events keep theirs.
|
|
@@ -744,7 +744,7 @@ export function createReadVerbs(ctx) {
|
|
|
744
744
|
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
745
745
|
// `encrypted` field's plaintext never sits in a second at-rest
|
|
746
746
|
// store (Redis) the field-encryption feature doesn't cover.
|
|
747
|
-
return withStreamVersion(await decryptForRead(cached), streamVersion);
|
|
747
|
+
return withStreamVersion(await decryptForRead(cached, { degradeUndecryptable: () => { } }), streamVersion);
|
|
748
748
|
}
|
|
749
749
|
}
|
|
750
750
|
}
|
|
@@ -755,9 +755,12 @@ export function createReadVerbs(ctx) {
|
|
|
755
755
|
// Same coerce-before-rehydrate/decrypt ordering as list() above — raw
|
|
756
756
|
// is snake_case (raw SQL); coerceRow maps it to camelCase.
|
|
757
757
|
const rowInfo = extractTableInfo(table);
|
|
758
|
-
const
|
|
758
|
+
const undecryptableFields = [];
|
|
759
|
+
const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity), { degradeUndecryptable: (field) => undecryptableFields.push(field) });
|
|
759
760
|
const result = withStreamVersion(coerced, snapshot.streamVersion);
|
|
760
|
-
|
|
761
|
+
// A degraded row must not be cached: re-encrypting its null would
|
|
762
|
+
// replay the missing value on later cache hits without any error log.
|
|
763
|
+
if (entityCache && entityName && undecryptableFields.length === 0) {
|
|
761
764
|
await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
|
|
762
765
|
}
|
|
763
766
|
return result;
|
|
@@ -11,7 +11,7 @@ import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline
|
|
|
11
11
|
import { generateId } from "../utils/index.js";
|
|
12
12
|
import { applyEntityEvent } from "./apply-entity-event.js";
|
|
13
13
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
14
|
-
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
14
|
+
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
15
15
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
16
16
|
import { runInSavepointIfSupported } from "./query.js";
|
|
17
17
|
import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
|
|
@@ -118,12 +118,43 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action,
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
export function createWriteVerbs(ctx) {
|
|
121
|
-
const { table, entity, entityName, entityCache, softDelete, streamTenantFor, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
|
|
121
|
+
const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
|
|
122
122
|
const entityInstructionFieldNames = instructionFieldNames(entity);
|
|
123
|
+
const eventVersion = entity.eventVersion ?? 1;
|
|
123
124
|
let insertSchema;
|
|
124
125
|
let updateSchema;
|
|
125
126
|
const createOutputSchema = () => (insertSchema ??= buildInsertSchema(entity));
|
|
126
127
|
const updateOutputSchema = () => (updateSchema ??= buildUpdateSchema(entity));
|
|
128
|
+
// Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
|
|
129
|
+
// selectMany(runner, ...) did not — any caller could un-delete a foreign
|
|
130
|
+
// tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
|
|
131
|
+
// handlers) still read unfiltered. No isDeleted filter in the read: restore
|
|
132
|
+
// targets exactly the soft-deleted row.
|
|
133
|
+
async function loadSoftDeletedRow(db, id, streamTenantId) {
|
|
134
|
+
const row = await db.fetchOne(table, { id });
|
|
135
|
+
if (!row)
|
|
136
|
+
return { failure: writeFailure(new NotFoundError(entityName, id)) };
|
|
137
|
+
const streamTenantMismatch = streamTenantOverrideFailure(db, row, streamTenantId);
|
|
138
|
+
if (streamTenantMismatch)
|
|
139
|
+
return { failure: streamTenantMismatch };
|
|
140
|
+
if (!row["isDeleted"]) {
|
|
141
|
+
return {
|
|
142
|
+
failure: writeFailure(new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" })),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return { row: row };
|
|
146
|
+
}
|
|
147
|
+
async function expectedStreamVersion(db, id, streamTenant, expect) {
|
|
148
|
+
const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
|
|
149
|
+
const row = snapshot.row;
|
|
150
|
+
if (!row)
|
|
151
|
+
return writeFailure(new PreconditionFailedError({ entityId: id, field: "id" }));
|
|
152
|
+
const mismatch = Object.entries(expect).find(([key, expected]) => row[key] !== expected);
|
|
153
|
+
if (mismatch) {
|
|
154
|
+
return writeFailure(new PreconditionFailedError({ entityId: id, field: mismatch[0] }));
|
|
155
|
+
}
|
|
156
|
+
return snapshot.streamVersion;
|
|
157
|
+
}
|
|
127
158
|
return {
|
|
128
159
|
async create(payload, user, db, options) {
|
|
129
160
|
if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
|
|
@@ -218,6 +249,7 @@ export function createWriteVerbs(ctx) {
|
|
|
218
249
|
tenantId: streamTenantFor(user),
|
|
219
250
|
expectedVersion: 0,
|
|
220
251
|
type: entityEventName(entityName, "created"),
|
|
252
|
+
eventVersion,
|
|
221
253
|
payload: flatData,
|
|
222
254
|
metadata: buildEventMetadata(user),
|
|
223
255
|
}));
|
|
@@ -309,6 +341,10 @@ export function createWriteVerbs(ctx) {
|
|
|
309
341
|
const previous = await loadById(payload.id, db);
|
|
310
342
|
if (!previous)
|
|
311
343
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
344
|
+
const streamTenantId = updateOptions?.streamTenantId;
|
|
345
|
+
const streamTenantMismatch = streamTenantOverrideFailure(db, previous, streamTenantId);
|
|
346
|
+
if (streamTenantMismatch)
|
|
347
|
+
return streamTenantMismatch;
|
|
312
348
|
const preSaveResult = await runPreSave(updateOptions?.preSave, payload.changes, previous, false, entityName, "update", updateOutputSchema);
|
|
313
349
|
if ("failure" in preSaveResult)
|
|
314
350
|
return preSaveResult.failure;
|
|
@@ -322,7 +358,7 @@ export function createWriteVerbs(ctx) {
|
|
|
322
358
|
// prevents the attack where role A passes old, role B passes new and
|
|
323
359
|
// aggregation would wrongly allow a row-grab.
|
|
324
360
|
const mergedNew = { ...previous, ...changes };
|
|
325
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
|
|
361
|
+
if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, previous, mergedNew)) {
|
|
326
362
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
327
363
|
i18nKey: "errors.ownershipDenied",
|
|
328
364
|
details: {
|
|
@@ -350,7 +386,7 @@ export function createWriteVerbs(ctx) {
|
|
|
350
386
|
// for a checked field is still evaluated against the full post-hook
|
|
351
387
|
// row (`changes` merged onto `previous`) — an ownership rule can
|
|
352
388
|
// reference a column only a hook populates (kumiko-framework#1672).
|
|
353
|
-
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous, changes);
|
|
389
|
+
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, ownershipSubject(user, streamTenantId), previous, changes);
|
|
354
390
|
if (fieldDeniedUpdate) {
|
|
355
391
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
356
392
|
i18nKey: "errors.ownershipDenied",
|
|
@@ -367,7 +403,8 @@ export function createWriteVerbs(ctx) {
|
|
|
367
403
|
const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
|
|
368
404
|
if (unavailableOnUpdate)
|
|
369
405
|
return unavailableOnUpdate;
|
|
370
|
-
|
|
406
|
+
const streamTenant = streamTenantFor(user, streamTenantId);
|
|
407
|
+
await assertStreamWritable(db, payload.id, streamTenant);
|
|
371
408
|
// Stream-version is authoritative, not row.version. `ctx.appendEvent`
|
|
372
409
|
// can bump the stream between CRUD writes (domain event on the same
|
|
373
410
|
// aggregate); a stale row.version here would make the next CRUD write
|
|
@@ -376,22 +413,12 @@ export function createWriteVerbs(ctx) {
|
|
|
376
413
|
//
|
|
377
414
|
// With `expect:` the version comes from loadExpectSnapshot's combined
|
|
378
415
|
// query instead; see its comment for why.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
const mismatch = Object.entries(updateOptions.expect).find(([key, expected]) => snapshot.row?.[key] !== expected);
|
|
387
|
-
if (mismatch) {
|
|
388
|
-
return writeFailure(new PreconditionFailedError({ entityId: payload.id, field: mismatch[0] }));
|
|
389
|
-
}
|
|
390
|
-
currentVersion = snapshot.streamVersion;
|
|
391
|
-
}
|
|
392
|
-
else {
|
|
393
|
-
currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
|
|
394
|
-
}
|
|
416
|
+
const expect = updateOptions?.expect;
|
|
417
|
+
const currentVersion = expect
|
|
418
|
+
? await expectedStreamVersion(db, payload.id, streamTenant, expect)
|
|
419
|
+
: await getStreamVersion(runner, String(payload.id), streamTenant);
|
|
420
|
+
if (typeof currentVersion !== "number")
|
|
421
|
+
return currentVersion;
|
|
395
422
|
if (!updateOptions?.skipOptimisticLock) {
|
|
396
423
|
if (payload.version === undefined) {
|
|
397
424
|
return writeFailure(new FrameworkVersionConflict({
|
|
@@ -419,6 +446,7 @@ export function createWriteVerbs(ctx) {
|
|
|
419
446
|
const flatChanges = await encryptForStorage(flatChangesPlain, user, {
|
|
420
447
|
onlyKeys: Object.keys(submittedChanges),
|
|
421
448
|
subjectSource: mergedNew,
|
|
449
|
+
keyTenantId: streamTenantId,
|
|
422
450
|
});
|
|
423
451
|
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
424
452
|
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
@@ -429,16 +457,19 @@ export function createWriteVerbs(ctx) {
|
|
|
429
457
|
// re-encrypt it before it's persisted so plaintext of pii/encrypted
|
|
430
458
|
// fields doesn't land in the immutable log (flatChanges is already
|
|
431
459
|
// ciphertext from encryptForStorage above).
|
|
432
|
-
const encryptedPrevious = await encryptForStorage(previous, user
|
|
460
|
+
const encryptedPrevious = await encryptForStorage(previous, user, {
|
|
461
|
+
keyTenantId: streamTenantId,
|
|
462
|
+
});
|
|
433
463
|
// Savepoint-scoped — see the create() append() above for why:
|
|
434
464
|
// confines a losing writer's unique-violation to a nested scope
|
|
435
465
|
// instead of poisoning the whole outer transaction.
|
|
436
466
|
const event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
437
467
|
aggregateId: String(payload.id),
|
|
438
468
|
aggregateType: entityName,
|
|
439
|
-
tenantId:
|
|
469
|
+
tenantId: streamTenant,
|
|
440
470
|
expectedVersion: currentVersion,
|
|
441
471
|
type: entityEventName(entityName, "updated"),
|
|
472
|
+
eventVersion,
|
|
442
473
|
payload: {
|
|
443
474
|
changes: flatChanges,
|
|
444
475
|
previous: encryptedPrevious,
|
|
@@ -472,7 +503,7 @@ export function createWriteVerbs(ctx) {
|
|
|
472
503
|
const row = result.row;
|
|
473
504
|
const data = await decryptForRead(rehydrateCompoundTypes(row, entity));
|
|
474
505
|
if (entityCache && entityName) {
|
|
475
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
506
|
+
await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
|
|
476
507
|
}
|
|
477
508
|
const echoEvent = {
|
|
478
509
|
...event,
|
|
@@ -514,7 +545,7 @@ export function createWriteVerbs(ctx) {
|
|
|
514
545
|
throw e;
|
|
515
546
|
}
|
|
516
547
|
},
|
|
517
|
-
async delete(payload, user, db) {
|
|
548
|
+
async delete(payload, user, db, options) {
|
|
518
549
|
if (isIrreversibleEntityVerb("delete", entity)) {
|
|
519
550
|
assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
|
|
520
551
|
}
|
|
@@ -522,11 +553,15 @@ export function createWriteVerbs(ctx) {
|
|
|
522
553
|
const existing = await loadById(payload.id, db);
|
|
523
554
|
if (!existing)
|
|
524
555
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
556
|
+
const streamTenantId = options?.streamTenantId;
|
|
557
|
+
const streamTenantMismatch = streamTenantOverrideFailure(db, existing, streamTenantId);
|
|
558
|
+
if (streamTenantMismatch)
|
|
559
|
+
return streamTenantMismatch;
|
|
525
560
|
// H.2 — entity-level write-ownership on delete. Only the pre-delete
|
|
526
561
|
// row matters (there's no "new" row for a delete); passing existing
|
|
527
562
|
// twice to userCanWriteFieldRow makes the Straddle check trivial
|
|
528
563
|
// (same row on both sides) while keeping the multi-role-atomic shape.
|
|
529
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
564
|
+
if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
|
|
530
565
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
531
566
|
i18nKey: "errors.ownershipDenied",
|
|
532
567
|
details: {
|
|
@@ -538,9 +573,9 @@ export function createWriteVerbs(ctx) {
|
|
|
538
573
|
},
|
|
539
574
|
}));
|
|
540
575
|
}
|
|
541
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
576
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
|
|
542
577
|
// Stream-version authoritative (see update() for rationale).
|
|
543
|
-
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
|
|
578
|
+
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
|
|
544
579
|
// Deletes carry the full pre-delete row as `previous`. That's what
|
|
545
580
|
// projections and downstream consumers need to reverse any aggregates —
|
|
546
581
|
// a `{}`-payload delete would make cross-aggregate projections impossible
|
|
@@ -552,10 +587,13 @@ export function createWriteVerbs(ctx) {
|
|
|
552
587
|
event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
|
|
553
588
|
aggregateId: String(payload.id),
|
|
554
589
|
aggregateType: entityName,
|
|
555
|
-
tenantId: streamTenantFor(user),
|
|
590
|
+
tenantId: streamTenantFor(user, streamTenantId),
|
|
556
591
|
expectedVersion: currentVersion,
|
|
557
592
|
type: entityEventName(entityName, "deleted"),
|
|
558
|
-
|
|
593
|
+
eventVersion,
|
|
594
|
+
payload: {
|
|
595
|
+
previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
|
|
596
|
+
},
|
|
559
597
|
metadata: buildEventMetadata(user),
|
|
560
598
|
}));
|
|
561
599
|
}
|
|
@@ -578,7 +616,7 @@ export function createWriteVerbs(ctx) {
|
|
|
578
616
|
return writeFailure(new InternalError({ message: "projection delete: applyEntityEvent skipped" }));
|
|
579
617
|
}
|
|
580
618
|
if (entityCache && entityName) {
|
|
581
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
619
|
+
await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
|
|
582
620
|
}
|
|
583
621
|
const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
|
|
584
622
|
await projectEntityEvent(db, runner, echoEvent);
|
|
@@ -597,15 +635,19 @@ export function createWriteVerbs(ctx) {
|
|
|
597
635
|
// hard-deletes the row regardless of softDelete — and, being an auto-verb,
|
|
598
636
|
// the erasure replays on rebuild (created → forgotten → row gone). Loads
|
|
599
637
|
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
600
|
-
async forget(payload, user, db) {
|
|
638
|
+
async forget(payload, user, db, options) {
|
|
601
639
|
assertIrreversibleOperationAllowed(`${entityName}.forget`);
|
|
602
640
|
const runner = tenantDbRunner(db);
|
|
603
641
|
const raw = await db.fetchOne(table, { id: payload.id });
|
|
604
642
|
if (!raw)
|
|
605
643
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
644
|
+
const streamTenantId = options?.streamTenantId;
|
|
645
|
+
const streamTenantMismatch = streamTenantOverrideFailure(db, raw, streamTenantId);
|
|
646
|
+
if (streamTenantMismatch)
|
|
647
|
+
return streamTenantMismatch;
|
|
606
648
|
const existing = await decryptForRead(rehydrateCompoundTypes(raw, entity));
|
|
607
649
|
if (!isFrameworkSystemUser(user) &&
|
|
608
|
-
!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
650
|
+
!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
|
|
609
651
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
610
652
|
i18nKey: "errors.ownershipDenied",
|
|
611
653
|
details: {
|
|
@@ -617,19 +659,22 @@ export function createWriteVerbs(ctx) {
|
|
|
617
659
|
},
|
|
618
660
|
}));
|
|
619
661
|
}
|
|
620
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
621
|
-
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
|
|
662
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
|
|
663
|
+
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
|
|
622
664
|
let event;
|
|
623
665
|
try {
|
|
624
666
|
event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
|
|
625
667
|
aggregateId: String(payload.id),
|
|
626
668
|
aggregateType: entityName,
|
|
627
|
-
tenantId: streamTenantFor(user),
|
|
669
|
+
tenantId: streamTenantFor(user, streamTenantId),
|
|
628
670
|
expectedVersion: currentVersion,
|
|
629
671
|
type: entityEventName(entityName, "forgotten"),
|
|
672
|
+
eventVersion,
|
|
630
673
|
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
631
674
|
// plaintext must not land in the immutable log, least of all on forget.
|
|
632
|
-
payload: {
|
|
675
|
+
payload: {
|
|
676
|
+
previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
|
|
677
|
+
},
|
|
633
678
|
metadata: buildEventMetadata(user),
|
|
634
679
|
}));
|
|
635
680
|
}
|
|
@@ -651,7 +696,7 @@ export function createWriteVerbs(ctx) {
|
|
|
651
696
|
return writeFailure(new InternalError({ message: "projection forget: applyEntityEvent skipped" }));
|
|
652
697
|
}
|
|
653
698
|
if (entityCache && entityName) {
|
|
654
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
699
|
+
await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
|
|
655
700
|
}
|
|
656
701
|
const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
|
|
657
702
|
await projectEntityEvent(db, runner, echoEvent);
|
|
@@ -666,29 +711,22 @@ export function createWriteVerbs(ctx) {
|
|
|
666
711
|
},
|
|
667
712
|
};
|
|
668
713
|
},
|
|
669
|
-
async restore(payload, user, db) {
|
|
714
|
+
async restore(payload, user, db, options) {
|
|
670
715
|
if (!softDelete) {
|
|
671
716
|
return writeFailure(new UnprocessableError("soft_delete_not_enabled", {
|
|
672
717
|
i18nKey: "errors.softDeleteNotEnabled",
|
|
673
718
|
}));
|
|
674
719
|
}
|
|
675
720
|
const runner = tenantDbRunner(db);
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const row = await db.fetchOne(table, { id: payload.id });
|
|
682
|
-
if (!row)
|
|
683
|
-
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
684
|
-
const data = row;
|
|
685
|
-
if (!data["isDeleted"]) {
|
|
686
|
-
return writeFailure(new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }));
|
|
687
|
-
}
|
|
721
|
+
const streamTenantId = options?.streamTenantId;
|
|
722
|
+
const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
|
|
723
|
+
if ("failure" in target)
|
|
724
|
+
return target.failure;
|
|
725
|
+
const data = target.row;
|
|
688
726
|
// H.2 — entity-level write-ownership on restore. Same shape as delete:
|
|
689
727
|
// only the stored row matters. Stored row carries pre-soft-delete
|
|
690
728
|
// teamId/... fields, so the ownership predicate still applies cleanly.
|
|
691
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
|
|
729
|
+
if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data, data)) {
|
|
692
730
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
693
731
|
i18nKey: "errors.ownershipDenied",
|
|
694
732
|
details: {
|
|
@@ -700,9 +738,9 @@ export function createWriteVerbs(ctx) {
|
|
|
700
738
|
},
|
|
701
739
|
}));
|
|
702
740
|
}
|
|
703
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
741
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
|
|
704
742
|
// Stream-version authoritative (see update() for rationale).
|
|
705
|
-
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
|
|
743
|
+
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
|
|
706
744
|
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
|
707
745
|
// delete for symmetry. Projections that decremented on delete use
|
|
708
746
|
// `previous` to re-increment on restore without re-querying the entity
|
|
@@ -713,9 +751,10 @@ export function createWriteVerbs(ctx) {
|
|
|
713
751
|
event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
714
752
|
aggregateId: String(payload.id),
|
|
715
753
|
aggregateType: entityName,
|
|
716
|
-
tenantId: streamTenantFor(user),
|
|
754
|
+
tenantId: streamTenantFor(user, streamTenantId),
|
|
717
755
|
expectedVersion: currentVersion,
|
|
718
756
|
type: entityEventName(entityName, "restored"),
|
|
757
|
+
eventVersion,
|
|
719
758
|
payload: { previous: data },
|
|
720
759
|
metadata: buildEventMetadata(user),
|
|
721
760
|
}));
|
|
@@ -752,7 +791,7 @@ export function createWriteVerbs(ctx) {
|
|
|
752
791
|
}
|
|
753
792
|
const restored = restoreResult.row;
|
|
754
793
|
if (entityCache && entityName) {
|
|
755
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
794
|
+
await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
|
|
756
795
|
}
|
|
757
796
|
// Read-side auto-convert for compound types, same as update/list.
|
|
758
797
|
// decryptForRead matches create/update/list/detail: the caller-facing
|