@cosmicdrift/kumiko-bundled-features 0.252.0 → 0.253.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/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/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 +12 -2
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +8 -1
- 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/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__/tenant-model-erasure.integration.test.ts +3 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.253.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.253.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.253.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.253.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.253.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.253.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.253.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.253.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.253.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -14,7 +14,7 @@ const gatedEntity = createEntity({
|
|
|
14
14
|
fields: {
|
|
15
15
|
status: createSelectField({ options: ["draft", "active"] as const, filterable: true }),
|
|
16
16
|
ownerId: { type: "reference", entity: "user" } satisfies ReferenceFieldDef,
|
|
17
|
-
title: createTextField({ searchable: true }),
|
|
17
|
+
title: createTextField({ searchable: true, personal: false, reason: "technical_reference" }),
|
|
18
18
|
// Raw ResolvedPiiFlags form — the manifest is schema, not rows, so this
|
|
19
19
|
// must never surface a value, only the marker.
|
|
20
20
|
secretNote: { type: "text", pii: true } satisfies TextFieldDef,
|
|
@@ -58,7 +58,7 @@ function buildTestFeature() {
|
|
|
58
58
|
|
|
59
59
|
const otherEntity = createEntity({
|
|
60
60
|
fields: {
|
|
61
|
-
label: createTextField({ searchable: true }),
|
|
61
|
+
label: createTextField({ searchable: true, personal: false, reason: "technical_reference" }),
|
|
62
62
|
},
|
|
63
63
|
});
|
|
64
64
|
|
|
@@ -147,7 +147,12 @@ describe("denyQns removes a tool", () => {
|
|
|
147
147
|
|
|
148
148
|
const gadgetEntity = createEntity({
|
|
149
149
|
fields: {
|
|
150
|
-
name: createTextField({
|
|
150
|
+
name: createTextField({
|
|
151
|
+
searchable: true,
|
|
152
|
+
filterable: true,
|
|
153
|
+
personal: false,
|
|
154
|
+
reason: "technical_reference",
|
|
155
|
+
}),
|
|
151
156
|
},
|
|
152
157
|
});
|
|
153
158
|
|
|
@@ -193,10 +198,24 @@ describe("denyQns reaches the entity CRUD tools", () => {
|
|
|
193
198
|
});
|
|
194
199
|
|
|
195
200
|
const plainItemEntity = createEntity({
|
|
196
|
-
fields: {
|
|
201
|
+
fields: {
|
|
202
|
+
label: createTextField({
|
|
203
|
+
searchable: true,
|
|
204
|
+
filterable: true,
|
|
205
|
+
personal: false,
|
|
206
|
+
reason: "technical_reference",
|
|
207
|
+
}),
|
|
208
|
+
},
|
|
197
209
|
});
|
|
198
210
|
const cloakedItemEntity = createEntity({
|
|
199
|
-
fields: {
|
|
211
|
+
fields: {
|
|
212
|
+
label: createTextField({
|
|
213
|
+
searchable: true,
|
|
214
|
+
filterable: true,
|
|
215
|
+
personal: false,
|
|
216
|
+
reason: "technical_reference",
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
200
219
|
});
|
|
201
220
|
|
|
202
221
|
// Both entities mounted in the same feature so the "default stays exposed"
|
|
@@ -27,7 +27,13 @@ const FEATURE_NAME = "agent-tools-int-test";
|
|
|
27
27
|
const widgetEntity = createEntity({
|
|
28
28
|
table: "agent_tools_int_test_widgets",
|
|
29
29
|
fields: {
|
|
30
|
-
name: createTextField({
|
|
30
|
+
name: createTextField({
|
|
31
|
+
required: true,
|
|
32
|
+
searchable: true,
|
|
33
|
+
filterable: true,
|
|
34
|
+
personal: false,
|
|
35
|
+
reason: "technical_reference",
|
|
36
|
+
}),
|
|
31
37
|
status: createSelectField({ options: ["pending", "approved"] as const, filterable: true }),
|
|
32
38
|
},
|
|
33
39
|
});
|
|
@@ -17,13 +17,20 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
17
17
|
|
|
18
18
|
const widgetEntity = createEntity({
|
|
19
19
|
fields: {
|
|
20
|
-
name: createTextField({
|
|
20
|
+
name: createTextField({
|
|
21
|
+
searchable: true,
|
|
22
|
+
filterable: true,
|
|
23
|
+
personal: false,
|
|
24
|
+
reason: "technical_reference",
|
|
25
|
+
}),
|
|
21
26
|
status: createSelectField({ options: ["open", "closed"] as const, filterable: true }),
|
|
22
|
-
notes: createTextField(),
|
|
27
|
+
notes: createTextField({ personal: false, reason: "technical_reference" }),
|
|
23
28
|
},
|
|
24
29
|
});
|
|
25
30
|
|
|
26
|
-
const orphanEntity = createEntity({
|
|
31
|
+
const orphanEntity = createEntity({
|
|
32
|
+
fields: { label: createTextField({ personal: false, reason: "technical_reference" }) },
|
|
33
|
+
});
|
|
27
34
|
|
|
28
35
|
function buildCatalogTestFeature() {
|
|
29
36
|
return defineFeature("catalog-test", (r) => {
|
|
@@ -104,7 +111,7 @@ function buildCatalogTestFeature() {
|
|
|
104
111
|
id: "widget-approve-form",
|
|
105
112
|
type: "actionForm",
|
|
106
113
|
handler: "catalog-test:write:widget:approve",
|
|
107
|
-
fields: { note: createTextField() },
|
|
114
|
+
fields: { note: createTextField({ personal: false, reason: "technical_reference" }) },
|
|
108
115
|
layout: { sections: [{ title: "s", fields: ["note"] }] },
|
|
109
116
|
});
|
|
110
117
|
});
|
|
@@ -23,8 +23,19 @@ import { dispatchToolCall } from "../tool-dispatch";
|
|
|
23
23
|
const vendorEntity = createEntity({
|
|
24
24
|
table: "agent_tools_test_vendors",
|
|
25
25
|
fields: {
|
|
26
|
-
name: createTextField({
|
|
27
|
-
|
|
26
|
+
name: createTextField({
|
|
27
|
+
required: true,
|
|
28
|
+
searchable: true,
|
|
29
|
+
filterable: true,
|
|
30
|
+
personal: false,
|
|
31
|
+
reason: "is_business_data",
|
|
32
|
+
}),
|
|
33
|
+
iban: createTextField({
|
|
34
|
+
required: true,
|
|
35
|
+
filterable: true,
|
|
36
|
+
personal: false,
|
|
37
|
+
reason: "is_business_data",
|
|
38
|
+
}),
|
|
28
39
|
},
|
|
29
40
|
});
|
|
30
41
|
|
|
@@ -38,7 +38,7 @@ import { createAuditFeature } from "../feature";
|
|
|
38
38
|
const widgetEntity = createEntity({
|
|
39
39
|
table: "audit_sec_widgets",
|
|
40
40
|
fields: {
|
|
41
|
-
name: createTextField({ required: true }),
|
|
41
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
42
42
|
},
|
|
43
43
|
});
|
|
44
44
|
|
|
@@ -29,8 +29,8 @@ import { createAuditFeature } from "../feature";
|
|
|
29
29
|
const widgetEntity = createEntity({
|
|
30
30
|
table: "audit_widgets",
|
|
31
31
|
fields: {
|
|
32
|
-
name: createTextField({ required: true }),
|
|
33
|
-
color: createTextField(),
|
|
32
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
33
|
+
color: createTextField({ personal: false, reason: "technical_reference" }),
|
|
34
34
|
},
|
|
35
35
|
});
|
|
36
36
|
|
|
@@ -39,7 +39,12 @@ import {
|
|
|
39
39
|
export const subscriptionEntity = createEntity({
|
|
40
40
|
table: "read_subscriptions",
|
|
41
41
|
fields: {
|
|
42
|
-
providerName: createTextField({
|
|
42
|
+
providerName: createTextField({
|
|
43
|
+
required: true,
|
|
44
|
+
maxLength: 50,
|
|
45
|
+
personal: false,
|
|
46
|
+
reason: "technical_reference",
|
|
47
|
+
}),
|
|
43
48
|
// `personal: "tenant"` (not `encrypted`): the field must crypto-shred when
|
|
44
49
|
// eraseSubjectKeys erases the tenant's subject key on tenant-destroy
|
|
45
50
|
// (#800). `encrypted: true` uses the app-wide master key instead — that
|
|
@@ -61,8 +66,18 @@ export const subscriptionEntity = createEntity({
|
|
|
61
66
|
personal: "tenant",
|
|
62
67
|
find: "none",
|
|
63
68
|
}),
|
|
64
|
-
status: createTextField({
|
|
65
|
-
|
|
69
|
+
status: createTextField({
|
|
70
|
+
required: true,
|
|
71
|
+
maxLength: 30,
|
|
72
|
+
personal: false,
|
|
73
|
+
reason: "technical_reference",
|
|
74
|
+
}),
|
|
75
|
+
tier: createTextField({
|
|
76
|
+
required: true,
|
|
77
|
+
maxLength: 50,
|
|
78
|
+
personal: false,
|
|
79
|
+
reason: "catalog_label",
|
|
80
|
+
}),
|
|
66
81
|
currentPeriodEnd: createTimestampField({ required: true }),
|
|
67
82
|
},
|
|
68
83
|
});
|
|
@@ -88,7 +103,12 @@ export const SUBSCRIPTION_PII_FIELDS = collectPiiSubjectFields(subscriptionEntit
|
|
|
88
103
|
export const paymentEntity = createEntity({
|
|
89
104
|
table: "read_payments",
|
|
90
105
|
fields: {
|
|
91
|
-
providerName: createTextField({
|
|
106
|
+
providerName: createTextField({
|
|
107
|
+
required: true,
|
|
108
|
+
maxLength: 50,
|
|
109
|
+
personal: false,
|
|
110
|
+
reason: "technical_reference",
|
|
111
|
+
}),
|
|
92
112
|
// Same `personal: "tenant"` rationale as subscriptionEntity above —
|
|
93
113
|
// crypto-shreds on tenant-destroy (#800) via eraseSubjectKeys.
|
|
94
114
|
providerCustomerId: createTextField({
|
|
@@ -97,7 +117,12 @@ export const paymentEntity = createEntity({
|
|
|
97
117
|
personal: "tenant",
|
|
98
118
|
find: "none",
|
|
99
119
|
}),
|
|
100
|
-
priceId: createTextField({
|
|
120
|
+
priceId: createTextField({
|
|
121
|
+
required: true,
|
|
122
|
+
maxLength: 200,
|
|
123
|
+
personal: false,
|
|
124
|
+
reason: "technical_reference",
|
|
125
|
+
}),
|
|
101
126
|
},
|
|
102
127
|
});
|
|
103
128
|
|
|
@@ -42,7 +42,14 @@ export const capCounterEntity = createEntity({
|
|
|
42
42
|
description:
|
|
43
43
|
"One usage counter per tenant, cap name and calendar period, holding the accumulated value and the timestamp of the last soft-limit warning.",
|
|
44
44
|
fields: {
|
|
45
|
-
capName: createTextField({
|
|
45
|
+
capName: createTextField({
|
|
46
|
+
required: true,
|
|
47
|
+
maxLength: 100,
|
|
48
|
+
sortable: true,
|
|
49
|
+
searchable: true,
|
|
50
|
+
personal: false,
|
|
51
|
+
reason: "technical_reference",
|
|
52
|
+
}),
|
|
46
53
|
value: createBigIntField({ required: true, default: 0 }),
|
|
47
54
|
periodStart: createTimestampField({ required: true }),
|
|
48
55
|
lastSoftWarnedAt: createTimestampField(),
|
package/src/config/table.ts
CHANGED
|
@@ -19,11 +19,13 @@ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/eng
|
|
|
19
19
|
export const configValueEntity = createEntity({
|
|
20
20
|
table: "read_config_values",
|
|
21
21
|
fields: {
|
|
22
|
-
key: createTextField({ required: true }),
|
|
22
|
+
key: createTextField({ required: true, personal: false, reason: "system_metadata" }),
|
|
23
23
|
// value is JSON-encoded primitive (or encrypted blob). Nullable so a
|
|
24
24
|
// deleted-then-recreated stream can signal "reset to default" without
|
|
25
25
|
// breaking the null-vs-missing distinction the resolver already draws.
|
|
26
|
-
|
|
26
|
+
// App/system/tenant config values (service urls, feature toggles,
|
|
27
|
+
// mail-server settings), not user-authored content.
|
|
28
|
+
value: createTextField({ personal: false, reason: "system_metadata" }),
|
|
27
29
|
// user-scope row: userId populated. tenant- / system-scope: null.
|
|
28
30
|
userId: createTextField({
|
|
29
31
|
personal: false,
|
|
@@ -41,7 +41,13 @@ import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko
|
|
|
41
41
|
import { createConfigFeature } from "../../config";
|
|
42
42
|
import { createTenantFeature } from "../../tenant";
|
|
43
43
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
RECORD_ENTITY_NOT_REGISTERED,
|
|
46
|
+
SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
47
|
+
SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
48
|
+
TARGET_RECORD_NOT_ADMIN_TENANT,
|
|
49
|
+
TARGET_RECORD_RETENTION_BLOCK_DELETE,
|
|
50
|
+
} from "../constants";
|
|
45
51
|
import { createCryptoShreddingFeature } from "../feature";
|
|
46
52
|
|
|
47
53
|
const FORGET = "crypto-shredding:write:forget-subject";
|
|
@@ -160,6 +166,25 @@ describe("crypto-shredding :: forget-subject (record subject, #2786)", () => {
|
|
|
160
166
|
requestId: "test",
|
|
161
167
|
});
|
|
162
168
|
expect(decrypted["body"]).toBe(PII_ERASED_SENTINEL);
|
|
169
|
+
|
|
170
|
+
// fw#2789: the operator path's audit trail — who erased which subject,
|
|
171
|
+
// and why — must be provable, not just the erase itself. The bystander
|
|
172
|
+
// user-forget above appended its own subject-forgotten event under the
|
|
173
|
+
// same rowId (record:<entity>:<id> and user:<id> share aggregate ids
|
|
174
|
+
// here), so this looks the record subject's event up by its subjectKey
|
|
175
|
+
// rather than assuming it is the only row.
|
|
176
|
+
const forgottenEvents = (await selectMany(stack.db, eventsTable, {
|
|
177
|
+
type: SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
178
|
+
})) as Array<{ payload: Record<string, unknown> }>;
|
|
179
|
+
const recordSubjectKey = `record:${RECORD_PROBE_ENTITY_NAME}:${rowId}`;
|
|
180
|
+
const recordForgottenEvent = forgottenEvents.find(
|
|
181
|
+
(e) => e.payload["subjectKey"] === recordSubjectKey,
|
|
182
|
+
);
|
|
183
|
+
expect(recordForgottenEvent?.payload).toMatchObject({
|
|
184
|
+
subjectKey: recordSubjectKey,
|
|
185
|
+
reason: REASON,
|
|
186
|
+
forgottenBy: dpoUser.id,
|
|
187
|
+
});
|
|
163
188
|
});
|
|
164
189
|
|
|
165
190
|
// Empirical: update()'s previous-row re-encrypt (encryptForStorage(previous))
|
|
@@ -289,3 +314,118 @@ describe("crypto-shredding :: forget-subject (record subject) tenant gate, #2786
|
|
|
289
314
|
);
|
|
290
315
|
});
|
|
291
316
|
});
|
|
317
|
+
|
|
318
|
+
// fw#2789: retention.strategy="blockDelete" on the host entity must win over
|
|
319
|
+
// a targeted row-shred — a separate stack (no tenant feature mounted) so the
|
|
320
|
+
// denial is proven independent of the tenant gate above, not a side effect
|
|
321
|
+
// of it.
|
|
322
|
+
const BLOCK_DELETE_PROBE_ENTITY_NAME = "blockDeleteProbe";
|
|
323
|
+
|
|
324
|
+
const blockDeleteProbeEntity = createEntity({
|
|
325
|
+
table: "read_forget_subject_blockdelete_probe",
|
|
326
|
+
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
327
|
+
fields: {
|
|
328
|
+
body: createTextField({
|
|
329
|
+
required: true,
|
|
330
|
+
maxLength: 200,
|
|
331
|
+
personal: { of: "id" },
|
|
332
|
+
find: "none",
|
|
333
|
+
}),
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
const blockDeleteProbeTable = buildEntityTable(
|
|
337
|
+
"forgetSubjectBlockDeleteProbe",
|
|
338
|
+
blockDeleteProbeEntity,
|
|
339
|
+
);
|
|
340
|
+
const blockDeleteProbeFeature = defineFeature("forget-subject-blockdelete-probe", (r) => {
|
|
341
|
+
r.entity(BLOCK_DELETE_PROBE_ENTITY_NAME, blockDeleteProbeEntity);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
function blockDeleteProbeExecutor() {
|
|
345
|
+
return createEventStoreExecutor(blockDeleteProbeTable, blockDeleteProbeEntity, {
|
|
346
|
+
entityName: BLOCK_DELETE_PROBE_ENTITY_NAME,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
describe("crypto-shredding :: forget-subject (record subject) retention gate, #2789", () => {
|
|
351
|
+
let retentionStack: TestStack;
|
|
352
|
+
let retentionKms: InMemoryKmsAdapter;
|
|
353
|
+
|
|
354
|
+
const RETENTION_TENANT: TenantId = testTenantId(24);
|
|
355
|
+
const dpoRetentionUser = {
|
|
356
|
+
id: "cccccccc-cccc-4ccc-8ccc-000000000004",
|
|
357
|
+
tenantId: RETENTION_TENANT,
|
|
358
|
+
roles: ["DataProtectionOfficer"],
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
beforeAll(async () => {
|
|
362
|
+
retentionStack = await setupTestStack({
|
|
363
|
+
features: [createCryptoShreddingFeature(), blockDeleteProbeFeature],
|
|
364
|
+
});
|
|
365
|
+
await unsafeCreateEntityTable(
|
|
366
|
+
retentionStack.db,
|
|
367
|
+
blockDeleteProbeEntity,
|
|
368
|
+
BLOCK_DELETE_PROBE_ENTITY_NAME,
|
|
369
|
+
);
|
|
370
|
+
await createEventsTable(retentionStack.db);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
afterAll(async () => {
|
|
374
|
+
await retentionStack.cleanup();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
beforeEach(async () => {
|
|
378
|
+
await resetTestTables(retentionStack.db, [blockDeleteProbeTable, eventsTable]);
|
|
379
|
+
retentionKms = new InMemoryKmsAdapter();
|
|
380
|
+
configurePiiSubjectKms(retentionKms);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
afterEach(() => {
|
|
384
|
+
resetPiiSubjectKmsForTests();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("blockDelete entity refuses the shred — key and plaintext survive, denial is audited", async () => {
|
|
388
|
+
const tenantDb = createTenantDb(retentionStack.db, RETENTION_TENANT, "system");
|
|
389
|
+
const plaintext = "ledger entry naming a third party";
|
|
390
|
+
const created = await blockDeleteProbeExecutor().create(
|
|
391
|
+
{ body: plaintext },
|
|
392
|
+
dpoRetentionUser,
|
|
393
|
+
tenantDb,
|
|
394
|
+
);
|
|
395
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
396
|
+
const rowId = String(created.data.id);
|
|
397
|
+
|
|
398
|
+
const err = await retentionStack.http.writeErr(
|
|
399
|
+
FORGET,
|
|
400
|
+
{
|
|
401
|
+
subject: { kind: "record", entity: BLOCK_DELETE_PROBE_ENTITY_NAME, id: rowId },
|
|
402
|
+
reason: REASON,
|
|
403
|
+
},
|
|
404
|
+
dpoRetentionUser,
|
|
405
|
+
);
|
|
406
|
+
expect(err.httpStatus).toBe(403);
|
|
407
|
+
expect((err.details as { reason?: string } | undefined)?.reason).toBe(
|
|
408
|
+
TARGET_RECORD_RETENTION_BLOCK_DELETE,
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
// The load-bearing claim: blockDelete winning means the data survived,
|
|
412
|
+
// not merely that the request returned 403.
|
|
413
|
+
const stillReadable = await blockDeleteProbeExecutor().detail(
|
|
414
|
+
{ id: rowId },
|
|
415
|
+
dpoRetentionUser,
|
|
416
|
+
tenantDb,
|
|
417
|
+
);
|
|
418
|
+
expect(stillReadable?.["body"]).toBe(plaintext);
|
|
419
|
+
|
|
420
|
+
const deniedEvents = (await selectMany(retentionStack.db, eventsTable, {
|
|
421
|
+
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
422
|
+
})) as Array<{ payload: Record<string, unknown> }>;
|
|
423
|
+
expect(deniedEvents).toHaveLength(1);
|
|
424
|
+
expect(deniedEvents[0]?.payload).toMatchObject({
|
|
425
|
+
subjectKind: "record",
|
|
426
|
+
denial: TARGET_RECORD_RETENTION_BLOCK_DELETE,
|
|
427
|
+
reason: REASON,
|
|
428
|
+
forgottenBy: dpoRetentionUser.id,
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
});
|
|
@@ -262,7 +262,7 @@ describe("crypto-shredding :: forget-subject purges the derived search index (#1
|
|
|
262
262
|
const probeEntity = createEntity({
|
|
263
263
|
table: "read_forget_subject_search_probe",
|
|
264
264
|
fields: {
|
|
265
|
-
ownerId: createTextField({ required: true }),
|
|
265
|
+
ownerId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
|
|
266
266
|
userNote: createTextField({
|
|
267
267
|
required: true,
|
|
268
268
|
maxLength: 100,
|
|
@@ -8,3 +8,4 @@ export const CRYPTO_SHREDDING_AGGREGATE_TYPE = "crypto-shredding-subject";
|
|
|
8
8
|
export const TARGET_TENANT_NOT_ADMIN_TENANT = "target_tenant_not_admin_tenant" as const;
|
|
9
9
|
export const RECORD_ENTITY_NOT_REGISTERED = "record_entity_not_registered" as const;
|
|
10
10
|
export const TARGET_RECORD_NOT_ADMIN_TENANT = "target_record_not_admin_tenant" as const;
|
|
11
|
+
export const TARGET_RECORD_RETENTION_BLOCK_DELETE = "target_record_retention_block_delete" as const;
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
45
45
|
SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
46
46
|
TARGET_RECORD_NOT_ADMIN_TENANT,
|
|
47
|
+
TARGET_RECORD_RETENTION_BLOCK_DELETE,
|
|
47
48
|
TARGET_TENANT_NOT_ADMIN_TENANT,
|
|
48
49
|
} from "../constants";
|
|
49
50
|
|
|
@@ -171,6 +172,28 @@ async function resolveTenantScopeDenial(
|
|
|
171
172
|
return ownedInTenant ? undefined : memberDenied;
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
// fw#2789: the host entity's OWN retention declaration gets the last word on
|
|
176
|
+
// a targeted row-shred — blockDelete (legally mandated physical retention,
|
|
177
|
+
// e.g. ledger/invoice text) means this command must refuse, not silently
|
|
178
|
+
// anonymize or proceed. Deliberately independent of the tenant feature/gate
|
|
179
|
+
// above: a retention obligation holds in single-tenant apps too, and running
|
|
180
|
+
// unconditionally (not nested under `features.has("tenant")`) keeps that
|
|
181
|
+
// true. Only the entity's declared `retention.strategy` is consulted here —
|
|
182
|
+
// NOT the data-retention feature's tenant-preset/override layering
|
|
183
|
+
// (resolveRetentionPolicy) — so a tenant cannot override its own way past a
|
|
184
|
+
// blockDelete declared on the entity.
|
|
185
|
+
function resolveRetentionDenial(
|
|
186
|
+
features: ReadonlyMap<string, FeatureDefinition>,
|
|
187
|
+
raw: SubjectIdInput,
|
|
188
|
+
): WriteFailure | undefined {
|
|
189
|
+
if (raw.kind !== "record") return undefined;
|
|
190
|
+
const entity = findRegisteredEntity(features, raw.entity);
|
|
191
|
+
if (entity?.retention?.strategy !== "blockDelete") return undefined;
|
|
192
|
+
return writeFailure(
|
|
193
|
+
new AccessDeniedError({ details: { reason: TARGET_RECORD_RETENTION_BLOCK_DELETE } }),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
174
197
|
// Denied cross-tenant probes must still leave an audit trail (fw#2348).
|
|
175
198
|
// Appended outside the handler tx (fw#2592): the caller's
|
|
176
199
|
// `return tenantScopeDenial` rolls that tx back.
|
|
@@ -296,6 +319,25 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
296
319
|
return auditFailure ?? tenantScopeDenial;
|
|
297
320
|
}
|
|
298
321
|
|
|
322
|
+
// Runs AFTER the tenant gate (VORHER only means "before the shred"): a
|
|
323
|
+
// retention check ahead of the tenant gate would leak a foreign
|
|
324
|
+
// entity's retention posture to a cross-tenant prober.
|
|
325
|
+
const retentionDenial = resolveRetentionDenial(ctx.registry.features, raw);
|
|
326
|
+
if (retentionDenial) {
|
|
327
|
+
// Own reason constant, not tenantScopeDenial's `.error.code` pattern:
|
|
328
|
+
// AccessDeniedError.code is the generic "access_denied" for every
|
|
329
|
+
// branch, so re-deriving it here would make a blockDelete refusal
|
|
330
|
+
// indistinguishable from a cross-tenant one in the audit trail.
|
|
331
|
+
const auditFailure = await appendDenialAuditEvent(
|
|
332
|
+
ctx,
|
|
333
|
+
event,
|
|
334
|
+
subjectKey,
|
|
335
|
+
raw.kind,
|
|
336
|
+
TARGET_RECORD_RETENTION_BLOCK_DELETE,
|
|
337
|
+
);
|
|
338
|
+
return auditFailure ?? retentionDenial;
|
|
339
|
+
}
|
|
340
|
+
|
|
299
341
|
// Erase BEFORE the audit append: if the append throws, the key is gone
|
|
300
342
|
// but no event exists — a retry is a no-op erase plus the event. The
|
|
301
343
|
// reverse order could leave an audit trail claiming a shred that never
|
|
@@ -40,7 +40,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
|
40
40
|
const propertyEntity = createEntity({
|
|
41
41
|
table: "read_t15a_properties",
|
|
42
42
|
fields: {
|
|
43
|
-
name: createTextField({ required: true }),
|
|
43
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
44
44
|
customFields: customFieldsField(),
|
|
45
45
|
},
|
|
46
46
|
});
|
|
@@ -37,7 +37,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
|
37
37
|
const propertyEntity = createEntity({
|
|
38
38
|
table: "read_xt_properties",
|
|
39
39
|
fields: {
|
|
40
|
-
name: createTextField({ required: true }),
|
|
40
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
41
41
|
customFields: customFieldsField(),
|
|
42
42
|
},
|
|
43
43
|
});
|
|
@@ -33,7 +33,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
|
33
33
|
const propertyEntity = createEntity({
|
|
34
34
|
table: "read_xt_set_properties",
|
|
35
35
|
fields: {
|
|
36
|
-
name: createTextField({ required: true }),
|
|
36
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
37
37
|
customFields: customFieldsField(),
|
|
38
38
|
},
|
|
39
39
|
});
|
|
@@ -42,7 +42,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
|
42
42
|
const propertyEntity = createEntity({
|
|
43
43
|
table: "read_t1_properties",
|
|
44
44
|
fields: {
|
|
45
|
-
name: createTextField({ required: true }),
|
|
45
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
46
46
|
customFields: customFieldsField(),
|
|
47
47
|
},
|
|
48
48
|
});
|
|
@@ -34,7 +34,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
|
34
34
|
const propertyEntity = createEntity({
|
|
35
35
|
table: "read_t15b_properties",
|
|
36
36
|
fields: {
|
|
37
|
-
name: createTextField({ required: true }),
|
|
37
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
38
38
|
customFields: customFieldsField(),
|
|
39
39
|
},
|
|
40
40
|
});
|
|
@@ -30,7 +30,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
|
30
30
|
const propertyEntity = createEntity({
|
|
31
31
|
table: "read_t15e_properties",
|
|
32
32
|
fields: {
|
|
33
|
-
name: createTextField({ required: true }),
|
|
33
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
34
34
|
customFields: customFieldsField(),
|
|
35
35
|
},
|
|
36
36
|
});
|
|
@@ -37,7 +37,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
|
37
37
|
const propertyEntity = createEntity({
|
|
38
38
|
table: "read_t15d_properties",
|
|
39
39
|
fields: {
|
|
40
|
-
name: createTextField({ required: true }),
|
|
40
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
41
41
|
customFields: customFieldsField(),
|
|
42
42
|
},
|
|
43
43
|
});
|
|
@@ -62,7 +62,7 @@ const PAST = (): Instant => getTemporal().Now.instant().subtract({ minutes: 1 })
|
|
|
62
62
|
const propertyEntity = createEntity({
|
|
63
63
|
table: "read_t15c_properties",
|
|
64
64
|
fields: {
|
|
65
|
-
name: createTextField({ required: true }),
|
|
65
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
66
66
|
customFields: customFieldsField(),
|
|
67
67
|
},
|
|
68
68
|
});
|
|
@@ -10,7 +10,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
|
10
10
|
const propertyEntity = createEntity({
|
|
11
11
|
table: "read_test_properties",
|
|
12
12
|
fields: {
|
|
13
|
-
name: createTextField({ required: true }),
|
|
13
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
14
14
|
customFields: customFieldsField(),
|
|
15
15
|
},
|
|
16
16
|
});
|