@cosmicdrift/kumiko-bundled-features 0.278.0 → 0.281.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
- package/src/auth-email-password/handlers/confirm-token-flow.ts +9 -0
- package/src/auth-email-password/handlers/login.write.ts +12 -17
- package/src/auth-mfa/mfa-code-verifier.ts +9 -1
- package/src/auth-mfa/mfa-status-checker.ts +5 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
- package/src/auth-mfa-user-data/hooks.ts +4 -6
- package/src/crypto-shredding/handlers/forget-subject.write.ts +24 -23
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
- package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +5 -10
- package/src/custom-fields/db/queries/field-access.ts +3 -5
- package/src/custom-fields/db/queries/quota.ts +6 -5
- package/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
- package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -1
- package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/src/custom-fields/lib/field-access.ts +5 -5
- package/src/custom-fields/wire-user-data-rights.ts +40 -24
- package/src/data-retention/resolve-for-tenant.ts +18 -6
- package/src/delivery/db/queries/preferences.ts +1 -0
- package/src/files-tenant-data/hooks.ts +3 -5
- package/src/folders/__tests__/folders.integration.test.ts +4 -3
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
- package/src/folders-user-data/hooks.ts +5 -10
- package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
- package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
- package/src/form-draft-user-data/hooks.ts +3 -6
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
- package/src/notes-history-user-data/hooks.ts +3 -4
- package/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/src/template-resolver-user-data/hooks.ts +1 -2
- package/src/tenant-lifecycle/feature.ts +2 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
- package/src/tenant-lifecycle/stages.ts +26 -2
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
- package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
- package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
- package/src/user-data-rights/feature.ts +4 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +19 -2
- package/src/user-data-rights/handlers/deletion-grace-period.ts +19 -27
- package/src/user-data-rights/handlers/request-deletion.write.ts +15 -2
- package/src/user-data-rights/run-export-jobs.ts +16 -1
- package/src/user-data-rights/run-forget-cleanup.ts +44 -2
- package/src/user-data-rights/run-user-export.ts +43 -3
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
|
@@ -7,15 +7,22 @@
|
|
|
7
7
|
// + `resolveRetentionPolicy`, also kein Drift-Risiko.
|
|
8
8
|
|
|
9
9
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
11
11
|
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
|
|
13
13
|
import type { RetentionPresetKey } from "./presets";
|
|
14
14
|
import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
|
|
15
15
|
import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
|
|
16
16
|
|
|
17
|
+
function isTenantDb(db: DbRunner | TenantDb): db is TenantDb {
|
|
18
|
+
return typeof (db as Partial<TenantDb>).unsafeRaw === "function";
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
export interface ResolveForTenantArgs {
|
|
18
|
-
|
|
22
|
+
// fw#2914 — a EXT_USER_DATA hook's ctx.db is a TenantDb (method-form);
|
|
23
|
+
// the cleanup cron's own db is still a raw DbRunner. Both are valid here —
|
|
24
|
+
// this lookup is a plain tenant-scoped read either way.
|
|
25
|
+
readonly db: DbRunner | TenantDb;
|
|
19
26
|
readonly registry: Registry;
|
|
20
27
|
readonly tenantId: TenantId;
|
|
21
28
|
readonly entityName: string;
|
|
@@ -43,10 +50,15 @@ export async function resolveRetentionPolicyForTenant(
|
|
|
43
50
|
const overrideRow =
|
|
44
51
|
args.preloadedOverride !== undefined
|
|
45
52
|
? args.preloadedOverride
|
|
46
|
-
: (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
: isTenantDb(args.db)
|
|
54
|
+
? ((await args.db.fetchOne<{ config: string | null }>(tenantRetentionOverrideTable, {
|
|
55
|
+
tenantId: args.tenantId,
|
|
56
|
+
entityName: args.entityName,
|
|
57
|
+
})) ?? null)
|
|
58
|
+
: ((await fetchOne(args.db, tenantRetentionOverrideTable, {
|
|
59
|
+
tenantId: args.tenantId,
|
|
60
|
+
entityName: args.entityName,
|
|
61
|
+
})) as { config: string | null } | null); // @cast-boundary db-runner
|
|
50
62
|
|
|
51
63
|
const tenantOverride = parseRetentionOverrideOrNull(
|
|
52
64
|
overrideRow?.config ?? null,
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
// cross into another tenant's keys (the provider's own list() prefix
|
|
19
19
|
// already scopes it).
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
23
22
|
import {
|
|
24
23
|
createSystemUser,
|
|
25
24
|
type StorageProviderDestroyTenantHook,
|
|
@@ -34,13 +33,12 @@ import {
|
|
|
34
33
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
35
34
|
|
|
36
35
|
export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
|
|
37
|
-
const rows = await selectMany<{ id: string }>(
|
|
36
|
+
const rows = await ctx.db.selectMany<{ id: string }>(fileRefsTable, {
|
|
38
37
|
tenantId: ctx.tenantId,
|
|
39
38
|
});
|
|
40
39
|
const user = createSystemUser(ctx.tenantId);
|
|
41
|
-
const db = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
42
40
|
for (const row of rows) {
|
|
43
|
-
const result = await crud.forget({ id: row.id }, user, db);
|
|
41
|
+
const result = await crud.forget({ id: row.id }, user, ctx.db);
|
|
44
42
|
// Executor writes return {isSuccess:false} instead of throwing — a
|
|
45
43
|
// discarded result would report this destroy stage "succeeded" while the
|
|
46
44
|
// row (and its PII fileName) survives. Throw so the pipeline's
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
19
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
20
21
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
22
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
22
23
|
import {
|
|
@@ -402,7 +403,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
402
403
|
test("multi-user tenant: no-op, rows survive", async () => {
|
|
403
404
|
await seedOneFolderWithAssignment();
|
|
404
405
|
const ctx = {
|
|
405
|
-
db: stack.db,
|
|
406
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
406
407
|
registry: stack.registry,
|
|
407
408
|
tenantId: admin.tenantId,
|
|
408
409
|
userId: admin.id,
|
|
@@ -417,7 +418,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
417
418
|
test("anonymize strategy: no-op even on a single-user tenant", async () => {
|
|
418
419
|
await seedOneFolderWithAssignment();
|
|
419
420
|
const ctx = {
|
|
420
|
-
db: stack.db,
|
|
421
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
421
422
|
registry: stack.registry,
|
|
422
423
|
tenantId: admin.tenantId,
|
|
423
424
|
userId: admin.id,
|
|
@@ -432,7 +433,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
432
433
|
test("single-user tenant + delete: rows are purged", async () => {
|
|
433
434
|
await seedOneFolderWithAssignment();
|
|
434
435
|
const ctx = {
|
|
435
|
-
db: stack.db,
|
|
436
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
436
437
|
registry: stack.registry,
|
|
437
438
|
tenantId: admin.tenantId,
|
|
438
439
|
userId: admin.id,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
13
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
14
15
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
16
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
17
|
import {
|
|
@@ -87,7 +88,7 @@ describe("folderAssignmentExportHook", () => {
|
|
|
87
88
|
);
|
|
88
89
|
|
|
89
90
|
const snippet = await folderAssignmentExportHook({
|
|
90
|
-
db: stack.db,
|
|
91
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
91
92
|
registry: stack.registry,
|
|
92
93
|
tenantId: admin.tenantId,
|
|
93
94
|
userId: admin.id,
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
|
|
5
5
|
// (a folder name is tenant data, not per-user PII).
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import { createTenantDb, type EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
9
8
|
import {
|
|
10
9
|
createEntityExecutor,
|
|
11
10
|
createSystemUser,
|
|
@@ -27,9 +26,7 @@ const { table: folderAssignmentTable, executor: folderAssignmentExecutor } = cre
|
|
|
27
26
|
// all tenant folders in-app, so bundling them into the user's export is no new
|
|
28
27
|
// exposure — it gives the data subject the organisation of the loans they work with.
|
|
29
28
|
export const folderExportHook: UserDataExportHook = async (ctx) => {
|
|
30
|
-
const rows = await
|
|
31
|
-
tenantId: ctx.tenantId,
|
|
32
|
-
});
|
|
29
|
+
const rows = await ctx.db.selectMany(folderTable, { tenantId: ctx.tenantId });
|
|
33
30
|
if (rows.length === 0) return null;
|
|
34
31
|
return { entity: "folder", rows };
|
|
35
32
|
};
|
|
@@ -38,7 +35,7 @@ export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
|
|
|
38
35
|
// folderAssignmentEntity is softDelete: true — a cleared assignment
|
|
39
36
|
// (isDeleted: true) is a removed folder membership, not something the GDPR
|
|
40
37
|
// export should still surface as current data.
|
|
41
|
-
const rows = await
|
|
38
|
+
const rows = await ctx.db.selectMany(folderAssignmentTable, {
|
|
42
39
|
tenantId: ctx.tenantId,
|
|
43
40
|
isDeleted: false,
|
|
44
41
|
});
|
|
@@ -65,11 +62,9 @@ function tenantScopedDelete(
|
|
|
65
62
|
// eventless, so a projection rebuild resurrects the rows. Bounded — forget
|
|
66
63
|
// only fires for single-user tenants.
|
|
67
64
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
68
|
-
|
|
69
|
-
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
70
|
-
const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
|
|
65
|
+
const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
|
|
71
66
|
for (const row of rows) {
|
|
72
|
-
await executor.delete({ id: row.id }, systemUser,
|
|
67
|
+
await executor.delete({ id: row.id }, systemUser, ctx.db);
|
|
73
68
|
}
|
|
74
69
|
};
|
|
75
70
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
9
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
10
11
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
12
|
import {
|
|
12
13
|
createTestUser,
|
|
@@ -50,7 +51,12 @@ describe("formDraftDeleteHook — delete failure handling (#review-batch-fw-1)",
|
|
|
50
51
|
|
|
51
52
|
await expect(
|
|
52
53
|
formDraftDeleteHook(
|
|
53
|
-
{
|
|
54
|
+
{
|
|
55
|
+
db: createTenantDb(stack.db, bob.tenantId, "tenant"),
|
|
56
|
+
registry: stack.registry,
|
|
57
|
+
tenantId: bob.tenantId,
|
|
58
|
+
userId: bob.id,
|
|
59
|
+
},
|
|
54
60
|
"delete",
|
|
55
61
|
),
|
|
56
62
|
).resolves.toBeUndefined();
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
9
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
9
10
|
import {
|
|
10
11
|
createTestUser,
|
|
@@ -52,7 +53,7 @@ describe("formDraftExportHook", () => {
|
|
|
52
53
|
);
|
|
53
54
|
|
|
54
55
|
const snippet = await formDraftExportHook({
|
|
55
|
-
db: stack.db,
|
|
56
|
+
db: createTenantDb(stack.db, owner.tenantId, "tenant"),
|
|
56
57
|
registry: stack.registry,
|
|
57
58
|
tenantId: owner.tenantId,
|
|
58
59
|
userId: owner.id,
|
|
@@ -88,7 +89,7 @@ describe("formDraftExportHook", () => {
|
|
|
88
89
|
);
|
|
89
90
|
|
|
90
91
|
const snippet = await formDraftExportHook({
|
|
91
|
-
db: stack.db,
|
|
92
|
+
db: createTenantDb(stack.db, tenantATestTenantId, "tenant"),
|
|
92
93
|
registry: stack.registry,
|
|
93
94
|
tenantId: tenantATestTenantId,
|
|
94
95
|
userId: sameIdTenantA.id,
|
|
@@ -101,7 +102,7 @@ describe("formDraftExportHook", () => {
|
|
|
101
102
|
test("returns null when the user saved no drafts", async () => {
|
|
102
103
|
const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
|
|
103
104
|
const snippet = await formDraftExportHook({
|
|
104
|
-
db: stack.db,
|
|
105
|
+
db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
|
|
105
106
|
registry: stack.registry,
|
|
106
107
|
tenantId: lurker.tenantId,
|
|
107
108
|
userId: lurker.id,
|
|
@@ -126,7 +127,12 @@ describe("formDraftDeleteHook", () => {
|
|
|
126
127
|
);
|
|
127
128
|
|
|
128
129
|
await formDraftDeleteHook(
|
|
129
|
-
{
|
|
130
|
+
{
|
|
131
|
+
db: createTenantDb(stack.db, alice.tenantId, "tenant"),
|
|
132
|
+
registry: stack.registry,
|
|
133
|
+
tenantId: alice.tenantId,
|
|
134
|
+
userId: alice.id,
|
|
135
|
+
},
|
|
130
136
|
"delete",
|
|
131
137
|
);
|
|
132
138
|
|
|
@@ -138,7 +144,12 @@ describe("formDraftDeleteHook", () => {
|
|
|
138
144
|
const lurker = createTestUser({ id: 13, roles: ["TenantMember"] });
|
|
139
145
|
await expect(
|
|
140
146
|
formDraftDeleteHook(
|
|
141
|
-
{
|
|
147
|
+
{
|
|
148
|
+
db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
|
|
149
|
+
registry: stack.registry,
|
|
150
|
+
tenantId: lurker.tenantId,
|
|
151
|
+
userId: lurker.id,
|
|
152
|
+
},
|
|
142
153
|
"delete",
|
|
143
154
|
),
|
|
144
155
|
).resolves.toBeUndefined();
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
// consumers without the user-data-rights pipeline don't pull a hard
|
|
4
4
|
// dependency.
|
|
5
5
|
|
|
6
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
|
-
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
6
|
import {
|
|
9
7
|
createSystemUser,
|
|
10
8
|
type UserDataDeleteHook,
|
|
@@ -13,7 +11,7 @@ import {
|
|
|
13
11
|
import { formDraftExecutor, formDraftTable } from "../form-draft";
|
|
14
12
|
|
|
15
13
|
export const formDraftExportHook: UserDataExportHook = async (ctx) => {
|
|
16
|
-
const rows = await
|
|
14
|
+
const rows = await ctx.db.selectMany(formDraftTable, {
|
|
17
15
|
tenantId: ctx.tenantId,
|
|
18
16
|
ownerId: ctx.userId,
|
|
19
17
|
});
|
|
@@ -37,15 +35,14 @@ export const formDraftExportHook: UserDataExportHook = async (ctx) => {
|
|
|
37
35
|
// owners' drafts, tenant model doesn't matter here (unlike folders' single-
|
|
38
36
|
// user gating: a form-draft row always has exactly one owner already).
|
|
39
37
|
export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
40
|
-
const rows = await selectMany<{ id: string }>(
|
|
38
|
+
const rows = await ctx.db.selectMany<{ id: string }>(formDraftTable, {
|
|
41
39
|
tenantId: ctx.tenantId,
|
|
42
40
|
ownerId: ctx.userId,
|
|
43
41
|
});
|
|
44
42
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
45
|
-
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
46
43
|
const failures: string[] = [];
|
|
47
44
|
for (const row of rows) {
|
|
48
|
-
const result = await formDraftExecutor.delete({ id: row.id }, systemUser,
|
|
45
|
+
const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db);
|
|
49
46
|
if (!result.isSuccess) {
|
|
50
47
|
failures.push(`${row.id}: ${result.error.message}`);
|
|
51
48
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
9
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
10
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
11
|
import {
|
|
@@ -78,7 +79,7 @@ describe("noteEntryExportHook", () => {
|
|
|
78
79
|
);
|
|
79
80
|
|
|
80
81
|
const snippet = await noteEntryExportHook({
|
|
81
|
-
db: stack.db,
|
|
82
|
+
db: createTenantDb(stack.db, author.tenantId, "tenant"),
|
|
82
83
|
registry: stack.registry,
|
|
83
84
|
tenantId: author.tenantId,
|
|
84
85
|
userId: author.id,
|
|
@@ -95,7 +96,7 @@ describe("noteEntryExportHook", () => {
|
|
|
95
96
|
test("returns null when the user authored no notes", async () => {
|
|
96
97
|
const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
|
|
97
98
|
const snippet = await noteEntryExportHook({
|
|
98
|
-
db: stack.db,
|
|
99
|
+
db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
|
|
99
100
|
registry: stack.registry,
|
|
100
101
|
tenantId: lurker.tenantId,
|
|
101
102
|
userId: lurker.id,
|
|
@@ -107,7 +108,12 @@ describe("noteEntryExportHook", () => {
|
|
|
107
108
|
describe("noteEntryDeleteHook", () => {
|
|
108
109
|
test("is a no-op — resolves without throwing", async () => {
|
|
109
110
|
const result = await noteEntryDeleteHook(
|
|
110
|
-
{
|
|
111
|
+
{
|
|
112
|
+
db: createTenantDb(stack.db, author.tenantId, "tenant"),
|
|
113
|
+
registry: stack.registry,
|
|
114
|
+
tenantId: author.tenantId,
|
|
115
|
+
userId: author.id,
|
|
116
|
+
},
|
|
111
117
|
"delete",
|
|
112
118
|
);
|
|
113
119
|
expect(result).toBeUndefined();
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
|
|
5
5
|
// export-only, erasure via crypto-shredding.
|
|
6
6
|
|
|
7
|
-
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
7
|
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
9
8
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
9
|
import { resolveRetentionPolicyForTenant } from "../data-retention";
|
|
@@ -14,7 +13,7 @@ import { policyToStrategy } from "../user-data-rights";
|
|
|
14
13
|
// note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
|
|
15
14
|
// per-user content, so the export filters by authorId directly.
|
|
16
15
|
export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
|
|
17
|
-
const rows = await
|
|
16
|
+
const rows = await ctx.db.selectMany(noteEntryTable, {
|
|
18
17
|
authorId: ctx.userId,
|
|
19
18
|
});
|
|
20
19
|
if (rows.length === 0) return null;
|
|
@@ -55,7 +54,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
|
55
54
|
// skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
|
|
56
55
|
if (!kms) return;
|
|
57
56
|
|
|
58
|
-
const mentions = await selectMany<{ noteId: string }>(
|
|
57
|
+
const mentions = await ctx.db.selectMany<{ noteId: string }>(noteMentionTable, {
|
|
59
58
|
subjectId: ctx.userId,
|
|
60
59
|
});
|
|
61
60
|
// skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
|
|
@@ -63,7 +62,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
|
63
62
|
|
|
64
63
|
const noteIds = new Set(mentions.map((m) => m.noteId));
|
|
65
64
|
for (const noteId of noteIds) {
|
|
66
|
-
const note = await fetchOne<{ entityType: string }>(
|
|
65
|
+
const note = await ctx.db.fetchOne<{ entityType: string }>(noteEntryTable, { id: noteId });
|
|
67
66
|
// Defensive: append-only rows are never hard-deleted, so this shouldn't
|
|
68
67
|
// happen — but a missing host means no retention policy to consult.
|
|
69
68
|
if (!note) continue;
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// so apps without the user-data-rights pipeline don't pull a hard dependency.
|
|
4
4
|
// Mirrors notes-history-user-data: export-only, erasure via crypto-shredding.
|
|
5
5
|
|
|
6
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
6
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
7
|
import { userContentEntriesTable } from "../template-resolver";
|
|
9
8
|
|
|
@@ -11,7 +10,7 @@ import { userContentEntriesTable } from "../template-resolver";
|
|
|
11
10
|
// scoping comes from the tenant-scoped ctx.db; a user who belongs to two
|
|
12
11
|
// tenants gets each tenant's entries from that tenant's forget run.
|
|
13
12
|
export const userContentExportHook: UserDataExportHook = async (ctx) => {
|
|
14
|
-
const rows = await
|
|
13
|
+
const rows = await ctx.db.selectMany(userContentEntriesTable, {
|
|
15
14
|
ownerId: ctx.userId,
|
|
16
15
|
});
|
|
17
16
|
if (rows.length === 0) return null;
|
|
@@ -132,6 +132,8 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
|
|
|
132
132
|
now: T.Now.instant(),
|
|
133
133
|
log: (message) => ctx.log?.warn(message),
|
|
134
134
|
fileProviderResolver: ctx._fileProviderResolver,
|
|
135
|
+
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
136
|
+
actor: ctx.systemUser.id,
|
|
135
137
|
});
|
|
136
138
|
},
|
|
137
139
|
});
|
|
@@ -3,6 +3,7 @@ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
|
3
3
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import {
|
|
5
5
|
createSystemUser,
|
|
6
|
+
type EscapeHatchAuditSink,
|
|
6
7
|
type Registry,
|
|
7
8
|
type TenantId,
|
|
8
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -216,6 +217,8 @@ export async function runNextDestructionStage(args: {
|
|
|
216
217
|
readonly tenantId: TenantId;
|
|
217
218
|
readonly log?: (message: string) => void;
|
|
218
219
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
220
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
221
|
+
readonly actor?: string;
|
|
219
222
|
}): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
|
|
220
223
|
const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
|
|
221
224
|
const { completed, abandoned, attemptsByStage } = replayStageState(events);
|
|
@@ -238,6 +241,8 @@ export async function runNextDestructionStage(args: {
|
|
|
238
241
|
tenantId: args.tenantId,
|
|
239
242
|
log: args.log,
|
|
240
243
|
fileProviderResolver: args.fileProviderResolver,
|
|
244
|
+
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
245
|
+
actor: args.actor,
|
|
241
246
|
};
|
|
242
247
|
|
|
243
248
|
let version = lastEventVersion(events);
|
|
@@ -304,6 +309,8 @@ export async function runTenantDestructionSweep(args: {
|
|
|
304
309
|
readonly now?: Temporal.Instant;
|
|
305
310
|
readonly log?: (message: string) => void;
|
|
306
311
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
312
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
313
|
+
readonly actor?: string;
|
|
307
314
|
}): Promise<{ readonly triggered: number; readonly advanced: number }> {
|
|
308
315
|
const T = getTemporal();
|
|
309
316
|
const now = args.now ?? T.Now.instant();
|
|
@@ -353,6 +360,8 @@ export async function runTenantDestructionSweep(args: {
|
|
|
353
360
|
tenantId: row.id as TenantId,
|
|
354
361
|
log: args.log,
|
|
355
362
|
fileProviderResolver: args.fileProviderResolver,
|
|
363
|
+
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
364
|
+
actor: args.actor,
|
|
356
365
|
});
|
|
357
366
|
if (!result.error && !result.halted) advanced++;
|
|
358
367
|
} catch (err) {
|
|
@@ -7,15 +7,19 @@ import {
|
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
8
8
|
import {
|
|
9
9
|
createSystemUser,
|
|
10
|
+
type EscapeHatchAuditSink,
|
|
10
11
|
EXT_EXTERNAL_RESOURCE,
|
|
11
12
|
EXT_INFRA_RESOURCE,
|
|
12
13
|
EXT_SEARCH_ADAPTER,
|
|
13
14
|
EXT_STORAGE_PROVIDER,
|
|
14
15
|
EXT_TENANT_DATA,
|
|
16
|
+
extensionUsageEscapeHatchReason,
|
|
15
17
|
type Registry,
|
|
18
|
+
type TenantDataHookCtx,
|
|
16
19
|
type TenantId,
|
|
17
20
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
21
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
22
|
+
import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
19
23
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
20
24
|
import {
|
|
21
25
|
tenantEntity,
|
|
@@ -35,6 +39,11 @@ export type DestructionStageCtx = {
|
|
|
35
39
|
// "destroyTenant" hooks) — undefined when no file-provider is wired,
|
|
36
40
|
// which those hooks must treat as "nothing to clean up", not an error.
|
|
37
41
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
42
|
+
// fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
|
|
43
|
+
// systemUser.id); runTenantDataHooks uses them to attribute+audit any
|
|
44
|
+
// EXT_TENANT_DATA usage's declared escapeHatch.
|
|
45
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
46
|
+
readonly actor?: string;
|
|
38
47
|
};
|
|
39
48
|
|
|
40
49
|
export type DestructionStage = {
|
|
@@ -73,10 +82,25 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
|
|
|
73
82
|
const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
|
|
74
83
|
for (const usage of usages) {
|
|
75
84
|
const destroy = usage.options?.["destroy"] as
|
|
76
|
-
| ((
|
|
85
|
+
| ((hookCtx: TenantDataHookCtx) => Promise<void>)
|
|
77
86
|
| undefined;
|
|
78
87
|
if (!destroy) continue;
|
|
79
|
-
|
|
88
|
+
const reason = extensionUsageEscapeHatchReason(usage);
|
|
89
|
+
const report = createEscapeHatchReporter({
|
|
90
|
+
handler: `${EXT_TENANT_DATA}:${usage.entityName}`,
|
|
91
|
+
tenantId: ctx.tenantId,
|
|
92
|
+
actor: ctx.actor ?? "system",
|
|
93
|
+
sink: ctx.escapeHatchAuditSink,
|
|
94
|
+
});
|
|
95
|
+
const hookCtx: TenantDataHookCtx = {
|
|
96
|
+
db: createTenantDb(ctx.db, ctx.tenantId, "tenant", undefined, undefined, undefined, {
|
|
97
|
+
unsafeRaw: reason !== undefined ? { reason } : undefined,
|
|
98
|
+
report,
|
|
99
|
+
}),
|
|
100
|
+
registry: ctx.registry,
|
|
101
|
+
tenantId: ctx.tenantId,
|
|
102
|
+
};
|
|
103
|
+
await destroy(hookCtx);
|
|
80
104
|
}
|
|
81
105
|
}
|
|
82
106
|
|
|
@@ -19,6 +19,7 @@ import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
|
|
|
19
19
|
import {
|
|
20
20
|
defineFeature,
|
|
21
21
|
EXT_USER_DATA,
|
|
22
|
+
SYSTEM_TENANT_ID,
|
|
22
23
|
type UserDataDeleteHook,
|
|
23
24
|
type UserDataExportHook,
|
|
24
25
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -49,7 +50,7 @@ let stack: TestStack;
|
|
|
49
50
|
|
|
50
51
|
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
51
52
|
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
52
|
-
const TENANT_SYSTEM =
|
|
53
|
+
const TENANT_SYSTEM = SYSTEM_TENANT_ID;
|
|
53
54
|
|
|
54
55
|
function uuid(suffix: number): string {
|
|
55
56
|
return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
|
|
@@ -77,20 +78,10 @@ const testNotesTable = defineUnmanagedTable({
|
|
|
77
78
|
// Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
|
|
78
79
|
// etc.), die ueber EXT_USER_DATA sauber in die Pipeline integrieren.
|
|
79
80
|
const exportNotes: UserDataExportHook = async (ctx) => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
FROM test_notes
|
|
84
|
-
WHERE tenant_id = $1 AND author_id = $2
|
|
85
|
-
`,
|
|
86
|
-
[ctx.tenantId, ctx.userId],
|
|
81
|
+
const rows = await ctx.db.selectMany<{ id: string; title: string; body: string }>(
|
|
82
|
+
testNotesTable,
|
|
83
|
+
{ tenantId: ctx.tenantId, authorId: ctx.userId },
|
|
87
84
|
);
|
|
88
|
-
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
89
|
-
const rows = ((result as any).rows ?? result) as Array<{
|
|
90
|
-
id: string;
|
|
91
|
-
title: string;
|
|
92
|
-
body: string;
|
|
93
|
-
}>;
|
|
94
85
|
if (rows.length === 0) return null;
|
|
95
86
|
return {
|
|
96
87
|
entity: "note",
|