@cosmicdrift/kumiko-bundled-features 0.243.4 → 0.245.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 +9 -9
- package/src/auth-mfa-user-data/hooks.ts +2 -1
- package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +30 -5
- package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +144 -14
- package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +225 -0
- package/src/notes-history/__tests__/notes-history.integration.test.ts +71 -28
- package/src/notes-history/changes.json +7 -0
- package/src/notes-history/feature.ts +34 -6
- package/src/notes-history/handlers/add-note.write.ts +27 -0
- package/src/notes-history/parent-visibility.ts +46 -0
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +29 -3
- package/src/shared/assert-erased.ts +15 -0
- package/src/shared/has-where-rule.ts +5 -8
- package/src/shared/index.ts +1 -0
- package/src/tags/__tests__/tags-ownership.test.ts +6 -7
- package/src/tags/feature.ts +3 -2
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +2 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +2 -1
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.245.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.245.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.245.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.245.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.245.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.245.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.245.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.245.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.245.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type UserDataExportHook,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
17
|
import { userMfaEntity, userMfaTable } from "../auth-mfa";
|
|
18
|
+
import { assertErased } from "../shared";
|
|
18
19
|
|
|
19
20
|
const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
20
21
|
entityName: "user-mfa",
|
|
@@ -49,6 +50,6 @@ export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
|
49
50
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
50
51
|
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
51
52
|
for (const row of rows) {
|
|
52
|
-
await executor.forget({ id: row.id }, systemUser, tdb);
|
|
53
|
+
assertErased(await executor.forget({ id: row.id }, systemUser, tdb), "user-mfa", row.id);
|
|
53
54
|
}
|
|
54
55
|
};
|
|
@@ -5,15 +5,25 @@
|
|
|
5
5
|
// KMS adapter so the round-trip is proven end-to-end, not assumed from a
|
|
6
6
|
// no-op-encrypt test env — a later refactor that silently reintroduces the
|
|
7
7
|
// raw UUID or gets stuck on the placeholder would fail this.
|
|
8
|
+
//
|
|
9
|
+
// fw#2627 made add-note's parent-visibility check unconditional, so a
|
|
10
|
+
// minimal `contact` fixture entity (PASS_CLAUSE) is mounted below and seeded
|
|
11
|
+
// with the one row this file writes a note to — orthogonal to what this file
|
|
12
|
+
// actually tests (authorName stamping).
|
|
8
13
|
|
|
9
14
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
16
|
import {
|
|
12
17
|
configurePiiSubjectKms,
|
|
13
18
|
InMemoryKmsAdapter,
|
|
14
19
|
isPiiCiphertext,
|
|
15
20
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
|
-
import
|
|
21
|
+
import {
|
|
22
|
+
createEntity,
|
|
23
|
+
createTextField,
|
|
24
|
+
defineFeature,
|
|
25
|
+
type SessionUser,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
27
|
import {
|
|
18
28
|
setupTestStack,
|
|
19
29
|
type TestStack,
|
|
@@ -30,6 +40,16 @@ import { createNotesHistoryFeature } from "../feature";
|
|
|
30
40
|
const notesHistoryFeature = createNotesHistoryFeature();
|
|
31
41
|
const tenantId = testTenantId(1);
|
|
32
42
|
|
|
43
|
+
const CONTACT_TABLE = "notes_kms_test_contacts";
|
|
44
|
+
const contactEntity = createEntity({
|
|
45
|
+
table: CONTACT_TABLE,
|
|
46
|
+
fields: { name: createTextField({ required: true, maxLength: 64 }) },
|
|
47
|
+
});
|
|
48
|
+
const contactFixtureFeature = defineFeature("notes-kms-test-contact-fixture", (r) => {
|
|
49
|
+
r.entity("contact", contactEntity);
|
|
50
|
+
});
|
|
51
|
+
const CONTACT_KMS_AUTHOR = "40000000-0000-4000-8000-000000000001";
|
|
52
|
+
|
|
33
53
|
let stack: TestStack;
|
|
34
54
|
|
|
35
55
|
function memberUser(userId: string): SessionUser {
|
|
@@ -37,9 +57,14 @@ function memberUser(userId: string): SessionUser {
|
|
|
37
57
|
}
|
|
38
58
|
|
|
39
59
|
beforeAll(async () => {
|
|
40
|
-
stack = await setupTestStack({ features: [notesHistoryFeature] });
|
|
60
|
+
stack = await setupTestStack({ features: [notesHistoryFeature, contactFixtureFeature] });
|
|
41
61
|
await unsafeCreateEntityTable(stack.db, noteEntryEntity);
|
|
42
62
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
63
|
+
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
64
|
+
await asRawClient(stack.db).unsafe(
|
|
65
|
+
`INSERT INTO ${CONTACT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
|
|
66
|
+
[CONTACT_KMS_AUTHOR, tenantId, CONTACT_KMS_AUTHOR],
|
|
67
|
+
);
|
|
43
68
|
});
|
|
44
69
|
|
|
45
70
|
afterAll(async () => {
|
|
@@ -70,13 +95,13 @@ describe("add-note — authorName stamped from the writer's own user row", () =>
|
|
|
70
95
|
|
|
71
96
|
const { id: noteId } = await stack.http.writeOk<{ id: string }>(
|
|
72
97
|
NotesHistoryHandlers.addNote,
|
|
73
|
-
{ entityType: "contact", entityId:
|
|
98
|
+
{ entityType: "contact", entityId: CONTACT_KMS_AUTHOR, body: "Called back." },
|
|
74
99
|
memberUser(userId),
|
|
75
100
|
);
|
|
76
101
|
|
|
77
102
|
const { rows } = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
78
103
|
NotesHistoryQueries.noteList,
|
|
79
|
-
{ filter: { field: "entityId", op: "eq", value:
|
|
104
|
+
{ filter: { field: "entityId", op: "eq", value: CONTACT_KMS_AUTHOR } },
|
|
80
105
|
memberUser(userId),
|
|
81
106
|
);
|
|
82
107
|
|
|
@@ -9,10 +9,24 @@
|
|
|
9
9
|
// stress shiftParams: the ownership fragment's `$N` placeholder must be
|
|
10
10
|
// renumbered past the tenant-scope filter's already-consumed slots
|
|
11
11
|
// (event-store-executor-read.ts), not just happen to work at `$1`.
|
|
12
|
+
//
|
|
13
|
+
// fw#2627 made add-note's parent-visibility check unconditional: entityType
|
|
14
|
+
// must now name a registered entity, and the row must exist and be visible
|
|
15
|
+
// to the caller. This file tests NOTE-level ownership, not parent-level, so
|
|
16
|
+
// the fixture `project` entity below is deliberately registered with NO
|
|
17
|
+
// `access` (PASS_CLAUSE) — any tenant member can write a note onto it — and
|
|
18
|
+
// every project row used below is inserted before its add-note call, so the
|
|
19
|
+
// parent-visibility check never interferes with what this file is actually
|
|
20
|
+
// proving.
|
|
12
21
|
|
|
13
22
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
23
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
-
import
|
|
24
|
+
import {
|
|
25
|
+
createEntity,
|
|
26
|
+
createTextField,
|
|
27
|
+
defineFeature,
|
|
28
|
+
type EntityDefinition,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
30
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
31
|
import {
|
|
18
32
|
createTestUser,
|
|
@@ -49,6 +63,23 @@ const teamOwnership: NonNullable<EntityDefinition["access"]> = {
|
|
|
49
63
|
},
|
|
50
64
|
};
|
|
51
65
|
|
|
66
|
+
// The `project` parent entity add-note now always checks for. No `access` —
|
|
67
|
+
// PASS_CLAUSE — so it never gates on its own; this file's team split is
|
|
68
|
+
// entirely on the note-entry side (teamOwnership above).
|
|
69
|
+
const PROJECT_TABLE = "notes_ownership_test_projects";
|
|
70
|
+
const projectEntity: EntityDefinition = createEntity({
|
|
71
|
+
table: PROJECT_TABLE,
|
|
72
|
+
fields: { name: createTextField({ required: true, maxLength: 64 }) },
|
|
73
|
+
});
|
|
74
|
+
const projectFixtureFeature = defineFeature("notes-ownership-test-project-fixture", (r) => {
|
|
75
|
+
r.entity("project", projectEntity);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const PROJ_1 = "20000000-0000-4000-8000-000000000001";
|
|
79
|
+
const PROJ_9 = "20000000-0000-4000-8000-000000000009";
|
|
80
|
+
const PROJ_2 = "20000000-0000-4000-8000-000000000002";
|
|
81
|
+
const PROJ_B = "20000000-0000-4000-8000-00000000000b";
|
|
82
|
+
|
|
52
83
|
type TestUser = ReturnType<typeof createTestUser>;
|
|
53
84
|
|
|
54
85
|
let scopedStack: TestStack;
|
|
@@ -67,23 +98,42 @@ const userB: TestUser = createTestUser({
|
|
|
67
98
|
claims: { team: "team-b" },
|
|
68
99
|
});
|
|
69
100
|
|
|
101
|
+
async function insertProjects(
|
|
102
|
+
stack: TestStack,
|
|
103
|
+
tenantId: string,
|
|
104
|
+
ids: readonly string[],
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
for (const id of ids) {
|
|
107
|
+
await asRawClient(stack.db).unsafe(
|
|
108
|
+
`INSERT INTO ${PROJECT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
|
|
109
|
+
[id, tenantId, id],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
70
114
|
beforeAll(async () => {
|
|
71
115
|
scopedStack = await setupTestStack({
|
|
72
|
-
features: [createNotesHistoryFeature({ ownership: teamOwnership })],
|
|
116
|
+
features: [createNotesHistoryFeature({ ownership: teamOwnership }), projectFixtureFeature],
|
|
73
117
|
});
|
|
74
118
|
await unsafeCreateEntityTable(scopedStack.db, createNoteEntryEntity(teamOwnership));
|
|
119
|
+
await unsafeCreateEntityTable(scopedStack.db, projectEntity);
|
|
75
120
|
await createEventsTable(scopedStack.db);
|
|
76
121
|
await asRawClient(scopedStack.db).unsafe(
|
|
77
122
|
`CREATE TABLE IF NOT EXISTS ${TEAMS_TABLE} (entity_id text PRIMARY KEY, team_id text NOT NULL)`,
|
|
78
123
|
);
|
|
124
|
+
await insertProjects(scopedStack, userA.tenantId, [PROJ_1, PROJ_9, PROJ_2]);
|
|
79
125
|
|
|
80
126
|
// Regression control, dedicated stack (mirrors tags.integration.test.ts's
|
|
81
127
|
// openStack/defaultStack): a plain (unscoped) mount of the SAME feature,
|
|
82
128
|
// used to prove the 0-rows result below is the ownership rule doing real
|
|
83
129
|
// work, not an empty table or a broken query.
|
|
84
|
-
defaultStack = await setupTestStack({
|
|
130
|
+
defaultStack = await setupTestStack({
|
|
131
|
+
features: [createNotesHistoryFeature(), projectFixtureFeature],
|
|
132
|
+
});
|
|
85
133
|
await unsafeCreateEntityTable(defaultStack.db, noteEntryEntity);
|
|
134
|
+
await unsafeCreateEntityTable(defaultStack.db, projectEntity);
|
|
86
135
|
await createEventsTable(defaultStack.db);
|
|
136
|
+
await insertProjects(defaultStack, userA.tenantId, [PROJ_1]);
|
|
87
137
|
});
|
|
88
138
|
|
|
89
139
|
afterAll(async () => {
|
|
@@ -128,16 +178,16 @@ describe("notes-history integration — row ownership (where-rule)", () => {
|
|
|
128
178
|
// weaker test would miss: a broken shiftParams (team_id compared against
|
|
129
179
|
// the tenant UUID or nothing → everyone sees 0) and a trivially-true
|
|
130
180
|
// subquery (EXISTS matches regardless of team_id → everyone sees both).
|
|
131
|
-
await addNote(scopedStack,
|
|
132
|
-
await addNote(scopedStack,
|
|
181
|
+
await addNote(scopedStack, PROJ_1, userA); // will be team-a
|
|
182
|
+
await addNote(scopedStack, PROJ_9, userA); // will be team-b — same author, different team
|
|
133
183
|
await asRawClient(scopedStack.db).unsafe(
|
|
134
184
|
`INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2), ($3, $4)`,
|
|
135
|
-
[
|
|
185
|
+
[PROJ_1, "team-a", PROJ_9, "team-b"],
|
|
136
186
|
);
|
|
137
187
|
|
|
138
188
|
// User B (different team) sees nothing, even filtered down to the exact row.
|
|
139
189
|
expect(
|
|
140
|
-
await listNotes(scopedStack, userB, { field: "entityId", op: "eq", value:
|
|
190
|
+
await listNotes(scopedStack, userB, { field: "entityId", op: "eq", value: PROJ_1 }),
|
|
141
191
|
).toHaveLength(0);
|
|
142
192
|
|
|
143
193
|
// User A (own team) sees it. This is the real shiftParams exercise: the
|
|
@@ -148,7 +198,7 @@ describe("notes-history integration — row ownership (where-rule)", () => {
|
|
|
148
198
|
const ownRows = await listNotes(scopedStack, userA, {
|
|
149
199
|
field: "entityId",
|
|
150
200
|
op: "eq",
|
|
151
|
-
value:
|
|
201
|
+
value: PROJ_1,
|
|
152
202
|
});
|
|
153
203
|
expect(ownRows).toHaveLength(1);
|
|
154
204
|
expect(ownRows[0]?.["body"]).toBe("note");
|
|
@@ -158,17 +208,17 @@ describe("notes-history integration — row ownership (where-rule)", () => {
|
|
|
158
208
|
// subquery (which would return both).
|
|
159
209
|
const aRows = await listNotes(scopedStack, userA);
|
|
160
210
|
expect(aRows).toHaveLength(1);
|
|
161
|
-
expect(aRows[0]?.["entityId"]).toBe(
|
|
211
|
+
expect(aRows[0]?.["entityId"]).toBe(PROJ_1);
|
|
162
212
|
|
|
163
213
|
// B authored NEITHER note, yet must see exactly the team-b one — proves
|
|
164
214
|
// the rule grants access by team membership, not by who wrote the row.
|
|
165
215
|
const bRows = await listNotes(scopedStack, userB);
|
|
166
216
|
expect(bRows).toHaveLength(1);
|
|
167
|
-
expect(bRows[0]?.["entityId"]).toBe(
|
|
217
|
+
expect(bRows[0]?.["entityId"]).toBe(PROJ_9);
|
|
168
218
|
});
|
|
169
219
|
|
|
170
220
|
test("the same data on an unscoped mount leaks across teams (regression control)", async () => {
|
|
171
|
-
await addNote(defaultStack,
|
|
221
|
+
await addNote(defaultStack, PROJ_1, userA);
|
|
172
222
|
// No ownership option on this stack's feature — the pre-fix behavior.
|
|
173
223
|
// Proves the 0-rows result above comes from the ownership rule, not
|
|
174
224
|
// from an unrelated empty-table/broken-query artifact.
|
|
@@ -176,10 +226,10 @@ describe("notes-history integration — row ownership (where-rule)", () => {
|
|
|
176
226
|
});
|
|
177
227
|
|
|
178
228
|
test("no explicit filter — ownership fragment still shifts correctly against the bare tenant scope", async () => {
|
|
179
|
-
await addNote(scopedStack,
|
|
229
|
+
await addNote(scopedStack, PROJ_2, userA);
|
|
180
230
|
await asRawClient(scopedStack.db).unsafe(
|
|
181
231
|
`INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2)`,
|
|
182
|
-
[
|
|
232
|
+
[PROJ_2, "team-a"],
|
|
183
233
|
);
|
|
184
234
|
|
|
185
235
|
expect(await listNotes(scopedStack, userB)).toHaveLength(0);
|
|
@@ -187,8 +237,88 @@ describe("notes-history integration — row ownership (where-rule)", () => {
|
|
|
187
237
|
});
|
|
188
238
|
});
|
|
189
239
|
|
|
240
|
+
describe("unqualified where-rule fails closed, not open (fw#2639)", () => {
|
|
241
|
+
// Same shape as `teamOwnership` above, but the subquery's inner reference
|
|
242
|
+
// is left unqualified (`entity_id` instead of `t.entity_id`/`${ctx.tableName}.entity_id`).
|
|
243
|
+
// Postgres binds the unqualified name to the innermost table (`t`) first,
|
|
244
|
+
// so `t.entity_id = entity_id` silently becomes `t.entity_id = t.entity_id`
|
|
245
|
+
// — a self-join tautology that matches every row regardless of team.
|
|
246
|
+
const unqualifiedOwnership: NonNullable<EntityDefinition["access"]> = {
|
|
247
|
+
read: {
|
|
248
|
+
TenantMember: {
|
|
249
|
+
kind: "where",
|
|
250
|
+
where: (user, ctx) => {
|
|
251
|
+
const team = user.claims?.["team"];
|
|
252
|
+
// Boot probe has no claims and bails out here; the runtime lint in
|
|
253
|
+
// ruleToFragment is what must catch the unqualified reference.
|
|
254
|
+
if (typeof team !== "string") throw new Error("team claim required");
|
|
255
|
+
return {
|
|
256
|
+
sqlText: `EXISTS (SELECT 1 FROM ${TEAMS_TABLE} t WHERE t.entity_id = entity_id AND t.team_id = $${ctx.paramStart})`,
|
|
257
|
+
params: [team],
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
let unqualifiedStack: TestStack;
|
|
265
|
+
|
|
266
|
+
beforeAll(async () => {
|
|
267
|
+
// Reaching this point at all proves the boot guard doesn't false-positive
|
|
268
|
+
// on this rule: PROBE_USER carries no claims, so `where()` throws before
|
|
269
|
+
// the lint ever sees the bad SQL, and boot-validator/ownership.ts's
|
|
270
|
+
// probeWhereRule swallows that and moves on.
|
|
271
|
+
//
|
|
272
|
+
// add-note's parent-visibility check is unconditional since fw#2627, so
|
|
273
|
+
// this stack also needs the PASS_CLAUSE project fixture mounted — this
|
|
274
|
+
// block otherwise stays focused on the note-entry ownership rule.
|
|
275
|
+
unqualifiedStack = await setupTestStack({
|
|
276
|
+
features: [
|
|
277
|
+
createNotesHistoryFeature({ ownership: unqualifiedOwnership }),
|
|
278
|
+
projectFixtureFeature,
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
await unsafeCreateEntityTable(unqualifiedStack.db, createNoteEntryEntity(unqualifiedOwnership));
|
|
282
|
+
await unsafeCreateEntityTable(unqualifiedStack.db, projectEntity);
|
|
283
|
+
await createEventsTable(unqualifiedStack.db);
|
|
284
|
+
await asRawClient(unqualifiedStack.db).unsafe(
|
|
285
|
+
`CREATE TABLE IF NOT EXISTS ${TEAMS_TABLE} (entity_id text PRIMARY KEY, team_id text NOT NULL)`,
|
|
286
|
+
);
|
|
287
|
+
await insertProjects(unqualifiedStack, userB.tenantId, [PROJ_B]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
afterAll(async () => {
|
|
291
|
+
await unqualifiedStack.cleanup();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("list() fails closed instead of leaking userB's row to userA", async () => {
|
|
295
|
+
await addNote(unqualifiedStack, PROJ_B, userB);
|
|
296
|
+
await asRawClient(unqualifiedStack.db).unsafe(
|
|
297
|
+
`INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2)`,
|
|
298
|
+
[PROJ_B, "team-b"],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Pre-fix: this query returned HTTP 200 with userB's row (the tautology
|
|
302
|
+
// matches every row) — queryErr would throw "Expected query to fail but
|
|
303
|
+
// it succeeded". Post-fix: the runtime lint in ruleToFragment throws
|
|
304
|
+
// before the query ever runs, so the request fails loud instead of
|
|
305
|
+
// leaking. Asserting the concrete status (not just "isSuccess: false")
|
|
306
|
+
// documents exactly how it fails: an uncaught Error auto-wraps into
|
|
307
|
+
// InternalError (500), not a handled 4xx.
|
|
308
|
+
const error = await unqualifiedStack.http.queryErr(NotesHistoryQueries.noteList, {}, userA);
|
|
309
|
+
expect(error.httpStatus).toBe(500);
|
|
310
|
+
expect(error.code).toBe("internal_error");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Green-path twin: `scopedStack` above (built with the correctly-qualified
|
|
314
|
+
// `teamOwnership` rule) already proves the lint doesn't reject every
|
|
315
|
+
// subquery rule — its first test asserts userA sees exactly its own
|
|
316
|
+
// team's row via HTTP 200 and never userB's. Relying on that coverage
|
|
317
|
+
// instead of duplicating a second correctly-qualified stack here.
|
|
318
|
+
});
|
|
319
|
+
|
|
190
320
|
describe("notes-history — boot guard rejects a where-rule in ownership.write", () => {
|
|
191
|
-
test("createNotesHistoryFeature throws instead of shipping a
|
|
321
|
+
test("createNotesHistoryFeature throws instead of shipping a deny-only ownership map", () => {
|
|
192
322
|
expect(() =>
|
|
193
323
|
createNotesHistoryFeature({
|
|
194
324
|
ownership: {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// fw#2627 — add-note previously trusted entityType/entityId straight from the
|
|
2
|
+
// client, so any dispatch-eligible tenant user could attach a note to a
|
|
3
|
+
// parent object they had no read access to. The parent-visibility check is
|
|
4
|
+
// unconditional (default-on, no opt-in): entityType must name a registered
|
|
5
|
+
// entity, and the row must be visible through that entity's own read path
|
|
6
|
+
// (tenant scope plus its `access.read` ownership) before add-note accepts
|
|
7
|
+
// the write — on every mount, `parents` or not. `parents`, when set, only
|
|
8
|
+
// narrows FURTHER to an allowlist of permitted parent entity names; it is
|
|
9
|
+
// not what turns the check on (see `openStack` tests below, which mount
|
|
10
|
+
// notes-history with no `parents` at all and still enforce the check).
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import {
|
|
15
|
+
createEntity,
|
|
16
|
+
createTextField,
|
|
17
|
+
defineFeature,
|
|
18
|
+
type EntityDefinition,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
createTestUser,
|
|
23
|
+
setupTestStack,
|
|
24
|
+
type TestStack,
|
|
25
|
+
unsafeCreateEntityTable,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
|
+
import { NotesHistoryHandlers } from "../constants";
|
|
28
|
+
import { createNoteEntryEntity } from "../entity";
|
|
29
|
+
import { createNotesHistoryFeature } from "../feature";
|
|
30
|
+
|
|
31
|
+
const PROJECT_TABLE = "notes_pv_test_projects";
|
|
32
|
+
|
|
33
|
+
// Team-scoped read ownership on the parent entity itself — the caller sees a
|
|
34
|
+
// project row only if their `team` claim matches the row's team_id.
|
|
35
|
+
const projectOwnership: NonNullable<EntityDefinition["access"]> = {
|
|
36
|
+
read: {
|
|
37
|
+
TenantMember: {
|
|
38
|
+
kind: "where",
|
|
39
|
+
where: (user, ctx) => ({
|
|
40
|
+
sqlText: `${ctx.tableName}.team_id = $${ctx.paramStart}`,
|
|
41
|
+
params: [user.claims?.["team"] ?? null],
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function createProjectEntity(access?: EntityDefinition["access"]): EntityDefinition {
|
|
48
|
+
return createEntity({
|
|
49
|
+
table: PROJECT_TABLE,
|
|
50
|
+
fields: {
|
|
51
|
+
teamId: createTextField({ required: true, maxLength: 64 }),
|
|
52
|
+
name: createTextField({ required: true, maxLength: 64 }),
|
|
53
|
+
},
|
|
54
|
+
access,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const guardedProjectEntity = createProjectEntity(projectOwnership);
|
|
59
|
+
|
|
60
|
+
// A second registered entity, deliberately left OUT of the `parents`
|
|
61
|
+
// allowlist below — proves the allowlist rejects a real, registered entity
|
|
62
|
+
// too, not just made-up names.
|
|
63
|
+
const contactEntity = createEntity({
|
|
64
|
+
table: "notes_pv_test_contacts",
|
|
65
|
+
fields: { name: createTextField({ required: true, maxLength: 64 }) },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const fixturesFeature = defineFeature("notes-pv-test-fixtures", (r) => {
|
|
69
|
+
r.entity("project", guardedProjectEntity);
|
|
70
|
+
r.entity("contact", contactEntity);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
type TestUser = ReturnType<typeof createTestUser>;
|
|
74
|
+
|
|
75
|
+
// Same tenant, different team claim — mirrors the ownership integration test.
|
|
76
|
+
const userA: TestUser = createTestUser({
|
|
77
|
+
id: 40,
|
|
78
|
+
roles: ["TenantMember"],
|
|
79
|
+
claims: { team: "team-a" },
|
|
80
|
+
});
|
|
81
|
+
const userB: TestUser = createTestUser({
|
|
82
|
+
id: 41,
|
|
83
|
+
roles: ["TenantMember"],
|
|
84
|
+
claims: { team: "team-b" },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const PROJECT_A = "a0000000-0000-4000-8000-000000000001";
|
|
88
|
+
// A second team-a project, dedicated to the cross-team-denial case so its
|
|
89
|
+
// note-count stays independent of PROJECT_A's.
|
|
90
|
+
const PROJECT_A2 = "a0000000-0000-4000-8000-000000000002";
|
|
91
|
+
// openStack-only team-a project row — never reused as PROJECT_A2 so the two
|
|
92
|
+
// stacks' tests stay order-independent.
|
|
93
|
+
const PROJECT_OPEN = "a0000000-0000-4000-8000-000000000009";
|
|
94
|
+
|
|
95
|
+
let guardedStack: TestStack;
|
|
96
|
+
let openStack: TestStack;
|
|
97
|
+
|
|
98
|
+
beforeAll(async () => {
|
|
99
|
+
guardedStack = await setupTestStack({
|
|
100
|
+
features: [createNotesHistoryFeature({ parents: ["project"] }), fixturesFeature],
|
|
101
|
+
});
|
|
102
|
+
await unsafeCreateEntityTable(guardedStack.db, guardedProjectEntity);
|
|
103
|
+
await unsafeCreateEntityTable(guardedStack.db, createNoteEntryEntity());
|
|
104
|
+
await createEventsTable(guardedStack.db);
|
|
105
|
+
await asRawClient(guardedStack.db).unsafe(
|
|
106
|
+
`INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES
|
|
107
|
+
($1, $2, 'team-a', 'Project A'),
|
|
108
|
+
($3, $2, 'team-a', 'Project A2')`,
|
|
109
|
+
[PROJECT_A, userA.tenantId, PROJECT_A2],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Unguarded mount: the SAME feature with no `parents` at all — proves the
|
|
113
|
+
// parent-visibility check itself is default-on, not something `parents`
|
|
114
|
+
// switches on.
|
|
115
|
+
openStack = await setupTestStack({
|
|
116
|
+
features: [createNotesHistoryFeature(), fixturesFeature],
|
|
117
|
+
});
|
|
118
|
+
await unsafeCreateEntityTable(openStack.db, guardedProjectEntity);
|
|
119
|
+
await unsafeCreateEntityTable(openStack.db, createNoteEntryEntity());
|
|
120
|
+
await createEventsTable(openStack.db);
|
|
121
|
+
await asRawClient(openStack.db).unsafe(
|
|
122
|
+
`INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES ($1, $2, 'team-a', 'Project Open')`,
|
|
123
|
+
[PROJECT_OPEN, userA.tenantId],
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterAll(async () => {
|
|
128
|
+
await guardedStack.cleanup();
|
|
129
|
+
await openStack.cleanup();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
async function addNote(
|
|
133
|
+
stack: TestStack,
|
|
134
|
+
entityType: string,
|
|
135
|
+
entityId: string,
|
|
136
|
+
user: TestUser,
|
|
137
|
+
): Promise<{ id: string }> {
|
|
138
|
+
return stack.http.writeOk<{ id: string }>(
|
|
139
|
+
NotesHistoryHandlers.addNote,
|
|
140
|
+
{ entityType, entityId, body: "note" },
|
|
141
|
+
user,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function addNoteErr(stack: TestStack, entityType: string, entityId: string, user: TestUser) {
|
|
146
|
+
return stack.http.writeErr(
|
|
147
|
+
NotesHistoryHandlers.addNote,
|
|
148
|
+
{ entityType, entityId, body: "note" },
|
|
149
|
+
user,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function noteCountFor(stack: TestStack, entityId: string): Promise<number> {
|
|
154
|
+
const rows = await asRawClient(stack.db).unsafe<{ count: string }>(
|
|
155
|
+
`SELECT count(*)::text AS count FROM read_note_entries WHERE entity_id = $1`,
|
|
156
|
+
[entityId],
|
|
157
|
+
);
|
|
158
|
+
return Number(rows[0]?.count ?? "0");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
describe("notes-history integration — add-note parent-visibility (parents option)", () => {
|
|
162
|
+
test("caller may note a parent row visible through the parent entity's own read path", async () => {
|
|
163
|
+
const result = await addNote(guardedStack, "project", PROJECT_A, userA);
|
|
164
|
+
expect(result.id).toBeTruthy();
|
|
165
|
+
expect(await noteCountFor(guardedStack, PROJECT_A)).toBe(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("caller is denied noting a parent row from a foreign team while the same row is writable by its own team", async () => {
|
|
169
|
+
const err = await addNoteErr(guardedStack, "project", PROJECT_A2, userB);
|
|
170
|
+
expect(err.code).toBe("not_found");
|
|
171
|
+
expect(err.httpStatus).toBe(404);
|
|
172
|
+
expect(await noteCountFor(guardedStack, PROJECT_A2)).toBe(0);
|
|
173
|
+
|
|
174
|
+
// Positive control: PROJECT_A2 itself is writable — by its own team — so
|
|
175
|
+
// the deny above is the team-claim check, not some other rejection (e.g.
|
|
176
|
+
// a row that doesn't exist at all).
|
|
177
|
+
const result = await addNote(guardedStack, "project", PROJECT_A2, userA);
|
|
178
|
+
expect(result.id).toBeTruthy();
|
|
179
|
+
expect(await noteCountFor(guardedStack, PROJECT_A2)).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("entityType outside the allowlist is denied even when it is a registered entity", async () => {
|
|
183
|
+
const err = await addNoteErr(
|
|
184
|
+
guardedStack,
|
|
185
|
+
"contact",
|
|
186
|
+
"c0000000-0000-4000-8000-000000000003",
|
|
187
|
+
userA,
|
|
188
|
+
);
|
|
189
|
+
expect(err.code).toBe("not_found");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("entityType that is not registered anywhere is denied", async () => {
|
|
193
|
+
const err = await addNoteErr(guardedStack, "totally-unknown-entity", "x", userA);
|
|
194
|
+
expect(err.code).toBe("not_found");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a malformed entityId is denied cleanly, without poisoning the connection for later writes", async () => {
|
|
198
|
+
const err = await addNoteErr(guardedStack, "project", "not-a-uuid", userA);
|
|
199
|
+
expect(err.code).toBe("not_found");
|
|
200
|
+
expect(err.httpStatus).toBe(404);
|
|
201
|
+
|
|
202
|
+
// The stack must still be usable afterwards — proves the malformed id was
|
|
203
|
+
// rejected before it ever reached a query, not caught after a failed
|
|
204
|
+
// (and tx-poisoning) cast.
|
|
205
|
+
const result = await addNote(guardedStack, "project", PROJECT_A, userA);
|
|
206
|
+
expect(result.id).toBeTruthy();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("on an unguarded mount (no `parents`), an unregistered entityType is still denied — the check is default-on", async () => {
|
|
210
|
+
const err = await addNoteErr(openStack, "totally-unknown-entity", "y", userA);
|
|
211
|
+
expect(err.code).toBe("not_found");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("on an unguarded mount, a registered and visible parent still succeeds", async () => {
|
|
215
|
+
const result = await addNote(openStack, "project", PROJECT_OPEN, userA);
|
|
216
|
+
expect(result.id).toBeTruthy();
|
|
217
|
+
expect(await noteCountFor(openStack, PROJECT_OPEN)).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("on an unguarded mount, a registered parent from a foreign team is still denied", async () => {
|
|
221
|
+
const err = await addNoteErr(openStack, "project", PROJECT_OPEN, userB);
|
|
222
|
+
expect(err.code).toBe("not_found");
|
|
223
|
+
expect(err.httpStatus).toBe(404);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -9,9 +9,17 @@
|
|
|
9
9
|
// - read-layer composition: list filtered on entityId
|
|
10
10
|
// - multi-tenant isolation
|
|
11
11
|
// - append-only: only add-note and list are registered handlers
|
|
12
|
+
//
|
|
13
|
+
// fw#2627 made add-note's parent-visibility check unconditional: entityType
|
|
14
|
+
// must name a registered entity, and the row must be visible to the caller.
|
|
15
|
+
// A minimal `contact` fixture entity (PASS_CLAUSE, no `access`) is mounted
|
|
16
|
+
// below and seeded with one row per id this file writes a note to — this
|
|
17
|
+
// file's own assertions are about note-entry behavior, not about parent
|
|
18
|
+
// ownership, so the fixture stays unrestricted on purpose.
|
|
12
19
|
|
|
13
20
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
21
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
22
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
23
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
24
|
import {
|
|
17
25
|
createTestUser,
|
|
@@ -25,12 +33,57 @@ import { createNotesHistoryFeature } from "../feature";
|
|
|
25
33
|
|
|
26
34
|
const notesHistoryFeature = createNotesHistoryFeature();
|
|
27
35
|
|
|
36
|
+
const CONTACT_TABLE = "notes_history_test_contacts";
|
|
37
|
+
const contactEntity = createEntity({
|
|
38
|
+
table: CONTACT_TABLE,
|
|
39
|
+
fields: { name: createTextField({ required: true, maxLength: 64 }) },
|
|
40
|
+
});
|
|
41
|
+
const contactFixtureFeature = defineFeature("notes-history-test-contact-fixture", (r) => {
|
|
42
|
+
r.entity("contact", contactEntity);
|
|
43
|
+
});
|
|
44
|
+
|
|
28
45
|
let stack: TestStack;
|
|
29
46
|
|
|
47
|
+
// Distinct ids (default createTestUser() shares TestUsers.admin.id) —
|
|
48
|
+
// authorId-attribution tests need two genuinely different users.
|
|
49
|
+
const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
|
|
50
|
+
const member = createTestUser({ id: 2, roles: ["TenantMember"] });
|
|
51
|
+
const otherTenant = createTestUser({
|
|
52
|
+
id: 10,
|
|
53
|
+
roles: ["TenantAdmin"],
|
|
54
|
+
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const CONTACT_1 = "30000000-0000-4000-8000-000000000001";
|
|
58
|
+
const CONTACT_2 = "30000000-0000-4000-8000-000000000002";
|
|
59
|
+
const CONTACT_2B = "30000000-0000-4000-8000-00000000002b";
|
|
60
|
+
const CONTACT_3 = "30000000-0000-4000-8000-000000000003";
|
|
61
|
+
const CONTACT_4 = "30000000-0000-4000-8000-000000000004";
|
|
62
|
+
const CONTACT_5 = "30000000-0000-4000-8000-000000000005";
|
|
63
|
+
const CONTACT_6 = "30000000-0000-4000-8000-000000000006";
|
|
64
|
+
const CONTACT_SHARED = "30000000-0000-4000-8000-000000000007";
|
|
65
|
+
|
|
30
66
|
beforeAll(async () => {
|
|
31
|
-
stack = await setupTestStack({ features: [notesHistoryFeature] });
|
|
67
|
+
stack = await setupTestStack({ features: [notesHistoryFeature, contactFixtureFeature] });
|
|
32
68
|
await unsafeCreateEntityTable(stack.db, noteEntryEntity);
|
|
69
|
+
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
33
70
|
await createEventsTable(stack.db);
|
|
71
|
+
|
|
72
|
+
for (const id of [
|
|
73
|
+
CONTACT_1,
|
|
74
|
+
CONTACT_2,
|
|
75
|
+
CONTACT_2B,
|
|
76
|
+
CONTACT_3,
|
|
77
|
+
CONTACT_4,
|
|
78
|
+
CONTACT_5,
|
|
79
|
+
CONTACT_6,
|
|
80
|
+
CONTACT_SHARED,
|
|
81
|
+
]) {
|
|
82
|
+
await asRawClient(stack.db).unsafe(
|
|
83
|
+
`INSERT INTO ${CONTACT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
|
|
84
|
+
[id, admin.tenantId, id],
|
|
85
|
+
);
|
|
86
|
+
}
|
|
34
87
|
});
|
|
35
88
|
|
|
36
89
|
afterAll(async () => {
|
|
@@ -42,16 +95,6 @@ beforeEach(async () => {
|
|
|
42
95
|
await asRawClient(stack.db).unsafe("DELETE FROM read_note_entries");
|
|
43
96
|
});
|
|
44
97
|
|
|
45
|
-
// Distinct ids (default createTestUser() shares TestUsers.admin.id) —
|
|
46
|
-
// authorId-attribution tests need two genuinely different users.
|
|
47
|
-
const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
|
|
48
|
-
const member = createTestUser({ id: 2, roles: ["TenantMember"] });
|
|
49
|
-
const otherTenant = createTestUser({
|
|
50
|
-
id: 10,
|
|
51
|
-
roles: ["TenantAdmin"],
|
|
52
|
-
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
53
|
-
});
|
|
54
|
-
|
|
55
98
|
async function addNote(
|
|
56
99
|
entityType: string,
|
|
57
100
|
entityId: string,
|
|
@@ -80,8 +123,8 @@ async function listNotes(
|
|
|
80
123
|
|
|
81
124
|
describe("notes-history integration — add + list", () => {
|
|
82
125
|
test("add-note lands in read_note_entries with author + timestamp stamped", async () => {
|
|
83
|
-
const { id } = await addNote("contact",
|
|
84
|
-
const rows = await listNotes({ field: "entityId", op: "eq", value:
|
|
126
|
+
const { id } = await addNote("contact", CONTACT_1, "Called about renewal", member);
|
|
127
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_1 });
|
|
85
128
|
expect(rows).toHaveLength(1);
|
|
86
129
|
expect(rows[0]?.["id"]).toBe(id);
|
|
87
130
|
expect(rows[0]?.["body"]).toBe("Called about renewal");
|
|
@@ -95,8 +138,8 @@ describe("notes-history integration — add + list", () => {
|
|
|
95
138
|
});
|
|
96
139
|
|
|
97
140
|
test("a client-supplied authorId in the payload is ignored", async () => {
|
|
98
|
-
await addNote("contact",
|
|
99
|
-
const rows = await listNotes({ field: "entityId", op: "eq", value:
|
|
141
|
+
await addNote("contact", CONTACT_2, "note", member, { authorId: admin.id });
|
|
142
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_2 });
|
|
100
143
|
// The write always attributes to the authenticated caller (member), never
|
|
101
144
|
// to a value smuggled in through the payload — schema doesn't even accept
|
|
102
145
|
// an authorId field, so this proves it's silently dropped, not honoured.
|
|
@@ -104,8 +147,8 @@ describe("notes-history integration — add + list", () => {
|
|
|
104
147
|
});
|
|
105
148
|
|
|
106
149
|
test("a client-supplied authorName in the payload is ignored", async () => {
|
|
107
|
-
await addNote("contact",
|
|
108
|
-
const rows = await listNotes({ field: "entityId", op: "eq", value:
|
|
150
|
+
await addNote("contact", CONTACT_2B, "note", member, { authorName: "Fake Display Name" });
|
|
151
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_2B });
|
|
109
152
|
// Same guard as authorId above: addNotePayloadSchema doesn't accept an
|
|
110
153
|
// authorName field, so a smuggled value never survives to the write —
|
|
111
154
|
// the name (once stamped) can only come from the caller's own session.
|
|
@@ -114,17 +157,17 @@ describe("notes-history integration — add + list", () => {
|
|
|
114
157
|
});
|
|
115
158
|
|
|
116
159
|
test("multiple notes on the same entity accumulate — nothing overwrites", async () => {
|
|
117
|
-
await addNote("contact",
|
|
118
|
-
await addNote("contact",
|
|
119
|
-
await addNote("contact",
|
|
120
|
-
const rows = await listNotes({ field: "entityId", op: "eq", value:
|
|
160
|
+
await addNote("contact", CONTACT_3, "first");
|
|
161
|
+
await addNote("contact", CONTACT_3, "second");
|
|
162
|
+
await addNote("contact", CONTACT_3, "third");
|
|
163
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_3 });
|
|
121
164
|
expect(rows.map((r) => r["body"]).sort()).toEqual(["first", "second", "third"]);
|
|
122
165
|
});
|
|
123
166
|
|
|
124
167
|
test("read-layer composition: filtering by entityId scopes to that entity only", async () => {
|
|
125
|
-
await addNote("contact",
|
|
126
|
-
await addNote("contact",
|
|
127
|
-
const rows = await listNotes({ field: "entityId", op: "eq", value:
|
|
168
|
+
await addNote("contact", CONTACT_4, "for four");
|
|
169
|
+
await addNote("contact", CONTACT_5, "for five");
|
|
170
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_4 });
|
|
128
171
|
expect(rows).toHaveLength(1);
|
|
129
172
|
expect(rows[0]?.["body"]).toBe("for four");
|
|
130
173
|
});
|
|
@@ -132,7 +175,7 @@ describe("notes-history integration — add + list", () => {
|
|
|
132
175
|
test("empty body is rejected", async () => {
|
|
133
176
|
const err = await stack.http.writeErr(
|
|
134
177
|
NotesHistoryHandlers.addNote,
|
|
135
|
-
{ entityType: "contact", entityId:
|
|
178
|
+
{ entityType: "contact", entityId: CONTACT_6, body: " " },
|
|
136
179
|
admin,
|
|
137
180
|
);
|
|
138
181
|
expect(err.httpStatus).toBe(400);
|
|
@@ -141,13 +184,13 @@ describe("notes-history integration — add + list", () => {
|
|
|
141
184
|
|
|
142
185
|
describe("notes-history integration — multi-tenant isolation", () => {
|
|
143
186
|
test("tenant B sees none of tenant A's notes", async () => {
|
|
144
|
-
await addNote("contact",
|
|
187
|
+
await addNote("contact", CONTACT_SHARED, "A's note", admin);
|
|
145
188
|
|
|
146
189
|
expect(
|
|
147
|
-
await listNotes({ field: "entityId", op: "eq", value:
|
|
190
|
+
await listNotes({ field: "entityId", op: "eq", value: CONTACT_SHARED }, otherTenant),
|
|
148
191
|
).toHaveLength(0);
|
|
149
192
|
expect(
|
|
150
|
-
await listNotes({ field: "entityId", op: "eq", value:
|
|
193
|
+
await listNotes({ field: "entityId", op: "eq", value: CONTACT_SHARED }, admin),
|
|
151
194
|
).toHaveLength(1);
|
|
152
195
|
});
|
|
153
196
|
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.244.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "add-note now always verifies entityType/entityId against the parent entity's own read path",
|
|
6
|
+
"detail": "Before this change, add-note took entityType/entityId straight from the client with no check — any dispatch-eligible tenant user could attach a note to any parent object, including one they couldn't otherwise see. add-note now unconditionally verifies that entityType names a registered entity and that the row is visible to the caller through that entity's own read path (tenant scope plus its `access.read` ownership) before accepting the write; entries that fail either check are rejected with a not_found response. createNotesHistoryFeature also accepts a `parents` option — an allowlist further narrowing which registered entities may be a note's parent — but this option is additive-only; the parent-visibility check itself is not gated behind it.",
|
|
7
|
+
"migration": "entityType must now name an entity registered in the same app (via r.entity(...) or a bundled feature), and the caller must be able to see the target row through that entity's own read path. Any consumer that called add-note with an entityType that is NOT a registered entity name, or with an entityId the caller could not otherwise read, will now get a not_found error instead of a successful write — register the parent entity (and, if it needs row-level ownership, its `access.read` rule) before upgrading."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.215.3",
|
|
4
11
|
"type": "improvement",
|
|
@@ -30,6 +30,7 @@ function registerNotesHistory(
|
|
|
30
30
|
r: FeatureRegistrar<typeof NOTES_HISTORY_FEATURE_NAME>,
|
|
31
31
|
access: AccessRule,
|
|
32
32
|
ownership: EntityDefinition["access"] | undefined,
|
|
33
|
+
parents: ReadonlySet<string> | undefined,
|
|
33
34
|
): void {
|
|
34
35
|
r.describe(
|
|
35
36
|
"Generic, host-agnostic, append-only note history for any entity. Owns one event-sourced entity, `note-entry` (`read_note_entries`), keyed by (entityType, entityId) — so attaching notes adds NO column to the host entity and needs no relational pivot or JOIN. Provides a `create` write-handler (author stamped server-side from the caller, never client-supplied) and a `list` query filterable on entityId. Deliberately append-only: no update or delete handler is registered — a correction is a new entry, not an edit, so who-said-what-when stays reconstructable. Every path uses one access rule — adopt the host's model with createNotesHistoryFeature({ access: { openToAll: true } }) or pin roles with createNotesHistoryFeature({ roles }).",
|
|
@@ -43,7 +44,7 @@ function registerNotesHistory(
|
|
|
43
44
|
const entity = createNoteEntryEntity(ownership);
|
|
44
45
|
r.entity("note-entry", entity);
|
|
45
46
|
|
|
46
|
-
r.writeHandler(createAddNoteHandler(access));
|
|
47
|
+
r.writeHandler(createAddNoteHandler(access, parents));
|
|
47
48
|
r.queryHandler(
|
|
48
49
|
defineEntityListHandler("note-entry", entity, {
|
|
49
50
|
access,
|
|
@@ -56,7 +57,7 @@ function registerNotesHistory(
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export const notesHistoryFeature = defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
|
|
59
|
-
registerNotesHistory(r, DEFAULT_NOTES_HISTORY_ACCESS, undefined),
|
|
60
|
+
registerNotesHistory(r, DEFAULT_NOTES_HISTORY_ACCESS, undefined, undefined),
|
|
60
61
|
);
|
|
61
62
|
|
|
62
63
|
export type NotesHistoryFeatureOptions = {
|
|
@@ -83,6 +84,16 @@ export type NotesHistoryFeatureOptions = {
|
|
|
83
84
|
* crypto-shredding — a silent Art.17 failure, not a thrown error. Make
|
|
84
85
|
* sure any `ownership.write` you set covers that role, or leave it unset. */
|
|
85
86
|
readonly ownership?: EntityDefinition["access"];
|
|
87
|
+
/** Allowlist further narrowing which registered entities may be used as a
|
|
88
|
+
* note's parent (entityType). This is NOT what turns parent-checking on —
|
|
89
|
+
* add-note always verifies that entityType names a registered entity and
|
|
90
|
+
* that the row is visible to the caller through that entity's own read
|
|
91
|
+
* path (tenant scope plus its `access.read` ownership); an entityType
|
|
92
|
+
* that names no registered entity is rejected regardless of this option.
|
|
93
|
+
* Setting `parents` narrows further, to a specific set of entity names —
|
|
94
|
+
* useful when a host entity is registered but should never be a valid
|
|
95
|
+
* note parent. */
|
|
96
|
+
readonly parents?: readonly string[];
|
|
86
97
|
};
|
|
87
98
|
|
|
88
99
|
function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
|
|
@@ -96,7 +107,12 @@ function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
|
|
|
96
107
|
export function createNotesHistoryFeature(
|
|
97
108
|
opts: NotesHistoryFeatureOptions = {},
|
|
98
109
|
): typeof notesHistoryFeature {
|
|
99
|
-
if (
|
|
110
|
+
if (
|
|
111
|
+
opts.access === undefined &&
|
|
112
|
+
opts.roles === undefined &&
|
|
113
|
+
opts.ownership === undefined &&
|
|
114
|
+
opts.parents === undefined
|
|
115
|
+
) {
|
|
100
116
|
return notesHistoryFeature;
|
|
101
117
|
}
|
|
102
118
|
if (hasWhereRule(opts.ownership?.write)) {
|
|
@@ -105,11 +121,23 @@ export function createNotesHistoryFeature(
|
|
|
105
121
|
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
106
122
|
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
107
123
|
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
108
|
-
"evaluate them
|
|
109
|
-
"
|
|
124
|
+
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
125
|
+
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
126
|
+
"leave it unset.",
|
|
110
127
|
);
|
|
111
128
|
}
|
|
129
|
+
// A mount that accepts no parents at all can never take a note write —
|
|
130
|
+
// that's a config mistake, not a valid allowlist. Omit `parents` instead
|
|
131
|
+
// of passing an empty array.
|
|
132
|
+
if (opts.parents !== undefined && opts.parents.length === 0) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
"createNotesHistoryFeature({ parents }): parents must not be an empty array — " +
|
|
135
|
+
"an empty allowlist rejects every add-note call. Omit `parents` to keep " +
|
|
136
|
+
"today's unrestricted behavior instead.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const parents = opts.parents === undefined ? undefined : new Set(opts.parents);
|
|
112
140
|
return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
|
|
113
|
-
registerNotesHistory(r, resolveAccess(opts), opts.ownership),
|
|
141
|
+
registerNotesHistory(r, resolveAccess(opts), opts.ownership, parents),
|
|
114
142
|
);
|
|
115
143
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { decryptStoredPii } from "../../shared";
|
|
4
5
|
import { userTable } from "../../user";
|
|
5
6
|
import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
|
|
6
7
|
import { noteEntryExecutor } from "../executor";
|
|
8
|
+
import { parentRowIsVisible } from "../parent-visibility";
|
|
7
9
|
import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
8
10
|
|
|
9
11
|
// add-note — appends a note-entry to (entityType, entityId). authorId is
|
|
@@ -11,8 +13,16 @@ import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
|
11
13
|
// (event.user.id), so a note can't be authored as someone else. No update or
|
|
12
14
|
// delete counterpart is registered — see entity.ts for why append-only is
|
|
13
15
|
// deliberate.
|
|
16
|
+
//
|
|
17
|
+
// entityType/entityId are never trusted client input: entityType must name a
|
|
18
|
+
// registered entity, and the row must be visible to the caller through that
|
|
19
|
+
// entity's own read path (tenant scope plus its `access.read` ownership) —
|
|
20
|
+
// see parent-visibility.ts. `parents`, when set, is an ADDITIONAL allowlist
|
|
21
|
+
// narrowing which registered entities may be a note's parent at all; it is
|
|
22
|
+
// not what turns the check on.
|
|
14
23
|
export function createAddNoteHandler(
|
|
15
24
|
access: AccessRule = DEFAULT_NOTES_HISTORY_ACCESS,
|
|
25
|
+
parents?: ReadonlySet<string>,
|
|
16
26
|
): WriteHandlerDef {
|
|
17
27
|
return {
|
|
18
28
|
name: "add-note",
|
|
@@ -23,6 +33,23 @@ export function createAddNoteHandler(
|
|
|
23
33
|
handler: async (event, ctx) => {
|
|
24
34
|
const payload = event.payload as AddNotePayload; // @cast-boundary engine-payload
|
|
25
35
|
|
|
36
|
+
// NotFoundError, not an access-denied error, so the response doesn't
|
|
37
|
+
// double as an existence oracle — same policy as executor.detail,
|
|
38
|
+
// which never distinguishes "no access" from "doesn't exist".
|
|
39
|
+
if (parents !== undefined && !parents.has(payload.entityType)) {
|
|
40
|
+
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
41
|
+
}
|
|
42
|
+
const visible = await parentRowIsVisible(
|
|
43
|
+
ctx.registry,
|
|
44
|
+
payload.entityType,
|
|
45
|
+
payload.entityId,
|
|
46
|
+
event.user,
|
|
47
|
+
ctx.db,
|
|
48
|
+
);
|
|
49
|
+
if (!visible) {
|
|
50
|
+
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
let authorName: string | null = null;
|
|
27
54
|
try {
|
|
28
55
|
// read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { EventStoreExecutor, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntityExecutor,
|
|
4
|
+
type EntityDefinition,
|
|
5
|
+
isUuid,
|
|
6
|
+
type Registry,
|
|
7
|
+
type SessionUser,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
|
|
10
|
+
// Keyed by definition identity, not by entity name — two test stacks can
|
|
11
|
+
// register different EntityDefinitions under the same entity name.
|
|
12
|
+
const executorsByEntity = new WeakMap<EntityDefinition, EventStoreExecutor>();
|
|
13
|
+
|
|
14
|
+
function idShapeMatchesEntity(entity: EntityDefinition, entityId: string): boolean {
|
|
15
|
+
return entity.idType === "serial" ? /^\d+$/.test(entityId) : isUuid(entityId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Checks whether a note's proposed (entityType, entityId) parent is one the
|
|
19
|
+
// caller can see via the parent entity's own read path — tenant scope plus
|
|
20
|
+
// its `access.read` ownership. Used to gate add-note when a `parents`
|
|
21
|
+
// allowlist is configured (see feature.ts).
|
|
22
|
+
export async function parentRowIsVisible(
|
|
23
|
+
registry: Registry,
|
|
24
|
+
entityType: string,
|
|
25
|
+
entityId: string,
|
|
26
|
+
user: SessionUser,
|
|
27
|
+
db: TenantDb,
|
|
28
|
+
): Promise<boolean> {
|
|
29
|
+
// Default-deny: an entityType that names no registered entity has no read
|
|
30
|
+
// path to check visibility against, so it can never be a valid parent.
|
|
31
|
+
const entity = registry.getEntity(entityType);
|
|
32
|
+
if (!entity) return false;
|
|
33
|
+
|
|
34
|
+
// A malformed id would abort the write transaction on a Postgres cast
|
|
35
|
+
// error (22P02) instead of cleanly denying — reject it before it reaches
|
|
36
|
+
// the executor.
|
|
37
|
+
if (!idShapeMatchesEntity(entity, entityId)) return false;
|
|
38
|
+
|
|
39
|
+
let executor = executorsByEntity.get(entity);
|
|
40
|
+
if (!executor) {
|
|
41
|
+
executor = createEntityExecutor(entityType, entity).executor;
|
|
42
|
+
executorsByEntity.set(entity, executor);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (await executor.detail({ id: entityId }, user, db)) !== null;
|
|
46
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// confirm it doesn't touch the row.
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
9
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
10
|
import {
|
|
9
11
|
createTestUser,
|
|
@@ -21,10 +23,34 @@ let stack: TestStack;
|
|
|
21
23
|
const author = createTestUser({ id: 1, roles: ["TenantMember"] });
|
|
22
24
|
const other = createTestUser({ id: 2, roles: ["TenantMember"] });
|
|
23
25
|
|
|
26
|
+
// fw#2627 made add-note's parent-visibility check unconditional — entityType
|
|
27
|
+
// must name a registered entity whose row is visible to the caller. This
|
|
28
|
+
// fixture stands in for that parent; it deliberately has no `access` (PASS_CLAUSE)
|
|
29
|
+
// so it never gates on its own, keeping this file focused on author filtering.
|
|
30
|
+
const CONTACT_TABLE = "notes_user_data_test_contacts";
|
|
31
|
+
const contactEntity = createEntity({
|
|
32
|
+
table: CONTACT_TABLE,
|
|
33
|
+
fields: { name: createTextField({ required: true, maxLength: 64 }) },
|
|
34
|
+
});
|
|
35
|
+
const contactFixtureFeature = defineFeature("notes-user-data-test-contact-fixture", (r) => {
|
|
36
|
+
r.entity("contact", contactEntity);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// author and other share the same tenantId (both from TestUsers.admin), so
|
|
40
|
+
// one contact row is visible to both.
|
|
41
|
+
const CONTACT_1 = "30000000-0000-4000-8000-000000000001";
|
|
42
|
+
|
|
24
43
|
beforeAll(async () => {
|
|
25
|
-
stack = await setupTestStack({
|
|
44
|
+
stack = await setupTestStack({
|
|
45
|
+
features: [createNotesHistoryFeature(), contactFixtureFeature],
|
|
46
|
+
});
|
|
26
47
|
await unsafeCreateEntityTable(stack.db, noteEntryEntity);
|
|
48
|
+
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
27
49
|
await createEventsTable(stack.db);
|
|
50
|
+
await asRawClient(stack.db).unsafe(
|
|
51
|
+
`INSERT INTO ${CONTACT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
|
|
52
|
+
[CONTACT_1, author.tenantId, "Contact 1"],
|
|
53
|
+
);
|
|
28
54
|
});
|
|
29
55
|
|
|
30
56
|
afterAll(async () => {
|
|
@@ -35,12 +61,12 @@ describe("noteEntryExportHook", () => {
|
|
|
35
61
|
test("includes only the requesting user's own authored notes", async () => {
|
|
36
62
|
await stack.http.writeOk(
|
|
37
63
|
NotesHistoryHandlers.addNote,
|
|
38
|
-
{ entityType: "contact", entityId:
|
|
64
|
+
{ entityType: "contact", entityId: CONTACT_1, body: "by author" },
|
|
39
65
|
author,
|
|
40
66
|
);
|
|
41
67
|
await stack.http.writeOk(
|
|
42
68
|
NotesHistoryHandlers.addNote,
|
|
43
|
-
{ entityType: "contact", entityId:
|
|
69
|
+
{ entityType: "contact", entityId: CONTACT_1, body: "by other" },
|
|
44
70
|
other,
|
|
45
71
|
);
|
|
46
72
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
// GDPR-forget hooks used to `await crud.forget(...)` and drop the result —
|
|
4
|
+
// a silent ownership_denied (or any other failure) meant the row was never
|
|
5
|
+
// actually erased, with nothing surfacing the miss. Callers must assert.
|
|
6
|
+
export function assertErased(result: WriteResult<unknown>, entityName: string, id: string): void {
|
|
7
|
+
// skip: the happy path — the assertion below only concerns failures.
|
|
8
|
+
if (result.isSuccess) return;
|
|
9
|
+
// skip: erasure is idempotent — a row that is already gone (e.g. a parallel
|
|
10
|
+
// forget sweep) satisfies the erase request just as well as one this call removed.
|
|
11
|
+
if (result.error.code === "not_found") return;
|
|
12
|
+
throw new Error(
|
|
13
|
+
`[user-data-rights] failed to erase ${entityName}/${id}: ${result.error.code} — ${result.error.message}`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import type { OwnershipMap } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
|
|
3
|
-
// `where`-rules are read-path only (buildOwnershipClause).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// where-specific handling either) and only blows up the first time a create
|
|
9
|
-
// hits it. Feature factories that accept an `ownership` option must reject
|
|
10
|
-
// this shape at build time instead of shipping the landmine.
|
|
3
|
+
// `where`-rules are read-path only (buildOwnershipClause). The write path
|
|
4
|
+
// decides in memory against the concrete row, so a where-rule there can only
|
|
5
|
+
// ever deny — boot validation rejects the shape (fw#2626). Feature factories
|
|
6
|
+
// that accept an `ownership` option reject it earlier, at build time, so the
|
|
7
|
+
// author gets the option name in the message instead of an entity scope.
|
|
11
8
|
export function hasWhereRule(map: OwnershipMap | undefined): boolean {
|
|
12
9
|
if (!map) return false;
|
|
13
10
|
return Object.values(map).some((rule) => rule !== "all" && rule.kind === "where");
|
package/src/shared/index.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
// H.2
|
|
2
|
-
// guard test. A where-rule in ownership.write
|
|
3
|
-
// has no
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// needed — the guard fires at feature-construction time.
|
|
1
|
+
// H.2 build-time guard — mirrors notes-history-ownership.integration.test.ts's
|
|
2
|
+
// guard test. A where-rule in ownership.write can only ever deny (the write
|
|
3
|
+
// path has no SQL layer); framework boot validation rejects it too, this guard
|
|
4
|
+
// just fires earlier with the option name in the message. No DB needed — it
|
|
5
|
+
// runs at feature-construction time.
|
|
7
6
|
|
|
8
7
|
import { describe, expect, test } from "bun:test";
|
|
9
8
|
import { createTagsFeature } from "../feature";
|
|
10
9
|
|
|
11
10
|
describe("tags — boot guard rejects a where-rule in ownership.write", () => {
|
|
12
|
-
test("createTagsFeature throws instead of shipping a
|
|
11
|
+
test("createTagsFeature throws instead of shipping a deny-only ownership map", () => {
|
|
13
12
|
expect(() =>
|
|
14
13
|
createTagsFeature({
|
|
15
14
|
ownership: {
|
package/src/tags/feature.ts
CHANGED
|
@@ -187,8 +187,9 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
|
|
|
187
187
|
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
188
188
|
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
189
189
|
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
190
|
-
"evaluate them
|
|
191
|
-
"
|
|
190
|
+
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
191
|
+
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
192
|
+
"leave it unset.",
|
|
192
193
|
);
|
|
193
194
|
}
|
|
194
195
|
const access = resolveAccess(opts);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type UserDataExportHook,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { configValueEntity, configValuesTable } from "../../config";
|
|
9
|
+
import { assertErased } from "../../shared";
|
|
9
10
|
import { featureMounted } from "./feature-mounted";
|
|
10
11
|
|
|
11
12
|
// userData-Hooks for config's USER-scoped rows (userId set). Tenant-/system-
|
|
@@ -46,6 +47,6 @@ export const configValueDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
|
46
47
|
for (const row of rows) {
|
|
47
48
|
const id = row["id"]; // @cast-boundary db-row
|
|
48
49
|
if (typeof id !== "string") continue;
|
|
49
|
-
await crud.forget({ id }, systemUser, tdb);
|
|
50
|
+
assertErased(await crud.forget({ id }, systemUser, tdb), "config-value", id);
|
|
50
51
|
}
|
|
51
52
|
};
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
fileRefEntity,
|
|
19
19
|
fileRefsTable,
|
|
20
20
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
21
|
+
import { assertErased } from "../../shared";
|
|
21
22
|
|
|
22
23
|
// Forget writes go through the executor (events), not deleteMany/updateMany:
|
|
23
24
|
// a projection rebuild replays the events, so the erasure survives. Eventless
|
|
@@ -258,6 +259,6 @@ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
|
258
259
|
for (const row of rows) {
|
|
259
260
|
const id = row["id"]; // @cast-boundary db-row
|
|
260
261
|
if (typeof id !== "string") continue;
|
|
261
|
-
await crud.forget({ id }, systemUser, tdb);
|
|
262
|
+
assertErased(await crud.forget({ id }, systemUser, tdb), "fileRef", id);
|
|
262
263
|
}
|
|
263
264
|
};
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type UserDataExportHook,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { notificationPreferenceEntity, notificationPreferencesTable } from "../../delivery";
|
|
9
|
+
import { assertErased } from "../../shared";
|
|
9
10
|
import { featureMounted } from "./feature-mounted";
|
|
10
11
|
|
|
11
12
|
// userData-Hooks for delivery's notification-preference rows. Event-sourced
|
|
@@ -49,6 +50,6 @@ export const notificationPreferenceDeleteHook: UserDataDeleteHook = async (ctx)
|
|
|
49
50
|
for (const row of rows) {
|
|
50
51
|
const id = row["id"]; // @cast-boundary db-row
|
|
51
52
|
if (typeof id !== "string") continue;
|
|
52
|
-
await crud.forget({ id }, systemUser, tdb);
|
|
53
|
+
assertErased(await crud.forget({ id }, systemUser, tdb), "notification-preference", id);
|
|
53
54
|
}
|
|
54
55
|
};
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type UserDataExportHook,
|
|
7
7
|
type UserDataHookCtx,
|
|
8
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
-
import { decryptStoredPii, mapWithConcurrency } from "../../shared";
|
|
9
|
+
import { assertErased, decryptStoredPii, mapWithConcurrency } from "../../shared";
|
|
10
10
|
import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant";
|
|
11
11
|
import { userTable } from "../../user";
|
|
12
12
|
import { featureMounted } from "./feature-mounted";
|
|
@@ -100,7 +100,7 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
|
|
|
100
100
|
const id = row["id"]; // @cast-boundary db-row
|
|
101
101
|
if (typeof id !== "string") continue;
|
|
102
102
|
if (strategy === "delete") {
|
|
103
|
-
await crud.forget({ id }, systemUser, tdb);
|
|
103
|
+
assertErased(await crud.forget({ id }, systemUser, tdb), "tenant-invitation", id);
|
|
104
104
|
} else {
|
|
105
105
|
// Row-id in the pseudonym keeps the (tenantId, email) unique index
|
|
106
106
|
// collision-free when a user has invitations in several states.
|