@cosmicdrift/kumiko-bundled-features 0.252.1 → 0.254.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/agent-tools/__tests__/agent-manifest.test.ts +2 -2
- package/src/agent-tools/__tests__/agent-optout.test.ts +22 -3
- package/src/agent-tools/__tests__/agent-tools.integration.test.ts +7 -1
- package/src/agent-tools/__tests__/tool-catalog.test.ts +11 -4
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +13 -2
- package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
- package/src/audit/__tests__/audit.integration.test.ts +2 -2
- package/src/billing-foundation/entities.ts +30 -5
- package/src/billing-foundation/handlers/process-event.write.ts +1 -0
- package/src/billing-foundation/handlers/process-payment-event.write.ts +1 -0
- package/src/cap-counter/entity.ts +8 -1
- package/src/config/table.ts +4 -2
- package/src/crypto-shredding/__tests__/forget-subject-record.integration.test.ts +141 -1
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +1 -1
- package/src/crypto-shredding/constants.ts +1 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +42 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/field-access.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/quota.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/retention.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +1 -1
- package/src/custom-fields/entity.ts +24 -4
- package/src/data-retention/__tests__/resolver.test.ts +6 -4
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
- package/src/delivery/__tests__/delivery.integration.test.ts +3 -3
- package/src/delivery/tables.ts +6 -2
- package/src/document-ingest-foundation/entity.ts +6 -2
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +12 -2
- package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -1
- package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +12 -2
- package/src/folders/__tests__/folders.integration.test.ts +8 -1
- package/src/folders/entity.ts +27 -5
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +8 -1
- package/src/form-draft/entity.ts +6 -1
- package/src/inbound-mail-foundation/entities.ts +57 -12
- package/src/inbound-mail-foundation/handlers/connect-account.write.ts +8 -4
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +3 -4
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +111 -1
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +6 -1
- package/src/jobs/db/queries/stale-run-sweep.ts +36 -14
- package/src/jobs/feature.ts +5 -0
- package/src/jobs/job-run-logger.ts +7 -2
- package/src/jobs/job-run-table.ts +11 -6
- package/src/ledger/entity.ts +27 -7
- package/src/managed-pages/screens/branding-screen.ts +27 -5
- package/src/managed-pages/table.ts +23 -4
- package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +8 -1
- package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +8 -1
- package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +20 -3
- package/src/notes-history/__tests__/notes-history.integration.test.ts +8 -1
- package/src/notes-history/entity.ts +63 -19
- package/src/notes-history/executor.ts +6 -1
- package/src/notes-history/feature.ts +7 -1
- package/src/notes-history/handlers/add-note.write.ts +19 -3
- package/src/notes-history/index.ts +7 -2
- package/src/notes-history/schemas.ts +5 -0
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +8 -1
- package/src/notes-history-user-data/__tests__/mention-forget.integration.test.ts +244 -0
- package/src/notes-history-user-data/hooks.ts +69 -16
- package/src/notes-history-user-data/index.ts +11 -2
- package/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/src/personal-access-tokens/schema/api-token.ts +29 -1
- package/src/secrets/table.ts +1 -1
- package/src/sessions/schema/user-session.ts +4 -0
- package/src/sessions/session-callbacks.ts +1 -0
- package/src/shared/encrypt-for-direct-write.ts +9 -3
- package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +12 -2
- package/src/tags/__tests__/tags.integration.test.ts +8 -1
- package/src/tags/entity.ts +20 -5
- package/src/template-resolver/table.ts +8 -7
- package/src/template-resolver/user-content-table.ts +6 -4
- package/src/tenant/invitation-table.ts +6 -1
- package/src/tenant/membership-table.ts +2 -2
- package/src/tenant/schema/tenant.ts +22 -3
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +1 -1
- package/src/tier-engine/entity.ts +12 -2
- package/src/user/schema/user.ts +15 -1
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +3 -1
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
- package/src/user-data-rights/index.ts +6 -1
- package/src/user-data-rights/schema/download-attempt.ts +25 -7
- package/src/user-data-rights/schema/download-token.ts +4 -0
- package/src/user-data-rights/schema/export-job.ts +6 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// notes-history mention-forget cascade (fw#2787):
|
|
2
|
+
//
|
|
3
|
+
// - a note carrying a structured @-mention on a forgotten user S has its
|
|
4
|
+
// `body` (Row-Subject, personal: { of: "id" }) crypto-shredded when S's
|
|
5
|
+
// Art. 17 forget runs (runForgetCleanup → noteEntryDeleteHook →
|
|
6
|
+
// note-mention lookup → kms.eraseKey on the note's own record subject)
|
|
7
|
+
// - a sibling note on the SAME host with NO mention on S stays fully
|
|
8
|
+
// readable — proves the cascade doesn't collaterally shred every note
|
|
9
|
+
// - the host entity's retention strategy is consulted BEFORE shredding:
|
|
10
|
+
// an `anonymize` override on the host blocks the erase entirely
|
|
11
|
+
|
|
12
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
14
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import {
|
|
16
|
+
configurePiiSubjectKms,
|
|
17
|
+
InMemoryKmsAdapter,
|
|
18
|
+
PII_ERASED_SENTINEL,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
20
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
23
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
24
|
+
import {
|
|
25
|
+
createTestUser,
|
|
26
|
+
setupTestStack,
|
|
27
|
+
type TestStack,
|
|
28
|
+
TestUsers,
|
|
29
|
+
unsafeCreateEntityTable,
|
|
30
|
+
unsafePushTables,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
|
+
import {
|
|
33
|
+
resetPiiSubjectKmsForTests,
|
|
34
|
+
resetTestTables,
|
|
35
|
+
seedRow,
|
|
36
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
37
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
38
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
39
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
40
|
+
import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
|
|
41
|
+
import { createFilesFeature } from "../../files";
|
|
42
|
+
import {
|
|
43
|
+
createNotesHistoryFeature,
|
|
44
|
+
NotesHistoryHandlers,
|
|
45
|
+
noteEntryEntity,
|
|
46
|
+
noteEntryExecutor,
|
|
47
|
+
noteMentionEntity,
|
|
48
|
+
} from "../../notes-history";
|
|
49
|
+
import { createSessionsFeature, userSessionEntity } from "../../sessions";
|
|
50
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
51
|
+
import { createUserDataRightsFeature, runForgetCleanup } from "../../user-data-rights";
|
|
52
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
53
|
+
import { notesHistoryUserDataFeature } from "..";
|
|
54
|
+
|
|
55
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
56
|
+
function pastInstant(): Instant {
|
|
57
|
+
return getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CONTACT_TABLE = "notes_mention_forget_test_contacts";
|
|
61
|
+
const contactEntity = createEntity({
|
|
62
|
+
table: CONTACT_TABLE,
|
|
63
|
+
fields: { name: createTextField({ required: true, maxLength: 64 }) },
|
|
64
|
+
});
|
|
65
|
+
const contactFixtureFeature = defineFeature("notes-mention-forget-test-contact-fixture", (r) => {
|
|
66
|
+
r.entity("contact", contactEntity);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const author = createTestUser({ id: 1, roles: ["TenantMember"] });
|
|
70
|
+
const CONTACT_1 = "40000000-0000-4000-8000-000000000001";
|
|
71
|
+
const SUBJECT_S = "40000000-0000-4000-8000-0000000000ff";
|
|
72
|
+
|
|
73
|
+
let stack: TestStack;
|
|
74
|
+
let overrideExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
75
|
+
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
stack = await setupTestStack({
|
|
78
|
+
features: [
|
|
79
|
+
createUserFeature(),
|
|
80
|
+
authFoundationFeature,
|
|
81
|
+
createSessionsFeature(),
|
|
82
|
+
createDataRetentionFeature(),
|
|
83
|
+
createComplianceProfilesFeature(),
|
|
84
|
+
createFilesFeature(),
|
|
85
|
+
createUserDataRightsFeature(),
|
|
86
|
+
createUserDataRightsDefaultsFeature(),
|
|
87
|
+
createNotesHistoryFeature(),
|
|
88
|
+
notesHistoryUserDataFeature,
|
|
89
|
+
contactFixtureFeature,
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
94
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
95
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
96
|
+
await unsafeCreateEntityTable(stack.db, noteEntryEntity, "note-entry");
|
|
97
|
+
await unsafeCreateEntityTable(stack.db, noteMentionEntity, "note-mention");
|
|
98
|
+
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
99
|
+
await unsafePushTables(stack.db, { fileRefsTable });
|
|
100
|
+
await createEventsTable(stack.db);
|
|
101
|
+
// tenant-membership table (from the tenant feature) manually created — same
|
|
102
|
+
// minimal setup as run-forget-cleanup.integration.test.ts, no tenant feature
|
|
103
|
+
// mounted here.
|
|
104
|
+
await asRawClient(stack.db).unsafe(`
|
|
105
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
106
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
107
|
+
tenant_id UUID NOT NULL,
|
|
108
|
+
user_id TEXT NOT NULL,
|
|
109
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
110
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
111
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
112
|
+
inserted_by_id TEXT,
|
|
113
|
+
modified_by_id TEXT,
|
|
114
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
115
|
+
deleted_at TIMESTAMPTZ,
|
|
116
|
+
deleted_by_id TEXT,
|
|
117
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
118
|
+
UNIQUE(user_id, tenant_id)
|
|
119
|
+
)
|
|
120
|
+
`);
|
|
121
|
+
await asRawClient(stack.db).unsafe(
|
|
122
|
+
`INSERT INTO ${CONTACT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)
|
|
123
|
+
ON CONFLICT (id) DO NOTHING`,
|
|
124
|
+
[CONTACT_1, author.tenantId, "Contact 1"],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
overrideExecutor = createEventStoreExecutor(
|
|
128
|
+
tenantRetentionOverrideTable,
|
|
129
|
+
tenantRetentionOverrideEntity,
|
|
130
|
+
{ entityName: "tenant-retention-override" },
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
afterAll(async () => {
|
|
135
|
+
await stack.cleanup();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
beforeEach(async () => {
|
|
139
|
+
await resetTestTables(stack.db, [
|
|
140
|
+
userTable,
|
|
141
|
+
"read_tenant_memberships",
|
|
142
|
+
tenantRetentionOverrideTable,
|
|
143
|
+
]);
|
|
144
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
resetPiiSubjectKmsForTests();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
async function seedForgottenSubject(): Promise<void> {
|
|
152
|
+
await seedRow(stack.db, userTable, {
|
|
153
|
+
id: SUBJECT_S,
|
|
154
|
+
tenantId: author.tenantId,
|
|
155
|
+
email: "subject-s@example.com",
|
|
156
|
+
passwordHash: "hashed",
|
|
157
|
+
displayName: "Subject S",
|
|
158
|
+
locale: "de",
|
|
159
|
+
emailVerified: true,
|
|
160
|
+
roles: '["Member"]',
|
|
161
|
+
status: USER_STATUS.DeletionRequested,
|
|
162
|
+
gracePeriodEnd: pastInstant(),
|
|
163
|
+
});
|
|
164
|
+
await asRawClient(stack.db).unsafe(
|
|
165
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
166
|
+
VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
167
|
+
[author.tenantId, SUBJECT_S],
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function seedContactRetentionOverride(strategy: "anonymize" | "delete"): Promise<void> {
|
|
172
|
+
const by = { ...TestUsers.systemAdmin, tenantId: author.tenantId };
|
|
173
|
+
const result = await overrideExecutor.create(
|
|
174
|
+
{
|
|
175
|
+
entityName: "contact",
|
|
176
|
+
config: JSON.stringify({ keepFor: "1y", strategy }),
|
|
177
|
+
reason: "test",
|
|
178
|
+
tenantId: author.tenantId,
|
|
179
|
+
},
|
|
180
|
+
by,
|
|
181
|
+
createTenantDb(stack.db, author.tenantId, "system"),
|
|
182
|
+
);
|
|
183
|
+
if (!result.isSuccess)
|
|
184
|
+
throw new Error(`seedContactRetentionOverride failed: ${JSON.stringify(result)}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
describe("notes-history mention-forget cascade", () => {
|
|
188
|
+
test("shreds only the note mentioning the forgotten subject, leaves the unmentioned note readable", async () => {
|
|
189
|
+
await seedForgottenSubject();
|
|
190
|
+
const tenantDb = createTenantDb(stack.db, author.tenantId, "system");
|
|
191
|
+
|
|
192
|
+
const mentioning = await stack.http.writeOk<{ id: string }>(
|
|
193
|
+
NotesHistoryHandlers.addNote,
|
|
194
|
+
{ entityType: "contact", entityId: CONTACT_1, body: "about S", mentions: [SUBJECT_S] },
|
|
195
|
+
author,
|
|
196
|
+
);
|
|
197
|
+
const unrelated = await stack.http.writeOk<{ id: string }>(
|
|
198
|
+
NotesHistoryHandlers.addNote,
|
|
199
|
+
{ entityType: "contact", entityId: CONTACT_1, body: "unrelated note" },
|
|
200
|
+
author,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const result = await runForgetCleanup({
|
|
204
|
+
db: stack.db,
|
|
205
|
+
registry: stack.registry,
|
|
206
|
+
now: getTemporal().Now.instant(),
|
|
207
|
+
});
|
|
208
|
+
expect(result.processedUserIds).toContain(SUBJECT_S);
|
|
209
|
+
expect(result.errors).toEqual([]);
|
|
210
|
+
|
|
211
|
+
const shredded = await noteEntryExecutor.detail({ id: mentioning.id }, author, tenantDb);
|
|
212
|
+
expect(shredded?.["body"]).toBe(PII_ERASED_SENTINEL);
|
|
213
|
+
|
|
214
|
+
const untouched = await noteEntryExecutor.detail({ id: unrelated.id }, author, tenantDb);
|
|
215
|
+
expect(untouched?.["body"]).toBe("unrelated note");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("host entity retention override (anonymize) blocks the shred", async () => {
|
|
219
|
+
await seedForgottenSubject();
|
|
220
|
+
await seedContactRetentionOverride("anonymize");
|
|
221
|
+
const tenantDb = createTenantDb(stack.db, author.tenantId, "system");
|
|
222
|
+
|
|
223
|
+
const mentioning = await stack.http.writeOk<{ id: string }>(
|
|
224
|
+
NotesHistoryHandlers.addNote,
|
|
225
|
+
{
|
|
226
|
+
entityType: "contact",
|
|
227
|
+
entityId: CONTACT_1,
|
|
228
|
+
body: "kept under retention",
|
|
229
|
+
mentions: [SUBJECT_S],
|
|
230
|
+
},
|
|
231
|
+
author,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const result = await runForgetCleanup({
|
|
235
|
+
db: stack.db,
|
|
236
|
+
registry: stack.registry,
|
|
237
|
+
now: getTemporal().Now.instant(),
|
|
238
|
+
});
|
|
239
|
+
expect(result.processedUserIds).toContain(SUBJECT_S);
|
|
240
|
+
|
|
241
|
+
const stillReadable = await noteEntryExecutor.detail({ id: mentioning.id }, author, tenantDb);
|
|
242
|
+
expect(stillReadable?.["body"]).toBe("kept under retention");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
// dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
|
|
5
5
|
// export-only, erasure via crypto-shredding.
|
|
6
6
|
|
|
7
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
9
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
-
import {
|
|
10
|
+
import { resolveRetentionPolicyForTenant } from "../data-retention";
|
|
11
|
+
import { noteEntryTable, noteMentionTable } from "../notes-history";
|
|
12
|
+
import { policyToStrategy } from "../user-data-rights";
|
|
10
13
|
|
|
11
14
|
// note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
|
|
12
15
|
// per-user content, so the export filters by authorId directly.
|
|
@@ -27,19 +30,69 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
|
|
|
27
30
|
};
|
|
28
31
|
};
|
|
29
32
|
|
|
30
|
-
//
|
|
31
|
-
// (entity.ts)
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
33
|
+
// `authorName` is separately annotated `personal: { of: "authorId" }`
|
|
34
|
+
// (entity.ts), so forgetting the AUTHOR already crypto-shreds that field on
|
|
35
|
+
// its own — unrelated to this hook. `body` is Row-Subject (`personal: { of:
|
|
36
|
+
// "id" }`): its key is per-note, not per-user, so forgetting a user only
|
|
37
|
+
// reaches a note's body when that note structurally @-mentions the user via
|
|
38
|
+
// a `note-mention` row (fw#2787). Free-text mentions are NOT detected —
|
|
39
|
+
// that's the documented gap in entity.ts, not a bug here.
|
|
40
|
+
//
|
|
41
|
+
// Per reached note: consult the HOST entity's (entityType, not note-entry's
|
|
42
|
+
// own) retention strategy first — blockDelete/anonymize must win over
|
|
43
|
+
// erasure, same rule the automated forget-cleanup pipeline applies to every
|
|
44
|
+
// other entity. The `strategy` this hook itself is called with is resolved
|
|
45
|
+
// for note-entry, not the host, so it is deliberately unused here.
|
|
46
|
+
//
|
|
39
47
|
// Precondition: this ONLY erases anything if the app mounts a KMS adapter —
|
|
40
|
-
// without one,
|
|
41
|
-
// (see pii-field-encryption.ts) and forget is a true no-op
|
|
42
|
-
//
|
|
43
|
-
//
|
|
48
|
+
// without one, record-owned fields fall back to plaintext storage
|
|
49
|
+
// framework-wide (see pii-field-encryption.ts) and forget is a true no-op.
|
|
50
|
+
// That gap is a property of the framework's crypto-shredding design, not
|
|
51
|
+
// specific to this hook; apps that need Art.17 coverage without KMS must
|
|
44
52
|
// mount one.
|
|
45
|
-
export const noteEntryDeleteHook: UserDataDeleteHook = async () => {
|
|
53
|
+
export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
54
|
+
const kms = configuredPiiSubjectKms();
|
|
55
|
+
// skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
|
|
56
|
+
if (!kms) return;
|
|
57
|
+
|
|
58
|
+
const mentions = await selectMany<{ noteId: string }>(ctx.db, noteMentionTable, {
|
|
59
|
+
subjectId: ctx.userId,
|
|
60
|
+
});
|
|
61
|
+
// skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
|
|
62
|
+
if (mentions.length === 0) return;
|
|
63
|
+
|
|
64
|
+
const noteIds = new Set(mentions.map((m) => m.noteId));
|
|
65
|
+
for (const noteId of noteIds) {
|
|
66
|
+
const note = await fetchOne<{ entityType: string }>(ctx.db, noteEntryTable, { id: noteId });
|
|
67
|
+
// Defensive: append-only rows are never hard-deleted, so this shouldn't
|
|
68
|
+
// happen — but a missing host means no retention policy to consult.
|
|
69
|
+
if (!note) continue;
|
|
70
|
+
|
|
71
|
+
const hostPolicy = await resolveRetentionPolicyForTenant({
|
|
72
|
+
db: ctx.db,
|
|
73
|
+
registry: ctx.registry,
|
|
74
|
+
tenantId: ctx.tenantId,
|
|
75
|
+
entityName: note.entityType,
|
|
76
|
+
});
|
|
77
|
+
if (policyToStrategy(hostPolicy.policy?.strategy ?? null) === "anonymize") continue;
|
|
78
|
+
|
|
79
|
+
await kms.eraseKey(
|
|
80
|
+
{ kind: "record", entity: "note-entry", id: noteId },
|
|
81
|
+
{
|
|
82
|
+
requestId: "notes-history-user-data:forget-mentioned-notes",
|
|
83
|
+
userId: ctx.userId,
|
|
84
|
+
eraseReason: "notes-history:mention-forget",
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// note-mention rows are a plain (noteId, subjectId) pointer — like authorId
|
|
91
|
+
// on note-entry, `subjectId`'s `personal: "ref"` annotation exists for the
|
|
92
|
+
// GDPR-hook-coverage boot guard, not because the row itself holds separately
|
|
93
|
+
// exportable content or needs its own physical erasure: forgetting the
|
|
94
|
+
// mentioned user's data is already handled by noteEntryDeleteHook shredding
|
|
95
|
+
// the mentioned NOTE's row-subject key above. Registered here only so the V3
|
|
96
|
+
// boot guard (validateGdprPiiHookCoverage) sees a hook for "note-mention".
|
|
97
|
+
export const noteMentionExportHook: UserDataExportHook = async () => null;
|
|
98
|
+
export const noteMentionDeleteHook: UserDataDeleteHook = async () => {};
|
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
// user-data-rights stack. Mirrors folders-user-data.
|
|
7
7
|
|
|
8
8
|
import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
noteEntryDeleteHook,
|
|
11
|
+
noteEntryExportHook,
|
|
12
|
+
noteMentionDeleteHook,
|
|
13
|
+
noteMentionExportHook,
|
|
14
|
+
} from "./hooks";
|
|
10
15
|
|
|
11
16
|
export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
|
|
12
17
|
r.describe(
|
|
13
|
-
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry`
|
|
18
|
+
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook looks up `note-mention` for notes that structurally @-mention the forgotten user and crypto-shreds each reached note's row-subject key (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for notes without such a mention. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
|
|
14
19
|
);
|
|
15
20
|
// user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
|
|
16
21
|
// is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
|
|
@@ -22,4 +27,8 @@ export const notesHistoryUserDataFeature = defineFeature("notes-history-user-dat
|
|
|
22
27
|
export: noteEntryExportHook,
|
|
23
28
|
delete: noteEntryDeleteHook,
|
|
24
29
|
});
|
|
30
|
+
r.useExtension(EXT_USER_DATA, "note-mention", {
|
|
31
|
+
export: noteMentionExportHook,
|
|
32
|
+
delete: noteMentionDeleteHook,
|
|
33
|
+
});
|
|
25
34
|
});
|
|
@@ -23,11 +23,15 @@ export const apiTokenEntity = createEntity({
|
|
|
23
23
|
required: true,
|
|
24
24
|
maxLength: 36,
|
|
25
25
|
access: { write: access.privileged },
|
|
26
|
+
personal: false,
|
|
27
|
+
reason: "pseudonymous_fk",
|
|
26
28
|
}),
|
|
27
29
|
tenantId: createTextField({
|
|
28
30
|
required: true,
|
|
29
31
|
maxLength: 36,
|
|
30
32
|
access: { write: access.privileged },
|
|
33
|
+
personal: false,
|
|
34
|
+
reason: "pseudonymous_fk",
|
|
31
35
|
}),
|
|
32
36
|
name: createTextField({
|
|
33
37
|
required: true,
|
|
@@ -36,19 +40,43 @@ export const apiTokenEntity = createEntity({
|
|
|
36
40
|
personal: { of: "userId" },
|
|
37
41
|
find: "none",
|
|
38
42
|
}),
|
|
43
|
+
// SHA-256 hash of the plaintext token — credential material, but kept
|
|
44
|
+
// Klasse 1 deliberately: resolver.ts point-reads this row with a raw
|
|
45
|
+
// exact-match `{ tokenHash: hashPatToken(rawToken) }` query on the hot
|
|
46
|
+
// auth path. A subject annotation here would make encryptForDirectWrite
|
|
47
|
+
// store ciphertext while the resolver keeps querying for the plaintext
|
|
48
|
+
// hash — every bearer-token request would 401 once a KMS is active.
|
|
49
|
+
// Fixing that needs a blind-index-backed lookup (like email's
|
|
50
|
+
// find:"exact" companion column), a resolver.ts rewrite out of scope
|
|
51
|
+
// here — see PR body "Offene Fragen".
|
|
39
52
|
tokenHash: createTextField({
|
|
40
53
|
required: true,
|
|
41
54
|
maxLength: 64,
|
|
42
55
|
access: { write: access.privileged },
|
|
56
|
+
personal: false,
|
|
57
|
+
reason: "credential_hash_used_as_exact_lookup_key_2809",
|
|
43
58
|
}),
|
|
59
|
+
// Short displayable prefix ("your tokens" list) — not the secret itself,
|
|
60
|
+
// but still identifies which of the user's tokens this row is. Kept
|
|
61
|
+
// Klasse 1: list.query.ts returns `r.prefix` straight through with no
|
|
62
|
+
// decrypt call (unlike `name`, which goes through decryptStoredPii) — a
|
|
63
|
+
// subject annotation would leak ciphertext to the UI once a KMS is
|
|
64
|
+
// active. See PR body "Offene Fragen".
|
|
44
65
|
prefix: createTextField({
|
|
45
66
|
required: true,
|
|
46
67
|
maxLength: 16,
|
|
47
68
|
access: { write: access.privileged },
|
|
69
|
+
personal: false,
|
|
70
|
+
reason: "credential_prefix_read_without_decrypt_2809",
|
|
48
71
|
}),
|
|
49
72
|
// JSON-encoded string[] of granted scope names — mirrors the roles-column
|
|
50
73
|
// convention (parseRoles-style text); the resolver JSON.parses it.
|
|
51
|
-
scopes: createTextField({
|
|
74
|
+
scopes: createTextField({
|
|
75
|
+
required: true,
|
|
76
|
+
access: { write: access.privileged },
|
|
77
|
+
personal: false,
|
|
78
|
+
reason: "technical_reference",
|
|
79
|
+
}),
|
|
52
80
|
createdAt: createTimestampField({ required: true, access: { write: access.privileged } }),
|
|
53
81
|
expiresAt: createTimestampField({ access: { write: access.privileged } }),
|
|
54
82
|
revokedAt: createTimestampField({ access: { write: access.privileged } }),
|
package/src/secrets/table.ts
CHANGED
|
@@ -50,7 +50,7 @@ export const tenantSecretEntity = createEntity({
|
|
|
50
50
|
description:
|
|
51
51
|
"One encrypted secret per tenant and key, holding the sealed envelope, the key-encryption-key version it was sealed under and its last rotation time; plaintext values are never stored or returned.",
|
|
52
52
|
fields: {
|
|
53
|
-
key: createTextField({ required: true }),
|
|
53
|
+
key: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
54
54
|
kekVersion: createNumberField({ required: true, integer: true }),
|
|
55
55
|
},
|
|
56
56
|
});
|
|
@@ -33,11 +33,15 @@ export const userSessionEntity = createEntity({
|
|
|
33
33
|
required: true,
|
|
34
34
|
maxLength: 36,
|
|
35
35
|
access: { write: access.privileged },
|
|
36
|
+
personal: false,
|
|
37
|
+
reason: "pseudonymous_fk",
|
|
36
38
|
}),
|
|
37
39
|
tenantId: createTextField({
|
|
38
40
|
required: true,
|
|
39
41
|
maxLength: 36,
|
|
40
42
|
access: { write: access.privileged },
|
|
43
|
+
personal: false,
|
|
44
|
+
reason: "pseudonymous_fk",
|
|
41
45
|
}),
|
|
42
46
|
createdAt: createTimestampField({
|
|
43
47
|
required: true,
|
|
@@ -12,12 +12,18 @@ import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
12
12
|
// true } at the registration site (enforced by the registry, #820).
|
|
13
13
|
export async function encryptForDirectWrite(
|
|
14
14
|
entity: EntityDefinition,
|
|
15
|
+
entityName: string,
|
|
15
16
|
row: Record<string, unknown>,
|
|
16
17
|
fallbackRequestId: string,
|
|
17
18
|
): Promise<Record<string, unknown>> {
|
|
18
19
|
const kms = configuredPiiSubjectKms();
|
|
19
20
|
if (!kms) return row;
|
|
20
|
-
return encryptPiiFieldValues(
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
return encryptPiiFieldValues(
|
|
22
|
+
row,
|
|
23
|
+
entity,
|
|
24
|
+
collectPiiSubjectFields(entity),
|
|
25
|
+
kms,
|
|
26
|
+
{ requestId: requestContext.get()?.requestId ?? fallbackRequestId },
|
|
27
|
+
{ entityName },
|
|
28
|
+
);
|
|
23
29
|
}
|
|
@@ -47,8 +47,18 @@ const projectOwnership: NonNullable<EntityDefinition["access"]> = {
|
|
|
47
47
|
const projectEntity = createEntity({
|
|
48
48
|
table: PROJECT_TABLE,
|
|
49
49
|
fields: {
|
|
50
|
-
teamId: createTextField({
|
|
51
|
-
|
|
50
|
+
teamId: createTextField({
|
|
51
|
+
required: true,
|
|
52
|
+
maxLength: 64,
|
|
53
|
+
personal: false,
|
|
54
|
+
reason: "technical_reference",
|
|
55
|
+
}),
|
|
56
|
+
name: createTextField({
|
|
57
|
+
required: true,
|
|
58
|
+
maxLength: 64,
|
|
59
|
+
personal: false,
|
|
60
|
+
reason: "technical_reference",
|
|
61
|
+
}),
|
|
52
62
|
},
|
|
53
63
|
access: projectOwnership,
|
|
54
64
|
});
|
|
@@ -33,7 +33,14 @@ const tagsFeature = createTagsFeature();
|
|
|
33
33
|
|
|
34
34
|
const CREDIT_TABLE = "tags_test_credits";
|
|
35
35
|
const NOTE_TABLE = "tags_test_notes";
|
|
36
|
-
const hostFields = {
|
|
36
|
+
const hostFields = {
|
|
37
|
+
name: createTextField({
|
|
38
|
+
required: true,
|
|
39
|
+
maxLength: 64,
|
|
40
|
+
personal: false,
|
|
41
|
+
reason: "technical_reference",
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
37
44
|
const creditEntity = createEntity({ table: CREDIT_TABLE, fields: hostFields });
|
|
38
45
|
const noteEntity = createEntity({ table: NOTE_TABLE, fields: hostFields });
|
|
39
46
|
const hostFixturesFeature = defineFeature("tags-test-host-fixtures", (r) => {
|
package/src/tags/entity.ts
CHANGED
|
@@ -28,11 +28,11 @@ export const tagEntity = createEntity({
|
|
|
28
28
|
reason: "catalog_label",
|
|
29
29
|
}),
|
|
30
30
|
// Optional UI hint (hex or token). No enforcement — purely for rendering.
|
|
31
|
-
color: createTextField({ maxLength: 32 }),
|
|
31
|
+
color: createTextField({ maxLength: 32, personal: false, reason: "technical_reference" }),
|
|
32
32
|
// Optional entity-type scope (GitLab project-vs-group labels): empty = global
|
|
33
33
|
// (offered on every entity); a value like "note" restricts the tag to that
|
|
34
34
|
// entityType in the picker. No enforcement on assign — purely a picker hint.
|
|
35
|
-
scope: createTextField({ maxLength: 64 }),
|
|
35
|
+
scope: createTextField({ maxLength: 64, personal: false, reason: "technical_reference" }),
|
|
36
36
|
},
|
|
37
37
|
});
|
|
38
38
|
|
|
@@ -63,10 +63,25 @@ export function createTagAssignmentEntity(access?: EntityDefinition["access"]) {
|
|
|
63
63
|
softDelete: true,
|
|
64
64
|
access,
|
|
65
65
|
fields: {
|
|
66
|
-
tagId: createTextField({
|
|
67
|
-
|
|
66
|
+
tagId: createTextField({
|
|
67
|
+
required: true,
|
|
68
|
+
maxLength: 64,
|
|
69
|
+
personal: false,
|
|
70
|
+
reason: "technical_reference",
|
|
71
|
+
}),
|
|
72
|
+
entityType: createTextField({
|
|
73
|
+
required: true,
|
|
74
|
+
maxLength: 64,
|
|
75
|
+
personal: false,
|
|
76
|
+
reason: "technical_reference",
|
|
77
|
+
}),
|
|
68
78
|
// Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
|
|
69
|
-
entityId: createTextField({
|
|
79
|
+
entityId: createTextField({
|
|
80
|
+
required: true,
|
|
81
|
+
maxLength: 128,
|
|
82
|
+
personal: false,
|
|
83
|
+
reason: "technical_reference",
|
|
84
|
+
}),
|
|
70
85
|
},
|
|
71
86
|
});
|
|
72
87
|
}
|
|
@@ -18,16 +18,17 @@ import { CONTENT_FORMATS, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } f
|
|
|
18
18
|
export const templateResourceEntity = createEntity({
|
|
19
19
|
table: "read_template_resources",
|
|
20
20
|
fields: {
|
|
21
|
-
slug: createTextField({ required: true }),
|
|
21
|
+
slug: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
22
22
|
kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
|
|
23
|
-
locale: createTextField({ required: true }),
|
|
23
|
+
locale: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
24
24
|
// Human-facing heading for kind="text-block" (legal pages, marketing
|
|
25
25
|
// copy). Nullable because render templates carry their subject inside
|
|
26
26
|
// the body; a NOT NULL here would break every existing mail row.
|
|
27
|
-
|
|
27
|
+
// Same authorship class as `content` below.
|
|
28
|
+
title: createTextField({ personal: false, reason: "is_business_data" }),
|
|
28
29
|
// Folder path for the content tree, `/`-separated ("page/marketing").
|
|
29
30
|
// null = root node.
|
|
30
|
-
folder: createTextField({}),
|
|
31
|
+
folder: createTextField({ personal: false, reason: "technical_reference" }),
|
|
31
32
|
// Template-body is authored by TenantAdmin/Operator (email-templates etc.),
|
|
32
33
|
// business data — kein end-user UGC.
|
|
33
34
|
content: createLongTextField({
|
|
@@ -35,10 +36,10 @@ export const templateResourceEntity = createEntity({
|
|
|
35
36
|
reason: "is_business_data",
|
|
36
37
|
}),
|
|
37
38
|
contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
|
|
38
|
-
variableSchema: createLongTextField({}),
|
|
39
|
-
linkedResources: createLongTextField({}),
|
|
39
|
+
variableSchema: createLongTextField({ personal: false, reason: "technical_reference" }),
|
|
40
|
+
linkedResources: createLongTextField({ personal: false, reason: "technical_reference" }),
|
|
40
41
|
scope: createSelectField({ required: true, options: [...TEMPLATE_SCOPES] }),
|
|
41
|
-
parentTemplateId: createTextField({}),
|
|
42
|
+
parentTemplateId: createTextField({ personal: false, reason: "technical_reference" }),
|
|
42
43
|
status: createSelectField({ required: true, options: [...TEMPLATE_STATUSES] }),
|
|
43
44
|
},
|
|
44
45
|
indexes: [
|
|
@@ -25,11 +25,13 @@ export const userContentEntryEntity = createEntity({
|
|
|
25
25
|
table: "read_user_content_entries",
|
|
26
26
|
fields: {
|
|
27
27
|
ownerId: createTextField({ required: true, personal: "ref" }),
|
|
28
|
-
slug: createTextField({ required: true }),
|
|
28
|
+
slug: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
29
29
|
kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
|
|
30
|
-
locale: createTextField({ required: true }),
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
locale: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
31
|
+
// Organizational label for the entry (e.g. "Default signature"), not the
|
|
32
|
+
// owner's PII itself — that lives in `content` below.
|
|
33
|
+
title: createTextField({ personal: false, reason: "technical_reference" }),
|
|
34
|
+
folder: createTextField({ personal: false, reason: "technical_reference" }),
|
|
33
35
|
content: createLongTextField({
|
|
34
36
|
personal: { of: "ownerId" },
|
|
35
37
|
find: "none",
|
|
@@ -61,7 +61,12 @@ export const tenantInvitationEntity = createEntity({
|
|
|
61
61
|
email: createTextField({ required: true, maxLength: 320, personal: "self", find: "exact" }),
|
|
62
62
|
// Membership-Rolle die dem User nach Accept gegeben wird. Default
|
|
63
63
|
// im handler ist "Admin" (Co-Admin-Pattern für kleine Teams).
|
|
64
|
-
role: createTextField({
|
|
64
|
+
role: createTextField({
|
|
65
|
+
required: true,
|
|
66
|
+
maxLength: 50,
|
|
67
|
+
personal: false,
|
|
68
|
+
reason: "technical_reference",
|
|
69
|
+
}),
|
|
65
70
|
// Lifecycle-State. Default "pending"; transitions:
|
|
66
71
|
// pending → accepted | cancelled | expired
|
|
67
72
|
status: createSelectField({
|