@cosmicdrift/kumiko-framework 0.354.1 → 0.356.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 +9 -2
- package/dist/db/event-store-executor-context.js +45 -9
- package/dist/db/event-store-executor-read.js +7 -4
- package/dist/db/event-store-executor-write.js +105 -61
- 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 +2 -2
- 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 +3 -3
- 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 +31 -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 +62 -10
- 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 +42 -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,9 @@ export type ExecutorContext = {
|
|
|
21
25
|
readonly entityCache?: EntityCache;
|
|
22
26
|
readonly searchAdapter?: SearchAdapter;
|
|
23
27
|
readonly softDelete: boolean;
|
|
24
|
-
readonly
|
|
28
|
+
readonly streamTenantCreateFailure: (db: TenantDb, payload: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
|
|
29
|
+
readonly streamTenantFor: (user: SessionUser, requestedStreamTenantId?: TenantId) => TenantId;
|
|
30
|
+
readonly streamTenantOverrideFailure: (db: TenantDb, row: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
|
|
25
31
|
readonly idFilter: (id: EntityId) => WhereObject;
|
|
26
32
|
readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
|
|
27
33
|
readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
|
|
@@ -53,8 +59,9 @@ export type ExecutorContext = {
|
|
|
53
59
|
readonly encryptForStorage: (row: Record<string, unknown>, user: SessionUser, opts?: {
|
|
54
60
|
onlyKeys?: Iterable<string>;
|
|
55
61
|
subjectSource?: Record<string, unknown>;
|
|
62
|
+
keyTenantId?: TenantId | undefined;
|
|
56
63
|
}) => Promise<Record<string, unknown>>;
|
|
57
|
-
readonly decryptForRead: (row: Record<string, unknown
|
|
64
|
+
readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
|
|
58
65
|
readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
|
|
59
66
|
readonly stripSensitive: (payload: Record<string, unknown> | undefined) => Record<string, unknown>;
|
|
60
67
|
};
|
|
@@ -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,27 @@ 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) or a tenant-mode db bound
|
|
122
|
+
// to exactly that stream tenant (ctx.forTenant) may address another tenant's stream, and only
|
|
123
|
+
// the tenant of the row it just loaded.
|
|
124
|
+
const streamTenantOverrideFailure = (db, row, requestedStreamTenantId) => {
|
|
125
|
+
if (requestedStreamTenantId === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
if (db.mode !== "system" && db.tenantId !== requestedStreamTenantId) {
|
|
128
|
+
return writeFailure(new AccessDeniedError({
|
|
129
|
+
message: `${entityName}: streamTenantId requires a system-mode db or a db bound to that tenant`,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
if (row["tenantId"] !== requestedStreamTenantId) {
|
|
133
|
+
return writeFailure(new AccessDeniedError({
|
|
134
|
+
message: `${entityName}: streamTenantId does not match the tenant of the addressed row`,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
};
|
|
139
|
+
// Create has no loaded row: the payload's own tenantId (if any) must agree with the target stream.
|
|
140
|
+
const streamTenantCreateFailure = (db, payload, requestedStreamTenantId) => streamTenantOverrideFailure(db, { tenantId: payload["tenantId"] ?? requestedStreamTenantId }, requestedStreamTenantId);
|
|
114
141
|
// idType default (undefined) is now "uuid" — the ES-pivot made UUID the
|
|
115
142
|
// only valid aggregate-id type. Explicit `idType: "serial"` is the only
|
|
116
143
|
// shape that's incompatible with the event-store and still rejected.
|
|
@@ -156,10 +183,10 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
156
183
|
function piiKms() {
|
|
157
184
|
return options.kms ?? configuredPiiSubjectKms();
|
|
158
185
|
}
|
|
159
|
-
function kmsContextFor(user) {
|
|
186
|
+
function kmsContextFor(user, keyTenantId) {
|
|
160
187
|
return {
|
|
161
188
|
requestId: requestContext.get()?.requestId ?? "event-store-executor",
|
|
162
|
-
...(user && { tenantId: user.tenantId, userId: String(user.id) }),
|
|
189
|
+
...(user && { tenantId: keyTenantId ?? user.tenantId, userId: String(user.id) }),
|
|
163
190
|
};
|
|
164
191
|
}
|
|
165
192
|
// Async on purpose: the envelope cipher wraps/unwraps DEKs via the
|
|
@@ -176,8 +203,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
176
203
|
const kms = piiKms();
|
|
177
204
|
if (hasPiiFields && kms) {
|
|
178
205
|
try {
|
|
179
|
-
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
180
|
-
tenantId: user.tenantId,
|
|
206
|
+
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user, opts?.keyTenantId), {
|
|
207
|
+
tenantId: opts?.keyTenantId ?? user.tenantId,
|
|
181
208
|
entityName,
|
|
182
209
|
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
183
210
|
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
@@ -198,14 +225,21 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
198
225
|
// unwrap before envelope decrypt, or the envelope cipher chokes on a
|
|
199
226
|
// still-PII-wrapped string (auth-mfa.totpSecret/recoveryCodes combine both
|
|
200
227
|
// markers; see pii-subject-encryption.integration.test.ts).
|
|
201
|
-
async function decryptForRead(row) {
|
|
228
|
+
async function decryptForRead(row, opts) {
|
|
202
229
|
let out = row;
|
|
203
230
|
const kms = piiKms();
|
|
204
231
|
if (hasPiiFields && kms) {
|
|
205
232
|
out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
|
|
206
233
|
}
|
|
207
234
|
if (hasEncryptedFields) {
|
|
208
|
-
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher()
|
|
235
|
+
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
|
|
236
|
+
? {
|
|
237
|
+
onFieldError: (field, error) => {
|
|
238
|
+
logUndecryptableField({ entityName, rowId: row["id"], field, error });
|
|
239
|
+
opts.degradeUndecryptable?.(field);
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
: undefined);
|
|
209
243
|
}
|
|
210
244
|
return out;
|
|
211
245
|
}
|
|
@@ -384,6 +418,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
384
418
|
searchAdapter,
|
|
385
419
|
softDelete,
|
|
386
420
|
streamTenantFor,
|
|
421
|
+
streamTenantOverrideFailure,
|
|
422
|
+
streamTenantCreateFailure,
|
|
387
423
|
idFilter,
|
|
388
424
|
loadById,
|
|
389
425
|
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;
|