@cosmicdrift/kumiko-bundled-features 0.109.0 → 0.111.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__/feature-options.test.ts +33 -0
- 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/auth-email-password/constants.ts +4 -0
- package/src/auth-email-password/feature.ts +8 -4
- 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/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__/auto-revoke-binding.test.ts +71 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +6 -4
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
- package/src/sessions/feature.ts +51 -18
- package/src/sessions/index.ts +2 -2
- 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/__tests__/user.integration.test.ts +26 -0
- package/src/user/handlers/find-for-auth.query.ts +7 -5
- 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__/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-user-export.ts +42 -4
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type SubscriptionProviderPlugin,
|
|
21
21
|
subscriptionAggregateId,
|
|
22
22
|
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
23
|
-
import {
|
|
23
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
24
24
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
25
25
|
import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
26
26
|
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
testTenantId,
|
|
32
32
|
unsafePushTables,
|
|
33
33
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
34
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
34
35
|
import { Hono } from "hono";
|
|
35
36
|
import Stripe from "stripe";
|
|
36
37
|
import { configValuesTable, createConfigFeature } from "../../config";
|
|
@@ -79,8 +80,8 @@ beforeAll(async () => {
|
|
|
79
80
|
priceToTier: PRICE_TO_TIER,
|
|
80
81
|
});
|
|
81
82
|
|
|
82
|
-
const encryption =
|
|
83
|
-
const resolver = createConfigResolver({ encryption });
|
|
83
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
84
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
84
85
|
const masterKeyProvider = createEnvMasterKeyProvider({
|
|
85
86
|
env: {
|
|
86
87
|
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
@@ -470,8 +471,8 @@ describe("scenario 6: billing-live gate end-to-end (#104)", () => {
|
|
|
470
471
|
|
|
471
472
|
beforeAll(async () => {
|
|
472
473
|
const stripeFeature = createSubscriptionStripeFeature({ priceToTier: PRICE_TO_TIER });
|
|
473
|
-
const encryption =
|
|
474
|
-
const resolver = createConfigResolver({ encryption });
|
|
474
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
475
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
475
476
|
const masterKeyProvider = createEnvMasterKeyProvider({
|
|
476
477
|
env: {
|
|
477
478
|
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
5
|
import {
|
|
6
6
|
createTestUser,
|
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
unsafeCreateEntityTable,
|
|
11
11
|
unsafePushTables,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
createTestEnvelopeCipher,
|
|
15
|
+
expectErrorIncludes,
|
|
16
|
+
rolesOf,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
14
18
|
import { createConfigAccessor, createConfigFeature } from "../../config";
|
|
15
19
|
import { ConfigHandlers, ConfigQueries } from "../../config/constants";
|
|
16
20
|
import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
|
|
@@ -33,8 +37,8 @@ const tenantFeature = createTenantFeature();
|
|
|
33
37
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
34
38
|
|
|
35
39
|
beforeAll(async () => {
|
|
36
|
-
const encryption =
|
|
37
|
-
resolver = createConfigResolver({ encryption });
|
|
40
|
+
const encryption = createTestEnvelopeCipher(testEncryptionKey);
|
|
41
|
+
resolver = createConfigResolver({ cipher: encryption });
|
|
38
42
|
|
|
39
43
|
stack = await setupTestStack({
|
|
40
44
|
features: [configFeature, tenantFeature],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
5
|
import {
|
|
6
6
|
createTestUser,
|
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
unsafeCreateEntityTable,
|
|
12
12
|
unsafePushTables,
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createTestEnvelopeCipher,
|
|
16
|
+
expectErrorIncludes,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
15
18
|
import { createConfigFeature } from "../../config";
|
|
16
19
|
import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
|
|
17
20
|
import { configValuesTable } from "../../config/table";
|
|
@@ -38,8 +41,8 @@ const tenantFeature = createTenantFeature();
|
|
|
38
41
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
39
42
|
|
|
40
43
|
beforeAll(async () => {
|
|
41
|
-
const encryption =
|
|
42
|
-
resolver = createConfigResolver({ encryption });
|
|
44
|
+
const encryption = createTestEnvelopeCipher(testEncryptionKey);
|
|
45
|
+
resolver = createConfigResolver({ cipher: encryption });
|
|
43
46
|
|
|
44
47
|
stack = await setupTestStack({
|
|
45
48
|
features: [configFeature, tenantFeature, tierEngineFeature],
|
|
@@ -109,6 +109,32 @@ describe("scenario 2: field-level read access", () => {
|
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
// --- find-for-auth is system-only: it returns the raw row incl. passwordHash,
|
|
113
|
+
// and every query handler is reachable via POST /api/query — so even a
|
|
114
|
+
// SystemAdmin (assignable app role) must be denied over HTTP. ---
|
|
115
|
+
|
|
116
|
+
describe("scenario 2b: find-for-auth is system-only", () => {
|
|
117
|
+
test("SystemAdmin over HTTP is denied — passwordHash must not leak", async () => {
|
|
118
|
+
await seedUser({
|
|
119
|
+
email: "auth-lookup@example.com",
|
|
120
|
+
displayName: "AuthLookup",
|
|
121
|
+
passwordHash: "must-never-leave-the-server",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const res = await stack.http.queryWithHeaders(
|
|
125
|
+
UserQueries.findForAuth,
|
|
126
|
+
{ email: "auth-lookup@example.com" },
|
|
127
|
+
systemAdmin,
|
|
128
|
+
{},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
132
|
+
const body = await res.text();
|
|
133
|
+
expect(body).toContain("access_denied");
|
|
134
|
+
expect(body).not.toContain("must-never-leave-the-server");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
112
138
|
// --- Scenario 3: user edits own profile, email/passwordHash are system-locked ---
|
|
113
139
|
|
|
114
140
|
describe("scenario 3: self-update + field-level write access", () => {
|
|
@@ -7,10 +7,12 @@ import { userTable } from "../schema/user";
|
|
|
7
7
|
// by email OR id (exactly one, enforced by the schema). Used by the auth
|
|
8
8
|
// features via ctx.queryAs(systemUser, ...).
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// Access is system-only, NOT access.privileged: every query handler is
|
|
11
|
+
// reachable via POST /api/query, and "SystemAdmin" is an assignable app
|
|
12
|
+
// role — privileged would hand any SystemAdmin the raw passwordHash over
|
|
13
|
+
// HTTP. The "system" role can't be minted into a session (only
|
|
14
|
+
// createSystemUser() carries it), so system-only keeps this handler
|
|
15
|
+
// internal to ctx.queryAs(systemUser, ...) callers.
|
|
14
16
|
export const findForAuthQuery = defineQueryHandler({
|
|
15
17
|
name: "user:find-for-auth",
|
|
16
18
|
schema: z
|
|
@@ -24,7 +26,7 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
24
26
|
(v) => (v.email !== undefined) !== (v.id !== undefined),
|
|
25
27
|
{ message: "exactly one of email or id must be set" },
|
|
26
28
|
),
|
|
27
|
-
access: { roles: access.
|
|
29
|
+
access: { roles: access.system },
|
|
28
30
|
handler: async (query, ctx) => {
|
|
29
31
|
const where =
|
|
30
32
|
query.payload.email !== undefined
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
12
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
14
13
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
14
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
15
|
import {
|
|
@@ -25,7 +24,11 @@ import {
|
|
|
25
24
|
unsafeCreateEntityTable,
|
|
26
25
|
unsafePushTables,
|
|
27
26
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
createTestEnvelopeCipher,
|
|
29
|
+
resetTestTables,
|
|
30
|
+
updateRows,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
29
32
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
30
33
|
import {
|
|
31
34
|
createComplianceProfilesFeature,
|
|
@@ -100,8 +103,8 @@ const noSignedUrlProviderFeature = defineFeature("test-no-signed-url-provider",
|
|
|
100
103
|
});
|
|
101
104
|
|
|
102
105
|
beforeAll(async () => {
|
|
103
|
-
const encryption =
|
|
104
|
-
const resolver = createConfigResolver({ encryption });
|
|
106
|
+
const encryption = createTestEnvelopeCipher(testEncryptionKey);
|
|
107
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
105
108
|
|
|
106
109
|
stack = await setupTestStack({
|
|
107
110
|
features: [
|
|
@@ -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
|
+
});
|
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, 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,13 @@ 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,
|
|
174
180
|
tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
|
|
175
181
|
userId,
|
|
176
182
|
});
|
|
177
|
-
if (
|
|
183
|
+
if (rawSnippet === null) continue;
|
|
184
|
+
const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
|
|
178
185
|
orphanEntities.push(snippet);
|
|
179
186
|
if (snippet.fileRefs) {
|
|
180
187
|
for (const ref of snippet.fileRefs) {
|
|
@@ -203,6 +210,37 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
203
210
|
};
|
|
204
211
|
}
|
|
205
212
|
|
|
213
|
+
// Art. 20 verlangt die DATEN, nicht deren Ciphertext: Export-Hooks lesen
|
|
214
|
+
// raw rows an der Executor-Decrypt-Schicht vorbei, darum entschluesselt
|
|
215
|
+
// dieser zentrale Pass encrypted entity fields nach jedem Hook. Ohne
|
|
216
|
+
// konfigurierten Cipher wird ein expliziter Marker exportiert statt den
|
|
217
|
+
// base64-Blob als "Wert" auszuliefern (leak-by-confusion).
|
|
218
|
+
const ENCRYPTED_UNAVAILABLE = "[encrypted:unavailable]";
|
|
219
|
+
|
|
220
|
+
async function decryptSnippetFields(
|
|
221
|
+
registry: Registry,
|
|
222
|
+
hookEntityName: string,
|
|
223
|
+
snippet: UserDataExportSnippet,
|
|
224
|
+
): Promise<UserDataExportSnippet> {
|
|
225
|
+
const entity = registry.getEntity(snippet.entity) ?? registry.getEntity(hookEntityName);
|
|
226
|
+
if (!entity) return snippet;
|
|
227
|
+
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
228
|
+
if (encryptedFields.size === 0) return snippet;
|
|
229
|
+
|
|
230
|
+
const cipher = configuredEntityFieldEncryption();
|
|
231
|
+
const rows = await Promise.all(
|
|
232
|
+
snippet.rows.map(async (row) => {
|
|
233
|
+
if (cipher) return decryptEntityFieldValues(row, encryptedFields, cipher);
|
|
234
|
+
const out = { ...row };
|
|
235
|
+
for (const name of encryptedFields) {
|
|
236
|
+
if (typeof out[name] === "string") out[name] = ENCRYPTED_UNAVAILABLE;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
return { ...snippet, rows };
|
|
242
|
+
}
|
|
243
|
+
|
|
206
244
|
// Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
|
|
207
245
|
// Pattern in run-forget-cleanup.ts — RFC4122-Null-UUID. Tenant-scoped
|
|
208
246
|
// Hooks finden hier nichts (no-op).
|