@cosmicdrift/kumiko-framework 0.395.0 → 0.396.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/agents/examples/ownership.md +3 -3
- package/dist/db/event-store-executor-context.d.ts +2 -0
- package/dist/db/event-store-executor-context.js +17 -0
- package/dist/db/event-store-executor-write.js +24 -1
- package/dist/engine/entity-handlers.js +9 -2
- package/package.json +3 -3
- package/src/changes.json +7 -0
|
@@ -203,14 +203,14 @@ describe("entity-level write: create/update/delete blocked on foreign rows", ()
|
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
test("TeamMember eng CANNOT update the ops row (not visible → not modifiable)", async () => {
|
|
206
|
-
// The row exists but
|
|
207
|
-
//
|
|
206
|
+
// The row exists, but a row the caller cannot read answers like a missing
|
|
207
|
+
// one, so the write does not reveal it. The guessed version never matters.
|
|
208
208
|
const err = await stack.http.writeErr(
|
|
209
209
|
"contracts:write:contract:update",
|
|
210
210
|
{ id: opsRowId, version: 1, changes: { propA: "hack" } },
|
|
211
211
|
teamMemberEng,
|
|
212
212
|
);
|
|
213
|
-
expectErrorIncludes(err, "
|
|
213
|
+
expectErrorIncludes(err, "not_found");
|
|
214
214
|
});
|
|
215
215
|
|
|
216
216
|
test("TeamMember eng CANNOT move their row to another team (teamId rewrite blocked)", async () => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
1
2
|
import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
2
3
|
import type { EntityId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
3
4
|
import type { ErasedSubjectHandling } from "../crypto/pii-field-encryption.ts";
|
|
@@ -44,6 +45,7 @@ export type ExecutorContext = {
|
|
|
44
45
|
readonly owned: boolean;
|
|
45
46
|
readonly streamVersion: number;
|
|
46
47
|
}>;
|
|
48
|
+
readonly isRowReadable: (id: EntityId, user: SessionUser, db: TenantDb, parentVisibility: ParentVisibilityOption | undefined) => Promise<boolean>;
|
|
47
49
|
readonly loadDetailSnapshot: (db: TenantDb, idWhere: WhereObject, ownership: {
|
|
48
50
|
kind: "pass";
|
|
49
51
|
} | {
|
|
@@ -10,6 +10,7 @@ import { isStreamArchived } from "../event-store/index.js";
|
|
|
10
10
|
import { assertUnreachable } from "../utils/index.js";
|
|
11
11
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
12
12
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
13
|
+
import { resolveReadOwnership } from "./event-store-executor-read-where.js";
|
|
13
14
|
import { constraintOf, isUniqueViolation } from "./pg-error.js";
|
|
14
15
|
import { toSnakeCase } from "./table-builder.js";
|
|
15
16
|
import { columnHandleName, schemaTableName } from "./table-introspection.js";
|
|
@@ -355,6 +356,21 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
355
356
|
const version = first["__streamVersion"];
|
|
356
357
|
return { owned: true, streamVersion: typeof version === "number" ? version : 0 };
|
|
357
358
|
}
|
|
359
|
+
// Same read predicate detail applies (access.read plus the parent-ref gate), evaluated
|
|
360
|
+
// for one row. The row's own isDeleted is not filtered because restore targets a
|
|
361
|
+
// soft-deleted row; includeDeleted below only concerns the parent host. Writers use it
|
|
362
|
+
// to treat an unreadable row as nonexistent.
|
|
363
|
+
async function isRowReadable(id, user, db, parentVisibility) {
|
|
364
|
+
const ownership = resolveReadOwnership({ entity, table }, String(schemaTableName(table)), user, db, parentVisibility, { includeDeleted: false, narrowTypes: undefined });
|
|
365
|
+
if (ownership.kind === "pass")
|
|
366
|
+
return true;
|
|
367
|
+
if (ownership.kind === "empty")
|
|
368
|
+
return false;
|
|
369
|
+
const tableName = String(schemaTableName(table));
|
|
370
|
+
const { whereParts, params } = buildRawIdWhere(db, { id }, ownership);
|
|
371
|
+
const rows = await executeRawQueryRead(tenantDbRunner(db), `SELECT 1 AS "__readable" FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`, params);
|
|
372
|
+
return rows.length > 0;
|
|
373
|
+
}
|
|
358
374
|
// Row and MAX(version) in ONE statement: two reads can pair a post-write
|
|
359
375
|
// version with a pre-write row, and update({version: detail.version}) would
|
|
360
376
|
// then pass the optimistic lock and overwrite the newer write (lost update).
|
|
@@ -436,6 +452,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
436
452
|
loadById,
|
|
437
453
|
assertStreamWritable,
|
|
438
454
|
probeOwnershipWithStreamVersion,
|
|
455
|
+
isRowReadable,
|
|
439
456
|
loadDetailSnapshot,
|
|
440
457
|
loadExpectSnapshot,
|
|
441
458
|
encryptForStorage,
|
|
@@ -24,8 +24,17 @@ import { tenantDbWriteRunner } from "./tenant-db-runner.js";
|
|
|
24
24
|
// store-executor, split out of event-store-executor.ts. They read an explicit
|
|
25
25
|
// ExecutorContext instead of capturing the factory's local scope directly.
|
|
26
26
|
export function createWriteVerbs(ctx) {
|
|
27
|
-
const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, ownerTransitionRekeys, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
|
|
27
|
+
const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, ownerTransitionRekeys, applyDefaults, stripSensitive, loadById, assertStreamWritable, isRowReadable, loadExpectSnapshot, } = ctx;
|
|
28
28
|
const entityInstructionFieldNames = instructionFieldNames(entity);
|
|
29
|
+
// A row the caller cannot read is reported as missing, like detail does, so a write
|
|
30
|
+
// can neither change it nor echo its content back. Framework system writes skip the
|
|
31
|
+
// check as they do for forget: their role never appears in a per-role read map.
|
|
32
|
+
async function unreadableRowFailure(id, user, db, parentVisibility) {
|
|
33
|
+
if (isFrameworkSystemUser(user) || (await isRowReadable(id, user, db, parentVisibility))) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return writeFailure(new NotFoundError(entityName, id));
|
|
37
|
+
}
|
|
29
38
|
// update and delete funnel through here, so custom handlers and nested writes cannot
|
|
30
39
|
// bypass the lock whatever they return. Setting the lock (unlocked row) passes.
|
|
31
40
|
// Framework system writes that name a writePurpose (retention, erasure) are exempt:
|
|
@@ -487,6 +496,9 @@ export function createWriteVerbs(ctx) {
|
|
|
487
496
|
});
|
|
488
497
|
if (!previous)
|
|
489
498
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
499
|
+
const unreadable = await unreadableRowFailure(payload.id, user, db, updateOptions?.parentVisibility);
|
|
500
|
+
if (unreadable)
|
|
501
|
+
return unreadable;
|
|
490
502
|
const streamTenantId = updateOptions?.streamTenantId;
|
|
491
503
|
const streamTenantMismatch = streamTenantOverrideFailure(db, previous, streamTenantId);
|
|
492
504
|
if (streamTenantMismatch)
|
|
@@ -561,6 +573,9 @@ export function createWriteVerbs(ctx) {
|
|
|
561
573
|
const existing = await loadById(payload.id, db);
|
|
562
574
|
if (!existing)
|
|
563
575
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
576
|
+
const unreadable = await unreadableRowFailure(payload.id, user, db, options?.parentVisibility);
|
|
577
|
+
if (unreadable)
|
|
578
|
+
return unreadable;
|
|
564
579
|
const streamTenantId = options?.streamTenantId;
|
|
565
580
|
const streamTenantMismatch = streamTenantOverrideFailure(db, existing, streamTenantId);
|
|
566
581
|
if (streamTenantMismatch)
|
|
@@ -645,6 +660,9 @@ export function createWriteVerbs(ctx) {
|
|
|
645
660
|
const raw = await db.fetchOne(table, { id: payload.id });
|
|
646
661
|
if (!raw)
|
|
647
662
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
663
|
+
const unreadable = await unreadableRowFailure(payload.id, user, db, options?.parentVisibility);
|
|
664
|
+
if (unreadable)
|
|
665
|
+
return unreadable;
|
|
648
666
|
const streamTenantId = options?.streamTenantId;
|
|
649
667
|
const streamTenantMismatch = streamTenantOverrideFailure(db, raw, streamTenantId);
|
|
650
668
|
if (streamTenantMismatch)
|
|
@@ -716,6 +734,11 @@ export function createWriteVerbs(ctx) {
|
|
|
716
734
|
}
|
|
717
735
|
const runner = tenantDbWriteRunner(db, "ctx.db executor restore");
|
|
718
736
|
const streamTenantId = options?.streamTenantId;
|
|
737
|
+
// Before loadSoftDeletedRow: its not_deleted answer would confirm that an unreadable
|
|
738
|
+
// live row exists.
|
|
739
|
+
const unreadable = await unreadableRowFailure(payload.id, user, db, options?.parentVisibility);
|
|
740
|
+
if (unreadable)
|
|
741
|
+
return unreadable;
|
|
719
742
|
const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
|
|
720
743
|
if ("failure" in target)
|
|
721
744
|
return target.failure;
|
|
@@ -289,6 +289,7 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
289
289
|
// they rely on to intentionally force a fresh event/ciphertext.
|
|
290
290
|
return executor.update(payload, event.user, db, {
|
|
291
291
|
streamTenantId,
|
|
292
|
+
parentVisibility: { entities: ctx.registry.getAllEntities() },
|
|
292
293
|
skipUnchanged: true,
|
|
293
294
|
preSave: runPreSave &&
|
|
294
295
|
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
@@ -301,7 +302,10 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
301
302
|
const db = dbFor(ctx);
|
|
302
303
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
303
304
|
const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
|
|
304
|
-
return executor.delete(payload, event.user, db, {
|
|
305
|
+
return executor.delete(payload, event.user, db, {
|
|
306
|
+
streamTenantId,
|
|
307
|
+
parentVisibility: { entities: ctx.registry.getAllEntities() },
|
|
308
|
+
});
|
|
305
309
|
};
|
|
306
310
|
break;
|
|
307
311
|
case "restore":
|
|
@@ -310,7 +314,10 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
310
314
|
const db = dbFor(ctx);
|
|
311
315
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
312
316
|
const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
|
|
313
|
-
return executor.restore(payload, event.user, db, {
|
|
317
|
+
return executor.restore(payload, event.user, db, {
|
|
318
|
+
streamTenantId,
|
|
319
|
+
parentVisibility: { entities: ctx.registry.getAllEntities() },
|
|
320
|
+
});
|
|
314
321
|
};
|
|
315
322
|
break;
|
|
316
323
|
default:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.396.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
"./package.json": "./package.json"
|
|
204
204
|
},
|
|
205
205
|
"dependencies": {
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.396.0",
|
|
207
207
|
"bullmq": "^5.76.7",
|
|
208
208
|
"hono": "^4.13.1",
|
|
209
209
|
"i18next": "^26.1.0",
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
}
|
|
226
226
|
},
|
|
227
227
|
"devDependencies": {
|
|
228
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
228
|
+
"@cosmicdrift/kumiko-testing": "0.396.0",
|
|
229
229
|
"bun-types": "^1.4.4",
|
|
230
230
|
"fflate": "^0.8.2",
|
|
231
231
|
"meilisearch": "^0.58.0",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.396.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Entity writes on a row the caller cannot read fail with not_found",
|
|
6
|
+
"detail": "update, delete, restore and forget only checked access.write, so on an entity with an owner access.read rule and no access.write any tenant member with handler access could change or delete another member's row, and delete returned the decrypted row. They now apply the same access.read rule and parentRef host-visibility gate as detail and answer not_found, so the row's existence stays hidden. Framework system writes are exempt. A write the caller could not read but that failed the write rule before now reports not_found instead of ownership_denied.",
|
|
7
|
+
"migration": "Writes on rows the caller cannot read now fail with not_found; give such writers read access or run them as system. Custom handlers that call the executor directly get the parentRef gate by passing parentVisibility: { entities: ctx.registry.getAllEntities() }, as the generated handlers do."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.395.0",
|
|
4
11
|
"type": "fix",
|