@cosmicdrift/kumiko-bundled-features 0.253.0 → 0.255.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/billing-foundation/handlers/process-event.write.ts +1 -0
- package/src/billing-foundation/handlers/process-payment-event.write.ts +1 -0
- package/src/crypto-shredding/__tests__/subject-id-schema.test.ts +42 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +1 -3
- package/src/delivery/feature.ts +1 -1
- 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/notes-history/entity.ts +51 -17
- 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__/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/sessions/session-callbacks.ts +1 -0
- package/src/shared/encrypt-for-direct-write.ts +9 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -0
- package/src/user-data-rights/index.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.255.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.255.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.255.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.255.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.255.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.255.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.255.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.255.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.255.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -155,6 +155,7 @@ export const processEventHandler: WriteHandlerDef = {
|
|
|
155
155
|
SUBSCRIPTION_PII_FIELDS,
|
|
156
156
|
piiKms,
|
|
157
157
|
{ requestId: `billing-foundation:process-event:${payload.providerEventId}`, tenantId },
|
|
158
|
+
{ tenantId, entityName: SUBSCRIPTION_AGGREGATE_TYPE },
|
|
158
159
|
)
|
|
159
160
|
: {
|
|
160
161
|
providerCustomerId: payload.providerCustomerId,
|
|
@@ -84,6 +84,7 @@ export const processPaymentEventHandler: WriteHandlerDef = {
|
|
|
84
84
|
requestId: `billing-foundation:process-payment-event:${payload.providerEventId}`,
|
|
85
85
|
tenantId,
|
|
86
86
|
},
|
|
87
|
+
{ tenantId, entityName: PAYMENT_AGGREGATE_TYPE },
|
|
87
88
|
)
|
|
88
89
|
: { providerCustomerId: payload.providerCustomerId };
|
|
89
90
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Mint/shred parity (fw#2801 security review): a record subject key is
|
|
2
|
+
// minted via subjectKeyForRecord (framework crypto) but shredded via a
|
|
3
|
+
// forget-subject request validated against subjectIdSchema (this feature).
|
|
4
|
+
// If the two ever disagreed on which entity names are valid, a field could
|
|
5
|
+
// encrypt cleanly and then never be reachable by forgetSubject — the
|
|
6
|
+
// "encrypted but not shreddable" state fw#2809 was built to prevent. This
|
|
7
|
+
// pins the invariant down without needing a DB.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { subjectKeyForRecord } from "@cosmicdrift/kumiko-framework/crypto";
|
|
11
|
+
import { subjectIdSchema } from "../handlers/forget-subject.write";
|
|
12
|
+
|
|
13
|
+
const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
|
|
14
|
+
|
|
15
|
+
const VALID_ENTITY_NAMES = [
|
|
16
|
+
"mail-account",
|
|
17
|
+
"note-entry",
|
|
18
|
+
"documentExtract",
|
|
19
|
+
"a",
|
|
20
|
+
"A1",
|
|
21
|
+
"tenant_membership",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const INVALID_ENTITY_NAMES = ["v2.digest", "_internal", "3rd", "bad:name", ""];
|
|
25
|
+
|
|
26
|
+
describe("record subject mint/shred parity", () => {
|
|
27
|
+
test("every entity name subjectKeyForRecord accepts, subjectIdSchema also accepts", () => {
|
|
28
|
+
for (const entity of VALID_ENTITY_NAMES) {
|
|
29
|
+
expect(() => subjectKeyForRecord(entity, UUID_A)).not.toThrow();
|
|
30
|
+
const result = subjectIdSchema.safeParse({ kind: "record", entity, id: UUID_A });
|
|
31
|
+
expect(result.success).toBe(true);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("every entity name subjectKeyForRecord rejects, subjectIdSchema also rejects", () => {
|
|
36
|
+
for (const entity of INVALID_ENTITY_NAMES) {
|
|
37
|
+
expect(() => subjectKeyForRecord(entity, UUID_A)).toThrow();
|
|
38
|
+
const result = subjectIdSchema.safeParse({ kind: "record", entity, id: UUID_A });
|
|
39
|
+
expect(result.success).toBe(false);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -3,6 +3,7 @@ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
|
3
3
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
4
4
|
import {
|
|
5
5
|
configuredPiiSubjectKms,
|
|
6
|
+
RECORD_ENTITY_PATTERN,
|
|
6
7
|
type SubjectId,
|
|
7
8
|
subjectIdToKey,
|
|
8
9
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
@@ -48,9 +49,6 @@ import {
|
|
|
48
49
|
TARGET_TENANT_NOT_ADMIN_TENANT,
|
|
49
50
|
} from "../constants";
|
|
50
51
|
|
|
51
|
-
// Registry entity names are identifier-shaped; ":" would break the subject-key round-trip.
|
|
52
|
-
const RECORD_ENTITY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
53
|
-
|
|
54
52
|
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
55
53
|
z.object({ kind: z.literal("user"), userId: z.uuid() }),
|
|
56
54
|
z.object({ kind: z.literal("tenant"), tenantId: z.uuid() }),
|
package/src/delivery/feature.ts
CHANGED
|
@@ -64,7 +64,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
64
64
|
// recipientAddress is the real PII (email address); recipientId stays
|
|
65
65
|
// plaintext — pseudonymous fk, same line as config.userId (#821).
|
|
66
66
|
r.defineEvent("attempt", deliveryAttemptSchema, {
|
|
67
|
-
piiFields: { recipientAddress: {
|
|
67
|
+
piiFields: { recipientAddress: { personal: { of: "recipientId" } } },
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
// Inline projection that materialises every delivery attempt into
|
|
@@ -80,10 +80,14 @@ export const connectAccountHandler: WriteHandlerDef = {
|
|
|
80
80
|
const piiKms = configuredPiiSubjectKms();
|
|
81
81
|
const plainPii = { tenantId, address: payload.address };
|
|
82
82
|
const encryptedFields = piiKms
|
|
83
|
-
? await encryptPiiFieldValues(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
? await encryptPiiFieldValues(
|
|
84
|
+
plainPii,
|
|
85
|
+
mailAccountEntity,
|
|
86
|
+
MAIL_ACCOUNT_PII_FIELDS,
|
|
87
|
+
piiKms,
|
|
88
|
+
{ requestId: `inbound-mail-foundation:connect-account:${accountId}`, tenantId },
|
|
89
|
+
{ tenantId, entityName: MAIL_ACCOUNT_AGGREGATE_TYPE },
|
|
90
|
+
)
|
|
87
91
|
: plainPii;
|
|
88
92
|
|
|
89
93
|
const eventPayload: MailAccountEventPayload = {
|
|
@@ -179,10 +179,8 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
179
179
|
inboundMessageEntity,
|
|
180
180
|
INBOUND_MESSAGE_PII_FIELDS,
|
|
181
181
|
piiKms,
|
|
182
|
-
{
|
|
183
|
-
|
|
184
|
-
tenantId,
|
|
185
|
-
},
|
|
182
|
+
{ requestId: `inbound-mail-foundation:ingest-message:${messageAggId}`, tenantId },
|
|
183
|
+
{ tenantId, entityName: INBOUND_MESSAGE_AGGREGATE_TYPE },
|
|
186
184
|
)
|
|
187
185
|
: messagePlainPii;
|
|
188
186
|
|
|
@@ -265,6 +263,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
265
263
|
requestId: `inbound-mail-foundation:ingest-message:thread:${threadAggId}`,
|
|
266
264
|
tenantId,
|
|
267
265
|
},
|
|
266
|
+
{ tenantId, entityName: MAIL_THREAD_AGGREGATE_TYPE },
|
|
268
267
|
)
|
|
269
268
|
: threadPlainPii;
|
|
270
269
|
|
|
@@ -17,18 +17,26 @@ import {
|
|
|
17
17
|
// that is the whole point of a note *history* instead of the single
|
|
18
18
|
// overwritable textarea this bundle replaces (solon#13).
|
|
19
19
|
//
|
|
20
|
-
// `body` is about the HOST entity (entityType/entityId), not the author —
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// `
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
20
|
+
// `body` is about the HOST entity (entityType/entityId), not the author — the
|
|
21
|
+
// author is never the data subject of a note's content, so it is deliberately
|
|
22
|
+
// NOT `personal: { of: "authorId" }`. That axis would crypto-shred every
|
|
23
|
+
// note's content the moment its author's data-rights key is destroyed (an
|
|
24
|
+
// employee leaving triggers a forget), garbling unrelated business notes
|
|
25
|
+
// about customers/projects/etc. — data loss, not privacy.
|
|
26
|
+
//
|
|
27
|
+
// `body` is instead `personal: { of: "id" }` (Row-Subject, fw#2596/#2786):
|
|
28
|
+
// the note's OWN row is its content's subject, so `body` is encrypted under a
|
|
29
|
+
// per-note key that nothing else shares. That key gets erased only when
|
|
30
|
+
// `note-mention` (below) names this note as reached by a forgotten subject
|
|
31
|
+
// (see `notes-history-user-data/hooks.ts`) — a note with no mention on the
|
|
32
|
+
// forgotten subject is untouched. GDPR erasure of the AUTHOR still separately
|
|
33
|
+
// shreds `authorName` (`personal: { of: "authorId" }`, the name as stamped at
|
|
34
|
+
// write time); the two erasures are independent.
|
|
35
|
+
//
|
|
36
|
+
// Known gap (fw#2596, not fixed by this entity): prose that names a third
|
|
37
|
+
// party without a structured @-mention is not tracked — regex/NLP over
|
|
38
|
+
// free text is unreliable. The operator's manual `forget-subject` path
|
|
39
|
+
// covers what this structured trigger misses.
|
|
32
40
|
export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
|
|
33
41
|
return createEntity({
|
|
34
42
|
table: "read_note_entries",
|
|
@@ -66,14 +74,40 @@ export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
|
|
|
66
74
|
body: createLongTextField({
|
|
67
75
|
required: true,
|
|
68
76
|
maxLength: 20_000,
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// user
|
|
72
|
-
personal:
|
|
73
|
-
|
|
77
|
+
// Row-Subject (fw#2596/#2786): the note itself is the subject, so its
|
|
78
|
+
// key is erased per-note via `note-mention` lookups, not tied to any
|
|
79
|
+
// user's own forget.
|
|
80
|
+
personal: { of: "id" },
|
|
81
|
+
find: "none",
|
|
74
82
|
}),
|
|
75
83
|
},
|
|
76
84
|
});
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
export const noteEntryEntity = createNoteEntryEntity();
|
|
88
|
+
|
|
89
|
+
// note-mention — join row recording that a note names a subject via a
|
|
90
|
+
// structured @-mention in the editor (never parsed out of `body` text — see
|
|
91
|
+
// the header comment above). One row per (note, mentioned subject); a note
|
|
92
|
+
// with several mentions gets several rows.
|
|
93
|
+
//
|
|
94
|
+
// `subjectId` is the plain FK to the mentioned user's id — `personal: "ref"`
|
|
95
|
+
// marks it as a subject reference for the GDPR-hook-coverage boot guard
|
|
96
|
+
// (same role as `authorId` on note-entry above), not itself encrypted.
|
|
97
|
+
// `notes-history-user-data/hooks.ts` looks this table up by `subjectId` when
|
|
98
|
+
// a user is forgotten, to find every note whose row-subject key must be
|
|
99
|
+
// erased; see that file for the cascade and the retention-consult step it
|
|
100
|
+
// runs first.
|
|
101
|
+
export function createNoteMentionEntity() {
|
|
102
|
+
return createEntity({
|
|
103
|
+
table: "read_note_mentions",
|
|
104
|
+
description:
|
|
105
|
+
"One row recording that a note names a subject via a structured @-mention, keyed by noteId and the mentioned subject's id.",
|
|
106
|
+
fields: {
|
|
107
|
+
noteId: createTextField({ required: true, maxLength: 64 }),
|
|
108
|
+
subjectId: createTextField({ required: true, maxLength: 64, personal: "ref" }),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const noteMentionEntity = createNoteMentionEntity();
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { noteEntryEntity } from "./entity";
|
|
2
|
+
import { noteEntryEntity, noteMentionEntity } from "./entity";
|
|
3
3
|
|
|
4
4
|
export const { executor: noteEntryExecutor, table: noteEntryTable } = createEntityExecutor(
|
|
5
5
|
"note-entry",
|
|
6
6
|
noteEntryEntity,
|
|
7
7
|
);
|
|
8
|
+
|
|
9
|
+
export const { executor: noteMentionExecutor, table: noteMentionTable } = createEntityExecutor(
|
|
10
|
+
"note-mention",
|
|
11
|
+
noteMentionEntity,
|
|
12
|
+
);
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
23
|
import { hasWhereRule } from "../shared";
|
|
24
24
|
import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants";
|
|
25
|
-
import { createNoteEntryEntity } from "./entity";
|
|
25
|
+
import { createNoteEntryEntity, noteMentionEntity } from "./entity";
|
|
26
26
|
import { createAddNoteHandler } from "./handlers/add-note.write";
|
|
27
27
|
import { NOTES_HISTORY_FEATURE_I18N } from "./i18n";
|
|
28
28
|
|
|
@@ -43,6 +43,12 @@ function registerNotesHistory(
|
|
|
43
43
|
|
|
44
44
|
const entity = createNoteEntryEntity(ownership);
|
|
45
45
|
r.entity("note-entry", entity);
|
|
46
|
+
// No write/query handler of its own — populated only as a side effect of
|
|
47
|
+
// add-note (see handlers/add-note.write.ts), looked up by
|
|
48
|
+
// notes-history-user-data's forget cascade. Registering the entity (not
|
|
49
|
+
// just building it inline in the handler) is what makes it visible to the
|
|
50
|
+
// registry-wide GDPR boot guards and to executor.ts's table/projection setup.
|
|
51
|
+
r.entity("note-mention", noteMentionEntity);
|
|
46
52
|
|
|
47
53
|
r.writeHandler(createAddNoteHandler(access, parents));
|
|
48
54
|
r.queryHandler(
|
|
@@ -4,7 +4,7 @@ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/error
|
|
|
4
4
|
import { decryptStoredPii, parentRowIsVisible } from "../../shared";
|
|
5
5
|
import { userTable } from "../../user";
|
|
6
6
|
import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
|
|
7
|
-
import { noteEntryExecutor } from "../executor";
|
|
7
|
+
import { noteEntryExecutor, noteMentionExecutor } from "../executor";
|
|
8
8
|
import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
9
9
|
|
|
10
10
|
// add-note — appends a note-entry to (entityType, entityId). authorId is
|
|
@@ -69,11 +69,27 @@ export function createAddNoteHandler(
|
|
|
69
69
|
authorName = null;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const { mentions, ...notePayload } = payload;
|
|
73
|
+
const created = await noteEntryExecutor.create(
|
|
74
|
+
{ ...notePayload, authorId: event.user.id, authorName },
|
|
74
75
|
event.user,
|
|
75
76
|
ctx.db,
|
|
76
77
|
);
|
|
78
|
+
if (!created.isSuccess) return created;
|
|
79
|
+
|
|
80
|
+
// Same tx as the note create above (ctx.db) — a mention-row failure
|
|
81
|
+
// rolls the note back with it, same as the framework's own nested-write
|
|
82
|
+
// parent+child pattern (dispatch-write.ts).
|
|
83
|
+
for (const subjectId of new Set(mentions ?? [])) {
|
|
84
|
+
const mentionResult = await noteMentionExecutor.create(
|
|
85
|
+
{ noteId: created.data.id, subjectId },
|
|
86
|
+
event.user,
|
|
87
|
+
ctx.db,
|
|
88
|
+
);
|
|
89
|
+
if (!mentionResult.isSuccess) return mentionResult;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return created;
|
|
77
93
|
},
|
|
78
94
|
};
|
|
79
95
|
}
|
|
@@ -6,8 +6,13 @@ export {
|
|
|
6
6
|
NotesHistoryHandlers,
|
|
7
7
|
NotesHistoryQueries,
|
|
8
8
|
} from "./constants";
|
|
9
|
-
export { noteEntryEntity } from "./entity";
|
|
10
|
-
export {
|
|
9
|
+
export { noteEntryEntity, noteMentionEntity } from "./entity";
|
|
10
|
+
export {
|
|
11
|
+
noteEntryExecutor,
|
|
12
|
+
noteEntryTable,
|
|
13
|
+
noteMentionExecutor,
|
|
14
|
+
noteMentionTable,
|
|
15
|
+
} from "./executor";
|
|
11
16
|
export {
|
|
12
17
|
createNotesHistoryFeature,
|
|
13
18
|
type NotesHistoryFeatureOptions,
|
|
@@ -4,9 +4,14 @@ import { z } from "zod";
|
|
|
4
4
|
// needed here since note-entry has no deterministic aggregate-id derived
|
|
5
5
|
// from these fields (see aggregate-id note in entity.ts): a literal value
|
|
6
6
|
// can't shift stream-id tuple boundaries when there is no such tuple.
|
|
7
|
+
// `mentions` carries structured @-mentions from the editor as user ids —
|
|
8
|
+
// never parsed out of `body` text (regex/NLP over free text is unreliable,
|
|
9
|
+
// see entity.ts). Capped well above any realistic note; a client sending more
|
|
10
|
+
// is a bug, not a use case worth accommodating.
|
|
7
11
|
export const addNotePayloadSchema = z.object({
|
|
8
12
|
entityType: z.string().min(1).max(64),
|
|
9
13
|
entityId: z.string().min(1).max(128),
|
|
10
14
|
body: z.string().trim().min(1).max(20_000),
|
|
15
|
+
mentions: z.array(z.uuid()).max(50).optional(),
|
|
11
16
|
});
|
|
12
17
|
export type AddNotePayload = z.infer<typeof addNotePayloadSchema>;
|
|
@@ -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
|
});
|
|
@@ -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
|
}
|
|
@@ -333,6 +333,7 @@ describe("runUserExport :: PII-Subject-Ciphertexte (#820)", () => {
|
|
|
333
333
|
["email", "displayName"],
|
|
334
334
|
kms,
|
|
335
335
|
{ requestId: "test" },
|
|
336
|
+
{ entityName: "user" },
|
|
336
337
|
);
|
|
337
338
|
await asRawClient(stack.db).unsafe(
|
|
338
339
|
`UPDATE read_users SET email = $1, display_name = $2 WHERE id = $3`,
|
|
@@ -366,6 +367,7 @@ describe("runUserExport :: PII-Subject-Ciphertexte (#820)", () => {
|
|
|
366
367
|
["email"],
|
|
367
368
|
kms,
|
|
368
369
|
{ requestId: "test" },
|
|
370
|
+
{ entityName: "user" },
|
|
369
371
|
);
|
|
370
372
|
await asRawClient(stack.db).unsafe(`UPDATE read_users SET email = $1 WHERE id = $2`, [
|
|
371
373
|
String(encrypted["email"]),
|
|
@@ -414,6 +416,7 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
|
|
|
414
416
|
["email"],
|
|
415
417
|
kms,
|
|
416
418
|
{ requestId: "test" },
|
|
419
|
+
{ entityName: "user" },
|
|
417
420
|
);
|
|
418
421
|
await asRawClient(stack.db).unsafe(`UPDATE read_users SET email = $1 WHERE id = $2`, [
|
|
419
422
|
String(encryptedUser["email"]),
|
|
@@ -550,6 +553,7 @@ describe("runUserExport :: fileRef PII export via the fileRefs side-channel (#19
|
|
|
550
553
|
["fileName"],
|
|
551
554
|
kms,
|
|
552
555
|
{ requestId: "test" },
|
|
556
|
+
{ entityName: "fileRef" },
|
|
553
557
|
);
|
|
554
558
|
await asRawClient(stack.db).unsafe(
|
|
555
559
|
`
|
|
@@ -16,7 +16,12 @@ export type { SendDeletionExecutedEmailFn } from "./run-forget-cleanup";
|
|
|
16
16
|
// Runner-Exports — App-Tests dürfen export/forget deterministisch laufen
|
|
17
17
|
// lassen, statt über den Job-Cron zu warten (siehe sample
|
|
18
18
|
// user-data-rights-demo).
|
|
19
|
-
|
|
19
|
+
// Shared retention→strategy mapping (single source of truth per its own
|
|
20
|
+
// doc comment) — other EXT_USER_DATA delete hooks that must independently
|
|
21
|
+
// consult a DIFFERENT entity's retention (e.g. notes-history-user-data's
|
|
22
|
+
// host-entity check) reuse this instead of re-deriving the anonymize/delete
|
|
23
|
+
// split.
|
|
24
|
+
export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup";
|
|
20
25
|
export type { UserExportBundle } from "./run-user-export";
|
|
21
26
|
export { runUserExport } from "./run-user-export";
|
|
22
27
|
export {
|