@cosmicdrift/kumiko-bundled-features 0.244.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 +9 -9
- package/src/auth-mfa-user-data/hooks.ts +2 -1
- package/src/custom-fields/lib/define-or-resurrect.ts +2 -2
- package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +238 -0
- package/src/folders/__tests__/folders.integration.test.ts +96 -36
- package/src/folders/changes.json +9 -1
- package/src/folders/handlers/clear-folder.write.ts +24 -0
- package/src/folders/handlers/set-folder.write.ts +24 -0
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +30 -6
- 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 -1
- 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 +2 -0
- package/src/shared/parent-visibility.ts +48 -0
- package/src/tags/__tests__/tags-ownership.test.ts +6 -7
- package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +219 -0
- package/src/tags/__tests__/tags.integration.test.ts +112 -38
- package/src/tags/changes.json +9 -1
- package/src/tags/feature.ts +3 -2
- package/src/tags/handlers/assign-tag.write.ts +24 -0
- package/src/tags/handlers/remove-tag.write.ts +24 -0
- 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
|
@@ -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,6 +1,7 @@
|
|
|
1
1
|
import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import {
|
|
3
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { decryptStoredPii, parentRowIsVisible } from "../../shared";
|
|
4
5
|
import { userTable } from "../../user";
|
|
5
6
|
import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
|
|
6
7
|
import { noteEntryExecutor } from "../executor";
|
|
@@ -11,8 +12,16 @@ import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
|
11
12
|
// (event.user.id), so a note can't be authored as someone else. No update or
|
|
12
13
|
// delete counterpart is registered — see entity.ts for why append-only is
|
|
13
14
|
// deliberate.
|
|
15
|
+
//
|
|
16
|
+
// entityType/entityId are never trusted client input: entityType must name a
|
|
17
|
+
// registered entity, and the row must be visible to the caller through that
|
|
18
|
+
// entity's own read path (tenant scope plus its `access.read` ownership) —
|
|
19
|
+
// see shared/parent-visibility.ts. `parents`, when set, is an ADDITIONAL allowlist
|
|
20
|
+
// narrowing which registered entities may be a note's parent at all; it is
|
|
21
|
+
// not what turns the check on.
|
|
14
22
|
export function createAddNoteHandler(
|
|
15
23
|
access: AccessRule = DEFAULT_NOTES_HISTORY_ACCESS,
|
|
24
|
+
parents?: ReadonlySet<string>,
|
|
16
25
|
): WriteHandlerDef {
|
|
17
26
|
return {
|
|
18
27
|
name: "add-note",
|
|
@@ -23,6 +32,23 @@ export function createAddNoteHandler(
|
|
|
23
32
|
handler: async (event, ctx) => {
|
|
24
33
|
const payload = event.payload as AddNotePayload; // @cast-boundary engine-payload
|
|
25
34
|
|
|
35
|
+
// NotFoundError, not an access-denied error, so the response doesn't
|
|
36
|
+
// double as an existence oracle — same policy as executor.detail,
|
|
37
|
+
// which never distinguishes "no access" from "doesn't exist".
|
|
38
|
+
if (parents !== undefined && !parents.has(payload.entityType)) {
|
|
39
|
+
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
40
|
+
}
|
|
41
|
+
const visible = await parentRowIsVisible(
|
|
42
|
+
ctx.registry,
|
|
43
|
+
payload.entityType,
|
|
44
|
+
payload.entityId,
|
|
45
|
+
event.user,
|
|
46
|
+
ctx.db,
|
|
47
|
+
);
|
|
48
|
+
if (!visible) {
|
|
49
|
+
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
let authorName: string | null = null;
|
|
27
53
|
try {
|
|
28
54
|
// read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
|
|
@@ -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,3 +1,4 @@
|
|
|
1
|
+
export { assertErased } from "./assert-erased";
|
|
1
2
|
export {
|
|
2
3
|
type ChunkedMigrationOptions,
|
|
3
4
|
type ChunkedMigrationResult,
|
|
@@ -16,6 +17,7 @@ export { isWithinGracePeriod } from "./grace-period";
|
|
|
16
17
|
export { hasWhereRule } from "./has-where-rule";
|
|
17
18
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
18
19
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
20
|
+
export { parentRowIsVisible } from "./parent-visibility";
|
|
19
21
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
20
22
|
export { sessionField } from "./session-field";
|
|
21
23
|
export { sessionLocaleField } from "./session-locale-field";
|
|
@@ -0,0 +1,48 @@
|
|
|
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 client-supplied (entityType, entityId) host reference is one
|
|
19
|
+
// the caller can see via that entity's own read path — tenant scope plus its
|
|
20
|
+
// `access.read` ownership. Shared by every handler that attaches something to a
|
|
21
|
+
// host entity the client names (add-note, assign-tag, remove-tag, set-folder,
|
|
22
|
+
// clear-folder); those handlers deny with NotFoundError so the response never
|
|
23
|
+
// doubles as an existence oracle.
|
|
24
|
+
export async function parentRowIsVisible(
|
|
25
|
+
registry: Registry,
|
|
26
|
+
entityType: string,
|
|
27
|
+
entityId: string,
|
|
28
|
+
user: SessionUser,
|
|
29
|
+
db: TenantDb,
|
|
30
|
+
): Promise<boolean> {
|
|
31
|
+
// Default-deny: an entityType that names no registered entity has no read
|
|
32
|
+
// path to check visibility against, so it can never be a valid parent.
|
|
33
|
+
const entity = registry.getEntity(entityType);
|
|
34
|
+
if (!entity) return false;
|
|
35
|
+
|
|
36
|
+
// A malformed id would abort the write transaction on a Postgres cast
|
|
37
|
+
// error (22P02) instead of cleanly denying — reject it before it reaches
|
|
38
|
+
// the executor.
|
|
39
|
+
if (!idShapeMatchesEntity(entity, entityId)) return false;
|
|
40
|
+
|
|
41
|
+
let executor = executorsByEntity.get(entity);
|
|
42
|
+
if (!executor) {
|
|
43
|
+
executor = createEntityExecutor(entityType, entity).executor;
|
|
44
|
+
executorsByEntity.set(entity, executor);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (await executor.detail({ id: entityId }, user, db)) !== null;
|
|
48
|
+
}
|
|
@@ -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: {
|