@cosmicdrift/kumiko-framework 0.245.0 → 0.246.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.246.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.246.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.246.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -14,15 +14,14 @@
|
|
|
14
14
|
// (DEK erased → Wert unlesbar, bidx NULL). Load-bearing Test:
|
|
15
15
|
// db/__tests__/implicit-projection-equivalence.integration.test.ts.
|
|
16
16
|
//
|
|
17
|
-
// Tenant
|
|
18
|
-
//
|
|
19
|
-
// 1.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// 2.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// Sicherheitslücke.
|
|
17
|
+
// Tenant isolation: applyEntityEvent expects a raw DbRunner (tx or pool), NOT
|
|
18
|
+
// a TenantDb wrapper. Two sources keep it safe:
|
|
19
|
+
// 1. The live path reads the target row through the tenant-scoped TenantDb
|
|
20
|
+
// before writing — loadById() for update/delete/forget, db.fetchOne() for
|
|
21
|
+
// restore — so the aggregateId is already tenant-validated on arrival.
|
|
22
|
+
// 2. create sets tenantId explicitly from event.tenantId, never derived from
|
|
23
|
+
// the TenantDb wrapper default.
|
|
24
|
+
// The missing TenantDb wrapper in this function is therefore not a hole.
|
|
26
25
|
//
|
|
27
26
|
// Auto-Verben:
|
|
28
27
|
// <entity>.created → INSERT
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
entityEventName,
|
|
28
28
|
tryMapUniqueViolation,
|
|
29
29
|
} from "./event-store-executor-context";
|
|
30
|
-
import { runInSavepointIfSupported
|
|
30
|
+
import { runInSavepointIfSupported } from "./query";
|
|
31
31
|
|
|
32
32
|
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
33
33
|
// per-role ownership map can never cover it, and a silent deny means the
|
|
@@ -714,7 +714,12 @@ export function createWriteVerbs(
|
|
|
714
714
|
);
|
|
715
715
|
}
|
|
716
716
|
|
|
717
|
-
|
|
717
|
+
// Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
|
|
718
|
+
// selectMany(db.raw, ...) did not — any caller could un-delete a foreign
|
|
719
|
+
// tenant's row by id. "system"-mode dbs (r.systemScope() / crossTenant
|
|
720
|
+
// handlers) still read unfiltered. No isDeleted filter here: restore
|
|
721
|
+
// targets exactly the soft-deleted row.
|
|
722
|
+
const row = await db.fetchOne(table, { id: payload.id });
|
|
718
723
|
if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
719
724
|
const data = row as DbRow;
|
|
720
725
|
if (!data["isDeleted"]) {
|
|
@@ -783,9 +788,9 @@ export function createWriteVerbs(
|
|
|
783
788
|
throw e;
|
|
784
789
|
}
|
|
785
790
|
|
|
786
|
-
// Live==Rebuild via applyEntityEvent. Restore
|
|
787
|
-
//
|
|
788
|
-
//
|
|
791
|
+
// Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
|
|
792
|
+
// plus the version bump, so there is no sensitive-field drift and no
|
|
793
|
+
// payload override is needed.
|
|
789
794
|
const restoreResult = await runInSavepointIfSupported(db.raw, async (sp) =>
|
|
790
795
|
applyEntityEvent(event, table, entity, sp),
|
|
791
796
|
);
|
|
@@ -798,11 +803,11 @@ export function createWriteVerbs(
|
|
|
798
803
|
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
799
804
|
}
|
|
800
805
|
|
|
801
|
-
// Read-
|
|
806
|
+
// Read-side auto-convert for compound types, same as update/list.
|
|
802
807
|
// decryptForRead matches create/update/list/detail: the caller-facing
|
|
803
808
|
// row and `previous` snapshot must be plaintext for `encrypted` fields,
|
|
804
809
|
// same as every other executor method — `data`/`restored` are raw rows
|
|
805
|
-
// (
|
|
810
|
+
// (db.fetchOne / applyEntityEvent), never decrypted before this point.
|
|
806
811
|
const restoredHydrated = await decryptForRead(
|
|
807
812
|
rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
|
|
808
813
|
);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// restore() used to load its target row with selectMany(db.raw, ...) — a raw,
|
|
2
|
+
// unfiltered read. Any caller holding the write role could therefore un-delete
|
|
3
|
+
// a soft-deleted row belonging to another tenant and got the decrypted row back
|
|
4
|
+
// in the response. delete() never had that hole (it goes through the
|
|
5
|
+
// tenant-scoped loadById). These tests pin both halves: the tenant-scoped
|
|
6
|
+
// handler must not reach across, and the crossTenant handler must still be able
|
|
7
|
+
// to.
|
|
8
|
+
//
|
|
9
|
+
// Two stacks on the SAME Postgres database: one registers the restore handler
|
|
10
|
+
// without `crossTenant`, the other with it — a clean A/B on the one flag.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { selectMany } from "../../db/query";
|
|
14
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
15
|
+
import {
|
|
16
|
+
setupTestStack,
|
|
17
|
+
type TestStack,
|
|
18
|
+
TestUsers,
|
|
19
|
+
testTenantId,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
} from "../../stack";
|
|
22
|
+
import {
|
|
23
|
+
createEntity,
|
|
24
|
+
createTextField,
|
|
25
|
+
defineEntityCreateHandler,
|
|
26
|
+
defineEntityDeleteHandler,
|
|
27
|
+
defineEntityRestoreHandler,
|
|
28
|
+
defineFeature,
|
|
29
|
+
from,
|
|
30
|
+
} from "../index";
|
|
31
|
+
|
|
32
|
+
const thingEntity = createEntity({
|
|
33
|
+
table: "ctrestore_things",
|
|
34
|
+
softDelete: true,
|
|
35
|
+
fields: {
|
|
36
|
+
label: createTextField({ required: true }),
|
|
37
|
+
},
|
|
38
|
+
access: { write: { Admin: "all", SystemAdmin: from("user:tenantId", "tenantId") } },
|
|
39
|
+
});
|
|
40
|
+
const thingTable = buildEntityTable("thing", thingEntity);
|
|
41
|
+
|
|
42
|
+
function buildFeature(crossTenant: boolean) {
|
|
43
|
+
return defineFeature("ctrestore", (r) => {
|
|
44
|
+
r.entity("thing", thingEntity);
|
|
45
|
+
r.writeHandler(
|
|
46
|
+
defineEntityCreateHandler("thing", thingEntity, {
|
|
47
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
r.writeHandler(
|
|
51
|
+
defineEntityDeleteHandler("thing", thingEntity, {
|
|
52
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
r.writeHandler(
|
|
56
|
+
defineEntityRestoreHandler("thing", thingEntity, {
|
|
57
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
58
|
+
...(crossTenant && { crossTenant: true }),
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const dbName = `kumiko_test_ctrestore_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
65
|
+
|
|
66
|
+
let tenantScopedStack: TestStack;
|
|
67
|
+
let crossTenantStack: TestStack;
|
|
68
|
+
|
|
69
|
+
beforeAll(async () => {
|
|
70
|
+
tenantScopedStack = await setupTestStack({ features: [buildFeature(false)], dbName });
|
|
71
|
+
await unsafeCreateEntityTable(tenantScopedStack.db, thingEntity, "thing");
|
|
72
|
+
crossTenantStack = await setupTestStack({
|
|
73
|
+
features: [buildFeature(true)],
|
|
74
|
+
dbName,
|
|
75
|
+
persistentDb: true,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
await crossTenantStack.cleanup();
|
|
81
|
+
await tenantScopedStack.cleanup();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
async function createDeletedThing(label: string): Promise<string> {
|
|
85
|
+
const created = await tenantScopedStack.http.writeOk<{ id: string }>(
|
|
86
|
+
"ctrestore:write:thing:create",
|
|
87
|
+
{ label },
|
|
88
|
+
TestUsers.otherTenant,
|
|
89
|
+
);
|
|
90
|
+
await tenantScopedStack.http.writeOk(
|
|
91
|
+
"ctrestore:write:thing:delete",
|
|
92
|
+
{ id: created.id },
|
|
93
|
+
TestUsers.otherTenant,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const rows = await selectMany(tenantScopedStack.db, thingTable, { id: created.id });
|
|
97
|
+
expect(rows[0]?.["isDeleted"]).toBe(true);
|
|
98
|
+
|
|
99
|
+
return created.id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("entity restore: tenant isolation", () => {
|
|
103
|
+
test("tenant-scoped restore cannot un-delete a foreign tenant's row", async () => {
|
|
104
|
+
const id = await createDeletedThing("foreign-deleted");
|
|
105
|
+
|
|
106
|
+
const err = await tenantScopedStack.http.writeErr(
|
|
107
|
+
"ctrestore:write:thing:restore",
|
|
108
|
+
{ id },
|
|
109
|
+
TestUsers.admin,
|
|
110
|
+
);
|
|
111
|
+
expect(err.code).toBe("not_found");
|
|
112
|
+
|
|
113
|
+
const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
|
|
114
|
+
expect(rows[0]?.["isDeleted"]).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("own-tenant restore still works", async () => {
|
|
118
|
+
const id = await createDeletedThing("own-restore");
|
|
119
|
+
|
|
120
|
+
await tenantScopedStack.http.writeOk(
|
|
121
|
+
"ctrestore:write:thing:restore",
|
|
122
|
+
{ id },
|
|
123
|
+
TestUsers.otherTenant,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
|
|
127
|
+
expect(rows[0]?.["isDeleted"]).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("crossTenant restore still reaches a foreign tenant's row", async () => {
|
|
131
|
+
const id = await createDeletedThing("crosstenant-restore");
|
|
132
|
+
|
|
133
|
+
await crossTenantStack.http.writeOk(
|
|
134
|
+
"ctrestore:write:thing:restore",
|
|
135
|
+
{ id },
|
|
136
|
+
TestUsers.systemAdmin,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
|
|
140
|
+
expect(rows[0]?.["isDeleted"]).toBe(false);
|
|
141
|
+
expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
|
|
142
|
+
});
|
|
143
|
+
});
|