@cosmicdrift/kumiko-bundled-features 0.110.0 → 0.112.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 +6 -6
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +9 -10
- package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +7 -4
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +3 -4
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
- package/src/config/__tests__/config.integration.test.ts +7 -8
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +179 -0
- package/src/config/__tests__/inherited-redaction.integration.test.ts +4 -3
- package/src/config/feature.ts +14 -8
- package/src/config/handlers/reencrypt.job.ts +197 -0
- package/src/config/handlers/set.write.ts +2 -2
- package/src/config/index.ts +4 -3
- package/src/config/resolver.ts +34 -10
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +4 -3
- package/src/folders/__tests__/folders.integration.test.ts +3 -0
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +1 -0
- package/src/jobs/__tests__/job-system-user.integration.test.ts +4 -8
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +4 -3
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +3 -4
- package/src/readiness/__tests__/readiness.integration.test.ts +4 -3
- package/src/secrets/handlers/rotate.job.ts +77 -98
- package/src/secrets/secrets-context.ts +7 -38
- package/src/secrets/table.ts +6 -7
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +6 -4
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
- package/src/shared/chunked-entity-migration.ts +89 -0
- package/src/shared/index.ts +7 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +6 -5
- package/src/tenant/__tests__/tenant.integration.test.ts +8 -4
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +7 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +152 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +108 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -0
- package/src/user-data-rights/run-user-export.ts +43 -4
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +31 -9
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Art. 20 + encrypted entity fields: export hooks read raw rows past the
|
|
2
|
+
// executor's decrypt layer, so runUserExport must decrypt encrypted fields
|
|
3
|
+
// centrally — plaintext with a configured cipher, an explicit marker
|
|
4
|
+
// (never the raw ciphertext) without one.
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import {
|
|
9
|
+
buildEntityTable,
|
|
10
|
+
configureEntityFieldEncryption,
|
|
11
|
+
resetEntityFieldEncryptionCacheForTests,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import {
|
|
14
|
+
createEntity,
|
|
15
|
+
createRegistry,
|
|
16
|
+
createTextField,
|
|
17
|
+
defineFeature,
|
|
18
|
+
EXT_USER_DATA,
|
|
19
|
+
type UserDataExportHook,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
24
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
25
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
26
|
+
import { createSessionsFeature } from "../../sessions";
|
|
27
|
+
import { createUserFeature } from "../../user";
|
|
28
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
29
|
+
import { runUserExport } from "../run-user-export";
|
|
30
|
+
|
|
31
|
+
const TENANT = "00000000-0000-4000-8000-00000000000a";
|
|
32
|
+
const USER_ID = "cccccccc-cccc-4ccc-8ccc-000000000001";
|
|
33
|
+
const PLAINTEXT_NOTE = "my private vault note";
|
|
34
|
+
|
|
35
|
+
const cipher = createTestEnvelopeCipher();
|
|
36
|
+
|
|
37
|
+
const vaultNoteEntity = createEntity({
|
|
38
|
+
table: "read_vault_notes",
|
|
39
|
+
fields: {
|
|
40
|
+
ownerId: createTextField({ required: true }),
|
|
41
|
+
note: createTextField({ encrypted: true }),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const vaultNotesTableRef = buildEntityTable("vault-note", vaultNoteEntity);
|
|
46
|
+
|
|
47
|
+
// Export hook mirrors real hooks (folders-user-data pattern): raw select,
|
|
48
|
+
// no decrypt of its own.
|
|
49
|
+
const vaultExportHook: UserDataExportHook = async (ctx) => {
|
|
50
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, vaultNotesTableRef, {
|
|
51
|
+
ownerId: ctx.userId,
|
|
52
|
+
});
|
|
53
|
+
if (rows.length === 0) return null;
|
|
54
|
+
return { entity: "vault-note", rows };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const vaultFeature = defineFeature("vault", (r) => {
|
|
58
|
+
const entity = r.entity("vault-note", vaultNoteEntity);
|
|
59
|
+
r.useExtension(EXT_USER_DATA, entity, {
|
|
60
|
+
export: vaultExportHook,
|
|
61
|
+
delete: async () => undefined,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let stack: TestStack;
|
|
66
|
+
let registry: ReturnType<typeof createRegistry>;
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
stack = await setupTestStack({ features: [] });
|
|
70
|
+
// user-data-rights hosts EXT_USER_DATA — without it the vault feature's
|
|
71
|
+
// useExtension reference fails registry creation. Its hard requires
|
|
72
|
+
// (user/data-retention/compliance-profiles/sessions) come along.
|
|
73
|
+
registry = createRegistry([
|
|
74
|
+
createUserFeature(),
|
|
75
|
+
createDataRetentionFeature(),
|
|
76
|
+
createComplianceProfilesFeature(),
|
|
77
|
+
createSessionsFeature(),
|
|
78
|
+
createUserDataRightsFeature(),
|
|
79
|
+
vaultFeature,
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
await asRawClient(stack.db).unsafe(`
|
|
83
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
84
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
85
|
+
tenant_id UUID NOT NULL,
|
|
86
|
+
user_id TEXT NOT NULL,
|
|
87
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
88
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
89
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
90
|
+
inserted_by_id TEXT,
|
|
91
|
+
modified_by_id TEXT,
|
|
92
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
93
|
+
deleted_at TIMESTAMPTZ,
|
|
94
|
+
deleted_by_id TEXT,
|
|
95
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
96
|
+
UNIQUE(user_id, tenant_id)
|
|
97
|
+
)
|
|
98
|
+
`);
|
|
99
|
+
await asRawClient(stack.db).unsafe(
|
|
100
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id) VALUES ('${TENANT}', '${USER_ID}')
|
|
101
|
+
ON CONFLICT DO NOTHING`,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await asRawClient(stack.db).unsafe(`
|
|
105
|
+
CREATE TABLE IF NOT EXISTS read_vault_notes (
|
|
106
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
107
|
+
tenant_id UUID NOT NULL,
|
|
108
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
110
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
111
|
+
inserted_by_id TEXT,
|
|
112
|
+
modified_by_id TEXT,
|
|
113
|
+
owner_id TEXT NOT NULL,
|
|
114
|
+
note TEXT
|
|
115
|
+
)
|
|
116
|
+
`);
|
|
117
|
+
const storedNote = await cipher.encrypt(PLAINTEXT_NOTE);
|
|
118
|
+
await asRawClient(stack.db).unsafe(
|
|
119
|
+
`INSERT INTO read_vault_notes (tenant_id, owner_id, note) VALUES ('${TENANT}', '${USER_ID}', '${storedNote.replaceAll("'", "''")}')`,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
resetEntityFieldEncryptionCacheForTests();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterAll(async () => {
|
|
128
|
+
await stack.cleanup();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
async function exportedNote(): Promise<unknown> {
|
|
132
|
+
const bundle = await runUserExport({
|
|
133
|
+
db: stack.db,
|
|
134
|
+
registry,
|
|
135
|
+
userId: USER_ID,
|
|
136
|
+
now: getTemporal().Now.instant(),
|
|
137
|
+
});
|
|
138
|
+
const snippet = bundle.tenants.flatMap((t) => t.entities).find((e) => e.entity === "vault-note");
|
|
139
|
+
return snippet?.rows[0]?.["note"];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe("user export with encrypted entity fields", () => {
|
|
143
|
+
test("decrypts encrypted fields when the cipher is configured", async () => {
|
|
144
|
+
configureEntityFieldEncryption(cipher);
|
|
145
|
+
expect(await exportedNote()).toBe(PLAINTEXT_NOTE);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("exports an explicit marker — never ciphertext — without a cipher", async () => {
|
|
149
|
+
const value = await exportedNote();
|
|
150
|
+
expect(value).toBe("[encrypted:unavailable]");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// A forget delete-hook receives the app registry so it can cascade custom child
|
|
2
|
+
// projections for the executor's `<entity>.forgotten` event (runProjectionsForEvent).
|
|
3
|
+
// executor.forget purges only the entity's OWN projection, and the forget pipeline
|
|
4
|
+
// is a job — not a dispatched command — so the dispatcher's post-command projection
|
|
5
|
+
// pass never fires. Without registry in the hook ctx the cascade is unreachable and
|
|
6
|
+
// child read-model rows (m:n joins, per-parent detail projections) are orphaned on
|
|
7
|
+
// live forget (a DSGVO gap). This pins that the plumbing delivers the real,
|
|
8
|
+
// functional registry instance; the end-to-end cascade is covered consumer-side.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
|
|
11
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
|
+
import {
|
|
13
|
+
createEntity,
|
|
14
|
+
createTextField,
|
|
15
|
+
defineFeature,
|
|
16
|
+
EXT_USER_DATA,
|
|
17
|
+
type Registry,
|
|
18
|
+
type UserDataDeleteHook,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
setupTestStack,
|
|
23
|
+
type TestStack,
|
|
24
|
+
unsafeCreateEntityTable,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
27
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
28
|
+
import { createSessionsFeature } from "../../sessions";
|
|
29
|
+
import { createUserFeature, userEntity } from "../../user";
|
|
30
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
31
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
32
|
+
import {
|
|
33
|
+
createForgetSeeders,
|
|
34
|
+
nowInstant,
|
|
35
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
36
|
+
TENANT_SYSTEM,
|
|
37
|
+
} from "./forget-test-helpers";
|
|
38
|
+
|
|
39
|
+
const PROBE = "forget-registry-probe";
|
|
40
|
+
let capturedRegistry: Registry | undefined;
|
|
41
|
+
|
|
42
|
+
const captureHook: UserDataDeleteHook = async (ctx) => {
|
|
43
|
+
capturedRegistry = ctx.registry;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const probeEntity = createEntity({
|
|
47
|
+
table: `read_${PROBE.replace(/-/g, "_")}`,
|
|
48
|
+
fields: { name: createTextField({ required: true }) },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const probeFeature = defineFeature(PROBE, (r) => {
|
|
52
|
+
r.entity(PROBE, probeEntity);
|
|
53
|
+
r.useExtension(EXT_USER_DATA, PROBE, {
|
|
54
|
+
export: async () => null,
|
|
55
|
+
delete: captureHook,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000b1";
|
|
60
|
+
let stack: TestStack;
|
|
61
|
+
const seed = (db: unknown) =>
|
|
62
|
+
// biome-ignore lint/suspicious/noExplicitAny: dummy writer; this seeder never writes binaries.
|
|
63
|
+
createForgetSeeders(db as any, { write: async () => {} });
|
|
64
|
+
|
|
65
|
+
beforeAll(async () => {
|
|
66
|
+
stack = await setupTestStack({
|
|
67
|
+
features: [
|
|
68
|
+
createUserFeature(),
|
|
69
|
+
createSessionsFeature(),
|
|
70
|
+
createDataRetentionFeature(),
|
|
71
|
+
createComplianceProfilesFeature(),
|
|
72
|
+
createUserDataRightsFeature(),
|
|
73
|
+
probeFeature,
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
77
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
78
|
+
await unsafeCreateEntityTable(stack.db, probeEntity);
|
|
79
|
+
await createEventsTable(stack.db);
|
|
80
|
+
await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await stack.cleanup();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
capturedRegistry = undefined;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("forget delete-hook receives the app registry instance", async () => {
|
|
92
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
93
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT_SYSTEM);
|
|
94
|
+
|
|
95
|
+
const result = await runForgetCleanup({
|
|
96
|
+
db: stack.db,
|
|
97
|
+
registry: stack.registry,
|
|
98
|
+
now: nowInstant(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.errors, JSON.stringify(result.errors)).toHaveLength(0);
|
|
102
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
103
|
+
// The hook ran and got the exact registry passed to runForgetCleanup — not
|
|
104
|
+
// undefined, not a stand-in — so runProjectionsForEvent(event, ctx.registry, db)
|
|
105
|
+
// reaches the real projections.
|
|
106
|
+
expect(capturedRegistry).toBe(stack.registry);
|
|
107
|
+
expect(capturedRegistry?.getProjectionsForSource(PROBE)).toBeDefined();
|
|
108
|
+
});
|
package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
15
|
import { randomBytes } from "node:crypto";
|
|
16
16
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
18
17
|
import {
|
|
19
18
|
createRegistry,
|
|
20
19
|
type Registry,
|
|
@@ -40,6 +39,7 @@ import {
|
|
|
40
39
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
41
40
|
import {
|
|
42
41
|
createLateBoundHolder,
|
|
42
|
+
createTestEnvelopeCipher,
|
|
43
43
|
resetTestTables,
|
|
44
44
|
updateRows,
|
|
45
45
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
@@ -97,8 +97,8 @@ function buildFeatures() {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
beforeAll(async () => {
|
|
100
|
-
const encryption =
|
|
101
|
-
const resolver = createConfigResolver({ encryption });
|
|
100
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
101
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
102
102
|
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
103
103
|
|
|
104
104
|
stack = await setupTestStack({
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
14
|
import { randomBytes } from "node:crypto";
|
|
15
15
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
17
16
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
17
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
19
18
|
import {
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
26
|
import {
|
|
28
27
|
createLateBoundHolder,
|
|
28
|
+
createTestEnvelopeCipher,
|
|
29
29
|
resetTestTables,
|
|
30
30
|
updateRows,
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
@@ -65,8 +65,8 @@ const ALICE_EMAIL = "alice.restrict@example.com";
|
|
|
65
65
|
const ALICE_PW = "alice-pw-long-enough";
|
|
66
66
|
|
|
67
67
|
beforeAll(async () => {
|
|
68
|
-
const encryption =
|
|
69
|
-
const resolver = createConfigResolver({ encryption });
|
|
68
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
69
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
70
70
|
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
71
71
|
|
|
72
72
|
stack = await setupTestStack({
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
|
|
23
23
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
24
24
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
25
|
+
import {
|
|
26
|
+
collectEncryptedFieldNames,
|
|
27
|
+
configuredEntityFieldEncryption,
|
|
28
|
+
decryptEntityFieldValues,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
25
30
|
import {
|
|
26
31
|
EXT_USER_DATA,
|
|
27
32
|
type Registry,
|
|
@@ -137,8 +142,9 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
137
142
|
for (const tenantId of tenantList) {
|
|
138
143
|
const entities: UserDataExportSnippet[] = [];
|
|
139
144
|
for (const entry of hookEntries) {
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
145
|
+
const rawSnippet = await entry.exportHook({ db, registry, tenantId, userId });
|
|
146
|
+
if (rawSnippet === null) continue;
|
|
147
|
+
const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
|
|
142
148
|
entities.push(snippet);
|
|
143
149
|
if (snippet.fileRefs) {
|
|
144
150
|
for (const ref of snippet.fileRefs) {
|
|
@@ -169,12 +175,14 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
169
175
|
if (tenantList.length === 0 && hookEntries.length > 0) {
|
|
170
176
|
const orphanEntities: UserDataExportSnippet[] = [];
|
|
171
177
|
for (const entry of hookEntries) {
|
|
172
|
-
const
|
|
178
|
+
const rawSnippet = await entry.exportHook({
|
|
173
179
|
db,
|
|
180
|
+
registry,
|
|
174
181
|
tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
|
|
175
182
|
userId,
|
|
176
183
|
});
|
|
177
|
-
if (
|
|
184
|
+
if (rawSnippet === null) continue;
|
|
185
|
+
const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
|
|
178
186
|
orphanEntities.push(snippet);
|
|
179
187
|
if (snippet.fileRefs) {
|
|
180
188
|
for (const ref of snippet.fileRefs) {
|
|
@@ -203,6 +211,37 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
203
211
|
};
|
|
204
212
|
}
|
|
205
213
|
|
|
214
|
+
// Art. 20 verlangt die DATEN, nicht deren Ciphertext: Export-Hooks lesen
|
|
215
|
+
// raw rows an der Executor-Decrypt-Schicht vorbei, darum entschluesselt
|
|
216
|
+
// dieser zentrale Pass encrypted entity fields nach jedem Hook. Ohne
|
|
217
|
+
// konfigurierten Cipher wird ein expliziter Marker exportiert statt den
|
|
218
|
+
// base64-Blob als "Wert" auszuliefern (leak-by-confusion).
|
|
219
|
+
const ENCRYPTED_UNAVAILABLE = "[encrypted:unavailable]";
|
|
220
|
+
|
|
221
|
+
async function decryptSnippetFields(
|
|
222
|
+
registry: Registry,
|
|
223
|
+
hookEntityName: string,
|
|
224
|
+
snippet: UserDataExportSnippet,
|
|
225
|
+
): Promise<UserDataExportSnippet> {
|
|
226
|
+
const entity = registry.getEntity(snippet.entity) ?? registry.getEntity(hookEntityName);
|
|
227
|
+
if (!entity) return snippet;
|
|
228
|
+
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
229
|
+
if (encryptedFields.size === 0) return snippet;
|
|
230
|
+
|
|
231
|
+
const cipher = configuredEntityFieldEncryption();
|
|
232
|
+
const rows = await Promise.all(
|
|
233
|
+
snippet.rows.map(async (row) => {
|
|
234
|
+
if (cipher) return decryptEntityFieldValues(row, encryptedFields, cipher);
|
|
235
|
+
const out = { ...row };
|
|
236
|
+
for (const name of encryptedFields) {
|
|
237
|
+
if (typeof out[name] === "string") out[name] = ENCRYPTED_UNAVAILABLE;
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
return { ...snippet, rows };
|
|
243
|
+
}
|
|
244
|
+
|
|
206
245
|
// Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
|
|
207
246
|
// Pattern in run-forget-cleanup.ts — RFC4122-Null-UUID. Tenant-scoped
|
|
208
247
|
// Hooks finden hier nichts (no-op).
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -162,6 +162,7 @@ describe("S2.H1 :: userExportHook", () => {
|
|
|
162
162
|
|
|
163
163
|
const result = await userExportHook({
|
|
164
164
|
db: stack.db,
|
|
165
|
+
registry: stack.registry,
|
|
165
166
|
tenantId: TENANT_A,
|
|
166
167
|
userId: uuid(1001),
|
|
167
168
|
});
|
|
@@ -182,6 +183,7 @@ describe("S2.H1 :: userExportHook", () => {
|
|
|
182
183
|
test("returns null wenn User nicht existiert", async () => {
|
|
183
184
|
const result = await userExportHook({
|
|
184
185
|
db: stack.db,
|
|
186
|
+
registry: stack.registry,
|
|
185
187
|
tenantId: TENANT_A,
|
|
186
188
|
userId: uuid(1002),
|
|
187
189
|
});
|
|
@@ -193,7 +195,10 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
193
195
|
test('strategy="delete" → softDelete + email/displayName anonymisiert + status=deleted', async () => {
|
|
194
196
|
await seedUser(uuid(1003));
|
|
195
197
|
|
|
196
|
-
await userDeleteHook(
|
|
198
|
+
await userDeleteHook(
|
|
199
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1003) },
|
|
200
|
+
"delete",
|
|
201
|
+
);
|
|
197
202
|
|
|
198
203
|
const row = await fetchUser(uuid(1003));
|
|
199
204
|
expect(row).not.toBeNull();
|
|
@@ -209,7 +214,10 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
209
214
|
test('strategy="anonymize" → email/displayName anonymisiert aber status bleibt active', async () => {
|
|
210
215
|
await seedUser(uuid(1004));
|
|
211
216
|
|
|
212
|
-
await userDeleteHook(
|
|
217
|
+
await userDeleteHook(
|
|
218
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1004) },
|
|
219
|
+
"anonymize",
|
|
220
|
+
);
|
|
213
221
|
|
|
214
222
|
const row = await fetchUser(uuid(1004));
|
|
215
223
|
if (!row) throw new Error("row should exist");
|
|
@@ -222,13 +230,19 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
222
230
|
test("idempotent: zweiter delete-Call crasht nicht UND State bleibt korrekt deleted", async () => {
|
|
223
231
|
await seedUser(uuid(1005));
|
|
224
232
|
|
|
225
|
-
await userDeleteHook(
|
|
233
|
+
await userDeleteHook(
|
|
234
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
|
|
235
|
+
"delete",
|
|
236
|
+
);
|
|
226
237
|
const afterFirst = await fetchUser(uuid(1005));
|
|
227
238
|
if (!afterFirst) throw new Error("user should exist after first delete");
|
|
228
239
|
|
|
229
240
|
// Zweiter Call: kein Crash + State unverändert
|
|
230
241
|
await expect(
|
|
231
|
-
userDeleteHook(
|
|
242
|
+
userDeleteHook(
|
|
243
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
|
|
244
|
+
"delete",
|
|
245
|
+
),
|
|
232
246
|
).resolves.toBeUndefined();
|
|
233
247
|
|
|
234
248
|
// State-Verifikation (S2.H1+H2-Audit N3): Row weiterhin deleted,
|
|
@@ -249,6 +263,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
|
|
|
249
263
|
|
|
250
264
|
const result = await fileRefExportHook({
|
|
251
265
|
db: stack.db,
|
|
266
|
+
registry: stack.registry,
|
|
252
267
|
tenantId: TENANT_A,
|
|
253
268
|
userId: "user-files-1",
|
|
254
269
|
});
|
|
@@ -263,6 +278,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
|
|
|
263
278
|
test("returns null wenn User keine Files hat", async () => {
|
|
264
279
|
const result = await fileRefExportHook({
|
|
265
280
|
db: stack.db,
|
|
281
|
+
registry: stack.registry,
|
|
266
282
|
tenantId: TENANT_A,
|
|
267
283
|
userId: "ghost-user-no-files",
|
|
268
284
|
});
|
|
@@ -276,7 +292,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
276
292
|
await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
|
|
277
293
|
|
|
278
294
|
await fileRefDeleteHook(
|
|
279
|
-
{ db: stack.db, tenantId: TENANT_A, userId: "user-delete-files" },
|
|
295
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-delete-files" },
|
|
280
296
|
"delete",
|
|
281
297
|
);
|
|
282
298
|
|
|
@@ -288,7 +304,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
288
304
|
await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
|
|
289
305
|
|
|
290
306
|
await fileRefDeleteHook(
|
|
291
|
-
{ db: stack.db, tenantId: TENANT_A, userId: "user-anon-files" },
|
|
307
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-anon-files" },
|
|
292
308
|
"anonymize",
|
|
293
309
|
);
|
|
294
310
|
|
|
@@ -305,7 +321,10 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
305
321
|
await seedFileRef(uuid(302), TENANT_B, "shared-user", "tenantB.pdf");
|
|
306
322
|
|
|
307
323
|
// Tenant A loescht alle Files von "shared-user"
|
|
308
|
-
await fileRefDeleteHook(
|
|
324
|
+
await fileRefDeleteHook(
|
|
325
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "shared-user" },
|
|
326
|
+
"delete",
|
|
327
|
+
);
|
|
309
328
|
|
|
310
329
|
const aRemaining = await fetchFileRefs(TENANT_A, "shared-user");
|
|
311
330
|
const bRemaining = await fetchFileRefs(TENANT_B, "shared-user");
|
|
@@ -319,7 +338,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
319
338
|
await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
|
|
320
339
|
|
|
321
340
|
await fileRefDeleteHook(
|
|
322
|
-
{ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" },
|
|
341
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
|
|
323
342
|
"delete",
|
|
324
343
|
);
|
|
325
344
|
const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
|
|
@@ -327,7 +346,10 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
327
346
|
|
|
328
347
|
// Zweiter Call: kein Crash + State weiter 0 Files
|
|
329
348
|
await expect(
|
|
330
|
-
fileRefDeleteHook(
|
|
349
|
+
fileRefDeleteHook(
|
|
350
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
|
|
351
|
+
"delete",
|
|
352
|
+
),
|
|
331
353
|
).resolves.toBeUndefined();
|
|
332
354
|
const afterSecond = await fetchFileRefs(TENANT_A, "user-idem-files");
|
|
333
355
|
expect(afterSecond).toHaveLength(0);
|