@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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// assign-tag/remove-tag previously took entityType/entityId straight from the
|
|
2
|
+
// client, so any dispatch-eligible tenant user could attach a catalog tag to —
|
|
3
|
+
// or detach one from — an object they had no read access to. The tag-assignment
|
|
4
|
+
// aggregate-id is derived from the tenant, so this was never a cross-tenant
|
|
5
|
+
// hole; it was "tag an object inside your own tenant that you aren't allowed to
|
|
6
|
+
// read", which also leaks through the assignment list of anyone who can see it.
|
|
7
|
+
//
|
|
8
|
+
// The check is unconditional (default-on, no opt-in): entityType must name a
|
|
9
|
+
// registered entity, and the host row must be visible through that entity's own
|
|
10
|
+
// read path (tenant scope plus its `access.read` ownership).
|
|
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 { TagsHandlers } from "../constants";
|
|
28
|
+
import { tagAssignmentEntity, tagEntity } from "../entity";
|
|
29
|
+
import { createTagsFeature } from "../feature";
|
|
30
|
+
|
|
31
|
+
const PROJECT_TABLE = "tags_pv_test_projects";
|
|
32
|
+
|
|
33
|
+
// Team-scoped read ownership on the host 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
|
+
const projectEntity = createEntity({
|
|
48
|
+
table: PROJECT_TABLE,
|
|
49
|
+
fields: {
|
|
50
|
+
teamId: createTextField({ required: true, maxLength: 64 }),
|
|
51
|
+
name: createTextField({ required: true, maxLength: 64 }),
|
|
52
|
+
},
|
|
53
|
+
access: projectOwnership,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const hostFixturesFeature = defineFeature("tags-pv-test-fixtures", (r) => {
|
|
57
|
+
r.entity("project", projectEntity);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type TestUser = ReturnType<typeof createTestUser>;
|
|
61
|
+
|
|
62
|
+
// Same tenant, different team claim.
|
|
63
|
+
const userA: TestUser = createTestUser({
|
|
64
|
+
id: 60,
|
|
65
|
+
roles: ["TenantMember"],
|
|
66
|
+
claims: { team: "team-a" },
|
|
67
|
+
});
|
|
68
|
+
const userB: TestUser = createTestUser({
|
|
69
|
+
id: 61,
|
|
70
|
+
roles: ["TenantMember"],
|
|
71
|
+
claims: { team: "team-b" },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const PROJECT_A = "b0000000-0000-4000-8000-000000000001";
|
|
75
|
+
// A second team-a project, dedicated to the remove-tag denial case so its
|
|
76
|
+
// assignment count stays independent of PROJECT_A's.
|
|
77
|
+
const PROJECT_A2 = "b0000000-0000-4000-8000-000000000002";
|
|
78
|
+
// Never assigned anything — the "no assignment" half of the oracle check.
|
|
79
|
+
const PROJECT_A3 = "b0000000-0000-4000-8000-000000000003";
|
|
80
|
+
|
|
81
|
+
let stack: TestStack;
|
|
82
|
+
let tagId: string;
|
|
83
|
+
|
|
84
|
+
beforeAll(async () => {
|
|
85
|
+
stack = await setupTestStack({
|
|
86
|
+
features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
|
|
87
|
+
});
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
89
|
+
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
90
|
+
await unsafeCreateEntityTable(stack.db, projectEntity);
|
|
91
|
+
await createEventsTable(stack.db);
|
|
92
|
+
await asRawClient(stack.db).unsafe(
|
|
93
|
+
`INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES
|
|
94
|
+
($1, $2, 'team-a', 'Project A'),
|
|
95
|
+
($3, $2, 'team-a', 'Project A2'),
|
|
96
|
+
($4, $2, 'team-a', 'Project A3')`,
|
|
97
|
+
[PROJECT_A, userA.tenantId, PROJECT_A2, PROJECT_A3],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const tag = await stack.http.writeOk<{ id: string }>(
|
|
101
|
+
TagsHandlers.createTag,
|
|
102
|
+
{ name: "VIP" },
|
|
103
|
+
userA,
|
|
104
|
+
);
|
|
105
|
+
tagId = tag.id;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterAll(async () => {
|
|
109
|
+
await stack.cleanup();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
async function activeAssignmentsFor(entityId: string): Promise<number> {
|
|
113
|
+
const rows = await asRawClient(stack.db).unsafe<{ n: number }>(
|
|
114
|
+
"SELECT count(*)::int AS n FROM read_tag_assignments WHERE entity_id = $1 AND is_deleted = FALSE",
|
|
115
|
+
[entityId],
|
|
116
|
+
);
|
|
117
|
+
return rows[0]?.n ?? 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function assign(entityType: string, entityId: string, user: TestUser) {
|
|
121
|
+
return stack.http.writeOk<{ id: string }>(
|
|
122
|
+
TagsHandlers.assignTag,
|
|
123
|
+
{ tagId, entityType, entityId },
|
|
124
|
+
user,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assignErr(entityType: string, entityId: string, user: TestUser) {
|
|
129
|
+
return stack.http.writeErr(TagsHandlers.assignTag, { tagId, entityType, entityId }, user);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function remove(entityType: string, entityId: string, user: TestUser) {
|
|
133
|
+
return stack.http.writeOk<{ id: string }>(
|
|
134
|
+
TagsHandlers.removeTag,
|
|
135
|
+
{ tagId, entityType, entityId },
|
|
136
|
+
user,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function removeErr(entityType: string, entityId: string, user: TestUser) {
|
|
141
|
+
return stack.http.writeErr(TagsHandlers.removeTag, { tagId, entityType, entityId }, user);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("tags integration — assign-tag parent-visibility", () => {
|
|
145
|
+
test("a host row visible through the host entity's own read path can be tagged", async () => {
|
|
146
|
+
const result = await assign("project", PROJECT_A, userA);
|
|
147
|
+
expect(result.id).toBeTruthy();
|
|
148
|
+
expect(await activeAssignmentsFor(PROJECT_A)).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a host row from a foreign team is denied, and nothing is written", async () => {
|
|
152
|
+
const err = await assignErr("project", PROJECT_A2, userB);
|
|
153
|
+
expect(err.code).toBe("not_found");
|
|
154
|
+
expect(err.httpStatus).toBe(404);
|
|
155
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(0);
|
|
156
|
+
|
|
157
|
+
// Positive control: PROJECT_A2 IS taggable — by its own team — so the deny
|
|
158
|
+
// above is the team-claim check, not a row that doesn't exist at all.
|
|
159
|
+
const result = await assign("project", PROJECT_A2, userA);
|
|
160
|
+
expect(result.id).toBeTruthy();
|
|
161
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("an entityType that is not a registered entity is denied", async () => {
|
|
165
|
+
const err = await assignErr("totally-unknown-entity", PROJECT_A, userA);
|
|
166
|
+
expect(err.code).toBe("not_found");
|
|
167
|
+
expect(err.httpStatus).toBe(404);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a malformed entityId is denied cleanly, without poisoning the connection", async () => {
|
|
171
|
+
const err = await assignErr("project", "not-a-uuid", userA);
|
|
172
|
+
expect(err.code).toBe("not_found");
|
|
173
|
+
expect(err.httpStatus).toBe(404);
|
|
174
|
+
|
|
175
|
+
// The stack must still be usable — proves the malformed id was rejected
|
|
176
|
+
// before it reached a query, not caught after a tx-poisoning cast error.
|
|
177
|
+
expect((await assign("project", PROJECT_A, userA)).id).toBeTruthy();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("tags integration — remove-tag parent-visibility", () => {
|
|
182
|
+
test("a foreign team cannot detach an existing assignment; the row survives", async () => {
|
|
183
|
+
await assign("project", PROJECT_A2, userA);
|
|
184
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
185
|
+
|
|
186
|
+
const err = await removeErr("project", PROJECT_A2, userB);
|
|
187
|
+
expect(err.code).toBe("not_found");
|
|
188
|
+
expect(err.httpStatus).toBe(404);
|
|
189
|
+
// Zero-write: the assignment userA made is still active, not soft-deleted.
|
|
190
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
191
|
+
|
|
192
|
+
// Positive control: the owning team CAN detach it.
|
|
193
|
+
await remove("project", PROJECT_A2, userA);
|
|
194
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("the denial carries no existence oracle: assigned and unassigned rows answer alike", async () => {
|
|
198
|
+
await assign("project", PROJECT_A2, userA);
|
|
199
|
+
expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
|
|
200
|
+
expect(await activeAssignmentsFor(PROJECT_A3)).toBe(0);
|
|
201
|
+
|
|
202
|
+
// remove-tag normally reports success for a never-assigned pair, so without
|
|
203
|
+
// the gate running FIRST the two responses below would differ and leak
|
|
204
|
+
// whether the invisible host row carries this tag.
|
|
205
|
+
const onAssigned = await removeErr("project", PROJECT_A2, userB);
|
|
206
|
+
const onUnassigned = await removeErr("project", PROJECT_A3, userB);
|
|
207
|
+
expect(onAssigned.code).toBe(onUnassigned.code);
|
|
208
|
+
expect(onAssigned.httpStatus).toBe(onUnassigned.httpStatus);
|
|
209
|
+
expect(onAssigned.code).toBe("not_found");
|
|
210
|
+
|
|
211
|
+
await remove("project", PROJECT_A2, userA);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("an entityType that is not a registered entity is denied", async () => {
|
|
215
|
+
const err = await removeErr("totally-unknown-entity", PROJECT_A, userA);
|
|
216
|
+
expect(err.code).toBe("not_found");
|
|
217
|
+
expect(err.httpStatus).toBe(404);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -7,9 +7,16 @@
|
|
|
7
7
|
// - read-layer composition both directions (tags of an entity / entities of a tag)
|
|
8
8
|
// - assign + remove are idempotent (re-assign = one row, remove-missing = ok)
|
|
9
9
|
// - multi-tenant isolation
|
|
10
|
+
//
|
|
11
|
+
// assign-tag/remove-tag verify that entityType names a registered entity and
|
|
12
|
+
// that the host row is visible to the caller. This file's assertions are about
|
|
13
|
+
// assignment mechanics, not parent ownership, so the `credit`/`note` fixtures
|
|
14
|
+
// below stay unrestricted (no `access`) on purpose — every host row this file
|
|
15
|
+
// tags is seeded in beforeAll.
|
|
10
16
|
|
|
11
17
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
18
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
20
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
14
21
|
import {
|
|
15
22
|
createTestUser,
|
|
@@ -17,19 +24,77 @@ import {
|
|
|
17
24
|
type TestStack,
|
|
18
25
|
unsafeCreateEntityTable,
|
|
19
26
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
|
+
import { v5 as uuidv5 } from "uuid";
|
|
20
28
|
import { TagsHandlers, TagsQueries } from "../constants";
|
|
21
29
|
import { tagAssignmentEntity, tagEntity } from "../entity";
|
|
22
30
|
import { createTagsFeature } from "../feature";
|
|
23
31
|
|
|
24
32
|
const tagsFeature = createTagsFeature();
|
|
25
33
|
|
|
34
|
+
const CREDIT_TABLE = "tags_test_credits";
|
|
35
|
+
const NOTE_TABLE = "tags_test_notes";
|
|
36
|
+
const hostFields = { name: createTextField({ required: true, maxLength: 64 }) };
|
|
37
|
+
const creditEntity = createEntity({ table: CREDIT_TABLE, fields: hostFields });
|
|
38
|
+
const noteEntity = createEntity({ table: NOTE_TABLE, fields: hostFields });
|
|
39
|
+
const hostFixturesFeature = defineFeature("tags-test-host-fixtures", (r) => {
|
|
40
|
+
r.entity("credit", creditEntity);
|
|
41
|
+
r.entity("note", noteEntity);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The parent entity's ids are uuid-shaped, but the readable labels below carry
|
|
45
|
+
// the intent of each case — derive one from the other instead of scattering
|
|
46
|
+
// opaque uuid literals through the assertions.
|
|
47
|
+
const HOST_FIXTURE_NAMESPACE = "6f1c4d2a-9b3e-4a7c-8d5f-1e2b3c4d5e6f";
|
|
48
|
+
function hostId(label: string): string {
|
|
49
|
+
return uuidv5(label, HOST_FIXTURE_NAMESPACE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CREDIT_LABELS = [
|
|
53
|
+
"credit-1",
|
|
54
|
+
"credit-2",
|
|
55
|
+
"credit-3",
|
|
56
|
+
"credit-4",
|
|
57
|
+
"credit-5",
|
|
58
|
+
"credit-6",
|
|
59
|
+
"credit-7",
|
|
60
|
+
"credit-8",
|
|
61
|
+
"credit-d1",
|
|
62
|
+
"credit-r",
|
|
63
|
+
"credit-x",
|
|
64
|
+
...Array.from({ length: 20 }, (_, i) => `credit-race-${i}`),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
async function seedHostRows(
|
|
68
|
+
target: TestStack,
|
|
69
|
+
table: string,
|
|
70
|
+
labels: readonly string[],
|
|
71
|
+
tenantId: string,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
for (const label of labels) {
|
|
74
|
+
await asRawClient(target.db).unsafe(
|
|
75
|
+
`INSERT INTO ${table} (id, tenant_id, name) VALUES ($1, $2, $3)`,
|
|
76
|
+
[hostId(label), tenantId, label],
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
26
81
|
let stack: TestStack;
|
|
27
82
|
|
|
83
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
84
|
+
const otherTenant = createTestUser({
|
|
85
|
+
roles: ["TenantAdmin"],
|
|
86
|
+
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
87
|
+
});
|
|
88
|
+
|
|
28
89
|
beforeAll(async () => {
|
|
29
|
-
stack = await setupTestStack({ features: [tagsFeature] });
|
|
90
|
+
stack = await setupTestStack({ features: [tagsFeature, hostFixturesFeature] });
|
|
30
91
|
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
31
92
|
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
93
|
+
await unsafeCreateEntityTable(stack.db, creditEntity);
|
|
94
|
+
await unsafeCreateEntityTable(stack.db, noteEntity);
|
|
32
95
|
await createEventsTable(stack.db);
|
|
96
|
+
await seedHostRows(stack, CREDIT_TABLE, CREDIT_LABELS, admin.tenantId);
|
|
97
|
+
await seedHostRows(stack, NOTE_TABLE, ["note-d1"], admin.tenantId);
|
|
33
98
|
});
|
|
34
99
|
|
|
35
100
|
afterAll(async () => {
|
|
@@ -42,12 +107,6 @@ beforeEach(async () => {
|
|
|
42
107
|
await asRawClient(stack.db).unsafe("DELETE FROM read_tag_assignments");
|
|
43
108
|
});
|
|
44
109
|
|
|
45
|
-
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
46
|
-
const otherTenant = createTestUser({
|
|
47
|
-
roles: ["TenantAdmin"],
|
|
48
|
-
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
49
|
-
});
|
|
50
|
-
|
|
51
110
|
async function createTag(name: string, user = admin): Promise<string> {
|
|
52
111
|
const tag = await stack.http.writeOk<{ id: string }>(TagsHandlers.createTag, { name }, user);
|
|
53
112
|
return tag.id;
|
|
@@ -111,10 +170,14 @@ describe("tags integration — catalog + assignment roundtrip", () => {
|
|
|
111
170
|
|
|
112
171
|
test("assign-tag → assignment queryable both composition directions", async () => {
|
|
113
172
|
const tagId = await createTag("VIP");
|
|
114
|
-
await assign(tagId, "credit", "credit-1");
|
|
173
|
+
await assign(tagId, "credit", hostId("credit-1"));
|
|
115
174
|
|
|
116
175
|
// tags of an entity
|
|
117
|
-
const byEntity = await listAssignments({
|
|
176
|
+
const byEntity = await listAssignments({
|
|
177
|
+
field: "entityId",
|
|
178
|
+
op: "eq",
|
|
179
|
+
value: hostId("credit-1"),
|
|
180
|
+
});
|
|
118
181
|
expect(byEntity).toHaveLength(1);
|
|
119
182
|
expect(byEntity[0]?.["tagId"]).toBe(tagId);
|
|
120
183
|
expect(byEntity[0]?.["entityType"]).toBe("credit");
|
|
@@ -122,17 +185,17 @@ describe("tags integration — catalog + assignment roundtrip", () => {
|
|
|
122
185
|
// entities carrying a tag
|
|
123
186
|
const byTag = await listAssignments({ field: "tagId", op: "eq", value: tagId });
|
|
124
187
|
expect(byTag).toHaveLength(1);
|
|
125
|
-
expect(byTag[0]?.["entityId"]).toBe("credit-1");
|
|
188
|
+
expect(byTag[0]?.["entityId"]).toBe(hostId("credit-1"));
|
|
126
189
|
});
|
|
127
190
|
|
|
128
191
|
test("remove-tag deletes the assignment", async () => {
|
|
129
192
|
const tagId = await createTag("temp");
|
|
130
|
-
await assign(tagId, "credit", "credit-2");
|
|
193
|
+
await assign(tagId, "credit", hostId("credit-2"));
|
|
131
194
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
132
195
|
|
|
133
|
-
await remove(tagId, "credit", "credit-2");
|
|
196
|
+
await remove(tagId, "credit", hostId("credit-2"));
|
|
134
197
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
135
|
-
const left = await listAssignments({ field: "entityId", op: "eq", value: "credit-2" });
|
|
198
|
+
const left = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-2") });
|
|
136
199
|
expect(left).toHaveLength(0);
|
|
137
200
|
});
|
|
138
201
|
});
|
|
@@ -231,9 +294,9 @@ describe("tags integration — delete-tag cascade", () => {
|
|
|
231
294
|
const target = await createTag("Sammelmappe");
|
|
232
295
|
const other = await createTag("bleibt");
|
|
233
296
|
// target spans two entityTypes; other tag stays attached to prove scoping
|
|
234
|
-
await assign(target, "credit", "credit-d1");
|
|
235
|
-
await assign(target, "note", "note-d1");
|
|
236
|
-
await assign(other, "credit", "credit-d1");
|
|
297
|
+
await assign(target, "credit", hostId("credit-d1"));
|
|
298
|
+
await assign(target, "note", hostId("note-d1"));
|
|
299
|
+
await assign(other, "credit", hostId("credit-d1"));
|
|
237
300
|
expect(await countAssignments(admin.tenantId)).toBe(3);
|
|
238
301
|
|
|
239
302
|
await deleteTag(target);
|
|
@@ -257,52 +320,54 @@ describe("tags integration — many-to-many composition", () => {
|
|
|
257
320
|
test("one entity carries multiple tags", async () => {
|
|
258
321
|
const a = await createTag("rot");
|
|
259
322
|
const b = await createTag("wasser");
|
|
260
|
-
await assign(a, "credit", "credit-3");
|
|
261
|
-
await assign(b, "credit", "credit-3");
|
|
323
|
+
await assign(a, "credit", hostId("credit-3"));
|
|
324
|
+
await assign(b, "credit", hostId("credit-3"));
|
|
262
325
|
|
|
263
|
-
const tags = await listAssignments({ field: "entityId", op: "eq", value: "credit-3" });
|
|
326
|
+
const tags = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-3") });
|
|
264
327
|
expect(tags.map((r) => r["tagId"]).sort()).toEqual([a, b].sort());
|
|
265
328
|
});
|
|
266
329
|
|
|
267
330
|
test("one tag spans multiple entities", async () => {
|
|
268
331
|
const tagId = await createTag("Mappe-2026");
|
|
269
|
-
await assign(tagId, "credit", "credit-4");
|
|
270
|
-
await assign(tagId, "credit", "credit-5");
|
|
332
|
+
await assign(tagId, "credit", hostId("credit-4"));
|
|
333
|
+
await assign(tagId, "credit", hostId("credit-5"));
|
|
271
334
|
|
|
272
335
|
const entities = await listAssignments({ field: "tagId", op: "eq", value: tagId });
|
|
273
|
-
expect(entities.map((r) => r["entityId"]).sort()).toEqual(
|
|
336
|
+
expect(entities.map((r) => r["entityId"]).sort()).toEqual(
|
|
337
|
+
[hostId("credit-4"), hostId("credit-5")].sort(),
|
|
338
|
+
);
|
|
274
339
|
});
|
|
275
340
|
});
|
|
276
341
|
|
|
277
342
|
describe("tags integration — idempotency", () => {
|
|
278
343
|
test("re-assigning the same (tag, entity) keeps exactly one row", async () => {
|
|
279
344
|
const tagId = await createTag("dup");
|
|
280
|
-
await assign(tagId, "credit", "credit-6");
|
|
281
|
-
await assign(tagId, "credit", "credit-6"); // re-assign: must be a no-op success
|
|
345
|
+
await assign(tagId, "credit", hostId("credit-6"));
|
|
346
|
+
await assign(tagId, "credit", hostId("credit-6")); // re-assign: must be a no-op success
|
|
282
347
|
|
|
283
348
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
284
|
-
const rows = await listAssignments({ field: "entityId", op: "eq", value: "credit-6" });
|
|
349
|
+
const rows = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-6") });
|
|
285
350
|
expect(rows).toHaveLength(1);
|
|
286
351
|
});
|
|
287
352
|
|
|
288
353
|
test("removing a never-assigned (tag, entity) succeeds (no error, no row)", async () => {
|
|
289
354
|
const tagId = await createTag("ghost");
|
|
290
355
|
// never assigned — remove must still succeed (idempotent end-state)
|
|
291
|
-
await remove(tagId, "credit", "credit-7");
|
|
356
|
+
await remove(tagId, "credit", hostId("credit-7"));
|
|
292
357
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
293
358
|
});
|
|
294
359
|
|
|
295
360
|
test("assign → remove → assign-again resurrects the same deterministic stream", async () => {
|
|
296
361
|
const tagId = await createTag("recurring");
|
|
297
|
-
await assign(tagId, "credit", "credit-r");
|
|
298
|
-
await remove(tagId, "credit", "credit-r");
|
|
362
|
+
await assign(tagId, "credit", hostId("credit-r"));
|
|
363
|
+
await remove(tagId, "credit", hostId("credit-r"));
|
|
299
364
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
300
365
|
|
|
301
366
|
// Re-attaching the same (tag, entity) must succeed (restore), not 409 — the
|
|
302
367
|
// deterministic aggregate-id reuses the removed stream.
|
|
303
|
-
await assign(tagId, "credit", "credit-r");
|
|
368
|
+
await assign(tagId, "credit", hostId("credit-r"));
|
|
304
369
|
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
305
|
-
const rows = await listAssignments({ field: "entityId", op: "eq", value: "credit-r" });
|
|
370
|
+
const rows = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-r") });
|
|
306
371
|
expect(rows).toHaveLength(1);
|
|
307
372
|
expect(rows[0]?.["tagId"]).toBe(tagId);
|
|
308
373
|
});
|
|
@@ -319,7 +384,7 @@ describe("tags integration — idempotency", () => {
|
|
|
319
384
|
// The handler must converge that into success instead of surfacing a 409
|
|
320
385
|
// for what is, from the caller's perspective, an idempotent operation.
|
|
321
386
|
const tagId = await createTag(`racy-${i}`);
|
|
322
|
-
const entityId = `credit-race-${i}
|
|
387
|
+
const entityId = hostId(`credit-race-${i}`);
|
|
323
388
|
const [a, b] = await Promise.all([
|
|
324
389
|
assign(tagId, "credit", entityId),
|
|
325
390
|
assign(tagId, "credit", entityId),
|
|
@@ -336,7 +401,11 @@ describe("tags integration — referential integrity", () => {
|
|
|
336
401
|
test("assigning an unknown tagId is rejected (no dangling assignment)", async () => {
|
|
337
402
|
const err = await stack.http.writeErr(
|
|
338
403
|
TagsHandlers.assignTag,
|
|
339
|
-
{
|
|
404
|
+
{
|
|
405
|
+
tagId: "00000000-0000-4000-8000-00000000dead",
|
|
406
|
+
entityType: "credit",
|
|
407
|
+
entityId: hostId("credit-x"),
|
|
408
|
+
},
|
|
340
409
|
admin,
|
|
341
410
|
);
|
|
342
411
|
expect(err.httpStatus).toBe(404);
|
|
@@ -347,11 +416,14 @@ describe("tags integration — referential integrity", () => {
|
|
|
347
416
|
describe("tags integration — multi-tenant isolation", () => {
|
|
348
417
|
test("tenant B sees neither tenant A's tags nor assignments", async () => {
|
|
349
418
|
const tagId = await createTag("A-only", admin);
|
|
350
|
-
await assign(tagId, "credit", "credit-8", admin);
|
|
419
|
+
await assign(tagId, "credit", hostId("credit-8"), admin);
|
|
351
420
|
|
|
352
421
|
expect(await listTags(otherTenant)).toHaveLength(0);
|
|
353
422
|
expect(
|
|
354
|
-
await listAssignments(
|
|
423
|
+
await listAssignments(
|
|
424
|
+
{ field: "entityId", op: "eq", value: hostId("credit-8") },
|
|
425
|
+
otherTenant,
|
|
426
|
+
),
|
|
355
427
|
).toHaveLength(0);
|
|
356
428
|
|
|
357
429
|
// tenant A still sees its own
|
|
@@ -378,11 +450,13 @@ describe("tags integration — openToAll access model", () => {
|
|
|
378
450
|
|
|
379
451
|
beforeAll(async () => {
|
|
380
452
|
openStack = await setupTestStack({
|
|
381
|
-
features: [createTagsFeature({ access: { openToAll: true } })],
|
|
453
|
+
features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
|
|
382
454
|
});
|
|
383
455
|
await unsafeCreateEntityTable(openStack.db, tagEntity);
|
|
384
456
|
await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
|
|
457
|
+
await unsafeCreateEntityTable(openStack.db, creditEntity);
|
|
385
458
|
await createEventsTable(openStack.db);
|
|
459
|
+
await seedHostRows(openStack, CREDIT_TABLE, ["c-1"], unprivileged.tenantId);
|
|
386
460
|
|
|
387
461
|
defaultStack = await setupTestStack({ features: [tagsFeature] });
|
|
388
462
|
await unsafeCreateEntityTable(defaultStack.db, tagEntity);
|
|
@@ -403,7 +477,7 @@ describe("tags integration — openToAll access model", () => {
|
|
|
403
477
|
);
|
|
404
478
|
await openStack.http.writeOk(
|
|
405
479
|
TagsHandlers.assignTag,
|
|
406
|
-
{ tagId: tag.id, entityType: "credit", entityId: "c-1" },
|
|
480
|
+
{ tagId: tag.id, entityType: "credit", entityId: hostId("c-1") },
|
|
407
481
|
unprivileged,
|
|
408
482
|
);
|
|
409
483
|
|
|
@@ -416,14 +490,14 @@ describe("tags integration — openToAll access model", () => {
|
|
|
416
490
|
|
|
417
491
|
const assigned = await openStack.http.queryOk<{ rows: unknown[] }>(
|
|
418
492
|
TagsQueries.assignmentList,
|
|
419
|
-
{ filter: { field: "entityId", op: "eq", value: "c-1" } },
|
|
493
|
+
{ filter: { field: "entityId", op: "eq", value: hostId("c-1") } },
|
|
420
494
|
unprivileged,
|
|
421
495
|
);
|
|
422
496
|
expect(assigned.rows).toHaveLength(1);
|
|
423
497
|
|
|
424
498
|
await openStack.http.writeOk(
|
|
425
499
|
TagsHandlers.removeTag,
|
|
426
|
-
{ tagId: tag.id, entityType: "credit", entityId: "c-1" },
|
|
500
|
+
{ tagId: tag.id, entityType: "credit", entityId: hostId("c-1") },
|
|
427
501
|
unprivileged,
|
|
428
502
|
);
|
|
429
503
|
});
|
package/src/tags/changes.json
CHANGED
|
@@ -1 +1,9 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.246.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "assign-tag and remove-tag now verify entityType/entityId against the host entity's own read path",
|
|
6
|
+
"detail": "Before this change, assign-tag and remove-tag took entityType/entityId straight from the client with no check — any dispatch-eligible tenant user could attach a catalog tag to, or detach one from, any object in their tenant, including one they could not otherwise read. The tag-assignment aggregate-id is derived from the tenant, so this was never a cross-tenant hole; it was tagging inside your own tenant across a row-level ownership boundary. Both handlers now unconditionally verify that entityType names a registered entity and that the host row is visible to the caller through that entity's own read path (tenant scope plus its `access.read` ownership) before touching the assignment; either check failing returns a not_found response, the same answer a genuinely missing row gets, so nothing doubles as an existence oracle. The check runs before the assignment and tag lookups, so a denied caller cannot tell an invisible host row apart from a missing assignment. This mirrors the add-note gate shipped in 0.244.0 and shares its helper.",
|
|
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 assign-tag or remove-tag 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 host entity (and, if it needs row-level ownership, its `access.read` rule) before upgrading. Note the consequence for hard-deleted host rows: once the host row is gone, its assignment can no longer be removed through remove-tag; delete-tag's cascade still detaches it."
|
|
8
|
+
}
|
|
9
|
+
]
|
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);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { parentRowIsVisible } from "../../shared";
|
|
3
4
|
import { tagAssignmentAggregateId } from "../aggregate-id";
|
|
4
5
|
import { DEFAULT_TAG_ACCESS } from "../constants";
|
|
5
6
|
import { tagAssignmentExecutor, tagExecutor } from "../executor";
|
|
@@ -21,6 +22,11 @@ import { type AssignTagPayload, assignTagPayloadSchema } from "../schemas";
|
|
|
21
22
|
// Referential integrity: there is no FK (event-sourced, no JOIN), so before a
|
|
22
23
|
// first-time create we verify the tag exists in the catalog — a malformed call
|
|
23
24
|
// with an unknown tagId would otherwise project a dangling assignment.
|
|
25
|
+
//
|
|
26
|
+
// Host reference: entityType/entityId are never trusted client input either —
|
|
27
|
+
// the aggregate-id is tenant-scoped, so this is not a cross-tenant hole, but
|
|
28
|
+
// without the check any dispatch-eligible tenant user could tag an object they
|
|
29
|
+
// are not allowed to read. See shared/parent-visibility.ts.
|
|
24
30
|
export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
|
|
25
31
|
return {
|
|
26
32
|
name: "assign-tag",
|
|
@@ -30,6 +36,24 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
30
36
|
"Attaches an existing catalog tag to one host entity addressed by its type and id, reporting success when the tag was already attached; use it to tag a record, not to create the tag.",
|
|
31
37
|
handler: async (event, ctx) => {
|
|
32
38
|
const payload = event.payload as AssignTagPayload; // @cast-boundary engine-payload
|
|
39
|
+
// entityType/entityId are client input: the caller must be able to see
|
|
40
|
+
// the host row through that entity's own read path (tenant scope plus its
|
|
41
|
+
// `access.read` ownership) before its assignment row may be written.
|
|
42
|
+
// Checked FIRST, ahead of every lookup below, so a denied caller can't tell
|
|
43
|
+
// an invisible parent apart from a missing assignment or an unknown tag —
|
|
44
|
+
// every path answers with the same NotFoundError.
|
|
45
|
+
if (
|
|
46
|
+
!(await parentRowIsVisible(
|
|
47
|
+
ctx.registry,
|
|
48
|
+
payload.entityType,
|
|
49
|
+
payload.entityId,
|
|
50
|
+
event.user,
|
|
51
|
+
ctx.db,
|
|
52
|
+
))
|
|
53
|
+
) {
|
|
54
|
+
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
const id = tagAssignmentAggregateId(
|
|
34
58
|
event.user.tenantId,
|
|
35
59
|
payload.tagId,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { parentRowIsVisible } from "../../shared";
|
|
2
4
|
import { tagAssignmentAggregateId } from "../aggregate-id";
|
|
3
5
|
import { DEFAULT_TAG_ACCESS } from "../constants";
|
|
4
6
|
import { tagAssignmentExecutor } from "../executor";
|
|
@@ -7,6 +9,10 @@ import { type RemoveTagPayload, removeTagPayloadSchema } from "../schemas";
|
|
|
7
9
|
// remove-tag — unlinks a tag from a host entity. Idempotent: removing an
|
|
8
10
|
// assignment that doesn't exist is already the requested end state (not
|
|
9
11
|
// assigned), so we pre-check and return success without a delete.
|
|
12
|
+
//
|
|
13
|
+
// Host reference: entityType/entityId are client input, so the caller must be
|
|
14
|
+
// able to see the host row before detaching anything from it — see
|
|
15
|
+
// shared/parent-visibility.ts.
|
|
10
16
|
export function createRemoveTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
|
|
11
17
|
return {
|
|
12
18
|
name: "remove-tag",
|
|
@@ -16,6 +22,24 @@ export function createRemoveTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
16
22
|
"Detaches one catalog tag from one host entity, reporting success when it was not attached; use it to untag a single record while the tag itself stays in the catalog.",
|
|
17
23
|
handler: async (event, ctx) => {
|
|
18
24
|
const payload = event.payload as RemoveTagPayload; // @cast-boundary engine-payload
|
|
25
|
+
// entityType/entityId are client input: the caller must be able to see
|
|
26
|
+
// the host row through that entity's own read path (tenant scope plus its
|
|
27
|
+
// `access.read` ownership) before its assignment row may be written.
|
|
28
|
+
// Checked FIRST, ahead of every lookup below, so a denied caller can't tell
|
|
29
|
+
// an invisible parent apart from a missing assignment or an unknown tag —
|
|
30
|
+
// every path answers with the same NotFoundError.
|
|
31
|
+
if (
|
|
32
|
+
!(await parentRowIsVisible(
|
|
33
|
+
ctx.registry,
|
|
34
|
+
payload.entityType,
|
|
35
|
+
payload.entityId,
|
|
36
|
+
event.user,
|
|
37
|
+
ctx.db,
|
|
38
|
+
))
|
|
39
|
+
) {
|
|
40
|
+
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
const id = tagAssignmentAggregateId(
|
|
20
44
|
event.user.tenantId,
|
|
21
45
|
payload.tagId,
|
|
@@ -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
|
};
|