@cosmicdrift/kumiko-bundled-features 0.118.0 → 0.120.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 +7 -6
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +283 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +5 -1
- package/src/auth-email-password/handlers/invite-accept.write.ts +5 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +5 -1
- package/src/auth-email-password/handlers/token-request-handler.ts +4 -2
- package/src/channel-email/__tests__/pii-guard.test.ts +56 -0
- package/src/channel-email/email-channel.ts +2 -1
- package/src/channel-email/index.ts +1 -0
- package/src/channel-email/pii-guard.ts +36 -0
- package/src/config/table.ts +1 -1
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +148 -0
- package/src/crypto-shredding/constants.ts +5 -0
- package/src/crypto-shredding/feature.ts +18 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +91 -0
- package/src/crypto-shredding/index.ts +11 -0
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +171 -0
- package/src/delivery/feature.ts +5 -1
- package/src/delivery/handlers/log.query.ts +14 -1
- package/src/delivery/tables.ts +1 -1
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +120 -0
- package/src/jobs/feature.ts +6 -1
- package/src/jobs/handlers/detail.query.ts +6 -0
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/mail-foundation/feature.ts +5 -2
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +39 -1
- package/src/personal-access-tokens/feature.ts +2 -0
- package/src/personal-access-tokens/handlers/create.write.ts +21 -15
- package/src/personal-access-tokens/handlers/list.query.ts +12 -9
- package/src/personal-access-tokens/schema/api-token.ts +1 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +49 -0
- package/src/sessions/feature.ts +2 -0
- package/src/sessions/handlers/list.query.ts +12 -9
- package/src/sessions/handlers/mine.query.ts +11 -8
- package/src/sessions/schema/user-session.ts +4 -2
- package/src/sessions/session-callbacks.ts +19 -10
- package/src/shared/decrypt-stored-pii.ts +19 -0
- package/src/shared/encrypt-for-direct-write.ts +23 -0
- package/src/shared/index.ts +2 -0
- package/src/tenant/handlers/invitations.query.ts +10 -2
- package/src/tenant/invitation-table.ts +3 -2
- package/src/user/schema/user.ts +5 -2
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +143 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +79 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +73 -0
- package/src/user-data-rights/handlers/deletion-grace-period.ts +4 -1
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +3 -1
- package/src/user-data-rights/run-export-jobs.ts +5 -1
- package/src/user-data-rights/run-forget-cleanup.ts +51 -3
- package/src/user-data-rights/run-user-export.ts +39 -12
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +61 -1
- package/src/user-data-rights-defaults/feature.ts +21 -1
- package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +37 -0
- package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +35 -0
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +2 -4
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +6 -1
- package/src/user-data-rights-defaults/index.ts +5 -0
- package/src/user-profile/handlers/change-email.write.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.120.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>",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./audit": "./src/audit/index.ts",
|
|
22
22
|
"./compliance-profiles": "./src/compliance-profiles/index.ts",
|
|
23
23
|
"./config": "./src/config/index.ts",
|
|
24
|
+
"./crypto-shredding": "./src/crypto-shredding/index.ts",
|
|
24
25
|
"./config/web": "./src/config/web/index.ts",
|
|
25
26
|
"./data-retention": "./src/data-retention/index.ts",
|
|
26
27
|
"./readiness": "./src/readiness/index.ts",
|
|
@@ -95,11 +96,11 @@
|
|
|
95
96
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
96
97
|
},
|
|
97
98
|
"dependencies": {
|
|
98
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
99
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
99
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.120.0",
|
|
100
|
+
"@cosmicdrift/kumiko-framework": "0.120.0",
|
|
101
|
+
"@cosmicdrift/kumiko-headless": "0.120.0",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer": "0.120.0",
|
|
103
|
+
"@cosmicdrift/kumiko-renderer-web": "0.120.0",
|
|
103
104
|
"@mollie/api-client": "^4.5.0",
|
|
104
105
|
"@node-rs/argon2": "^2.0.2",
|
|
105
106
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Auth-Flows mit aktivem PII-KMS + Blind-Index (#818 PR 2): user.email und
|
|
2
|
+
// invitation.email liegen als Ciphertext in der DB. Login und alle drei
|
|
3
|
+
// Invite-Accept-Branches müssen über die generierte email_bidx-Spalte
|
|
4
|
+
// (Equality-Lookup) bzw. decryptStoredEmail (Vergleich/Weiterverwendung)
|
|
5
|
+
// unverändert funktionieren — vor diesem PR war jeder dieser Pfade mit
|
|
6
|
+
// KMS silently broken.
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
configureBlindIndexKey,
|
|
12
|
+
configurePiiSubjectKms,
|
|
13
|
+
decryptPiiFieldValues,
|
|
14
|
+
InMemoryKmsAdapter,
|
|
15
|
+
isPiiCiphertext,
|
|
16
|
+
resetBlindIndexKeyForTests,
|
|
17
|
+
resetPiiSubjectKmsForTests,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
19
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import {
|
|
21
|
+
setupTestStack,
|
|
22
|
+
type TestStack,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
|
+
unsafePushTables,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
27
|
+
import { createConfigFeature } from "../../config";
|
|
28
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
29
|
+
import { configValuesTable } from "../../config/table";
|
|
30
|
+
import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
|
|
31
|
+
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
32
|
+
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
33
|
+
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
34
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
35
|
+
import { createTenantFeature } from "../../tenant";
|
|
36
|
+
import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
|
|
37
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
38
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
39
|
+
import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
|
|
40
|
+
import { createUserFeature } from "../../user/feature";
|
|
41
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
42
|
+
import { AuthHandlers } from "../constants";
|
|
43
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
44
|
+
import { hashPassword } from "../password-hashing";
|
|
45
|
+
import { seedUser } from "../seeding";
|
|
46
|
+
|
|
47
|
+
const APP_ACCEPT_URL = "https://app.example.com/invite/accept";
|
|
48
|
+
const BOB_EMAIL = "bob.kms@example.com";
|
|
49
|
+
const BOB_PASSWORD = "bob-existing-pw-1234";
|
|
50
|
+
const CAROL_EMAIL = "carol.kms@example.com";
|
|
51
|
+
const CAROL_PASSWORD = "carol-new-pw-1234";
|
|
52
|
+
const BIDX_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
53
|
+
|
|
54
|
+
const emailTransport = createInMemoryTransport();
|
|
55
|
+
|
|
56
|
+
let stack: TestStack;
|
|
57
|
+
let kms: InMemoryKmsAdapter;
|
|
58
|
+
let aliceId: string;
|
|
59
|
+
let bobId: string;
|
|
60
|
+
let TENANT_A_ID: TenantId;
|
|
61
|
+
let TENANT_B_ID: TenantId;
|
|
62
|
+
|
|
63
|
+
function extractTokenFromMail(html: string): string {
|
|
64
|
+
const match = html.match(/[?&]token=([^&"'<\s]+)/);
|
|
65
|
+
if (!match?.[1]) throw new Error(`No token in invite mail html: ${html.slice(0, 200)}`);
|
|
66
|
+
return decodeURIComponent(match[1]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
beforeAll(async () => {
|
|
70
|
+
stack = await setupTestStack({
|
|
71
|
+
features: [
|
|
72
|
+
createConfigFeature(),
|
|
73
|
+
createUserFeature(),
|
|
74
|
+
createTenantFeature(),
|
|
75
|
+
createTemplateResolverFeature(),
|
|
76
|
+
createRendererFoundationFeature(),
|
|
77
|
+
createDeliveryFeature(),
|
|
78
|
+
createRendererSimpleFeature(),
|
|
79
|
+
createChannelEmailFeature({
|
|
80
|
+
transport: emailTransport,
|
|
81
|
+
renderer: simpleRenderer,
|
|
82
|
+
resolveEmail: async () => "unused@test.local",
|
|
83
|
+
}),
|
|
84
|
+
createAuthEmailPasswordFeature({
|
|
85
|
+
invite: { tokenTtlMinutes: 60, appUrl: APP_ACCEPT_URL },
|
|
86
|
+
}),
|
|
87
|
+
],
|
|
88
|
+
extraContext: (deps) => ({
|
|
89
|
+
...createDeliveryTestContext(deps),
|
|
90
|
+
configResolver: createConfigResolver(),
|
|
91
|
+
}),
|
|
92
|
+
authConfig: {
|
|
93
|
+
membershipQuery: "tenant:query:memberships",
|
|
94
|
+
loginHandler: AuthHandlers.login,
|
|
95
|
+
invite: {
|
|
96
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
97
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
98
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
104
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
105
|
+
await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
|
|
106
|
+
await unsafePushTables(stack.db, {
|
|
107
|
+
configValuesTable,
|
|
108
|
+
tenantMembershipsTable,
|
|
109
|
+
notificationPreferencesTable,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(async () => {
|
|
114
|
+
await stack.cleanup();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
beforeEach(async () => {
|
|
118
|
+
// KMS + bidx-Key MUESSEN vor den Seeds stehen — seedUser läuft über den
|
|
119
|
+
// Executor, die Rows sollen den verschlüsselten Prod-Zustand abbilden.
|
|
120
|
+
kms = new InMemoryKmsAdapter();
|
|
121
|
+
configurePiiSubjectKms(kms);
|
|
122
|
+
configureBlindIndexKey(BIDX_KEY);
|
|
123
|
+
|
|
124
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
125
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
126
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
|
|
127
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
128
|
+
emailTransport.sent.length = 0;
|
|
129
|
+
const allKeys = await stack.redis.redis.keys("invite:*");
|
|
130
|
+
if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
|
|
131
|
+
|
|
132
|
+
TENANT_A_ID = crypto.randomUUID() as TenantId;
|
|
133
|
+
TENANT_B_ID = crypto.randomUUID() as TenantId;
|
|
134
|
+
await seedTenant(stack.db, {
|
|
135
|
+
id: TENANT_A_ID,
|
|
136
|
+
key: `kms-a-${TENANT_A_ID.slice(0, 8)}`,
|
|
137
|
+
name: "KMS Tenant A",
|
|
138
|
+
});
|
|
139
|
+
await seedTenant(stack.db, {
|
|
140
|
+
id: TENANT_B_ID,
|
|
141
|
+
key: `kms-b-${TENANT_B_ID.slice(0, 8)}`,
|
|
142
|
+
name: "KMS Tenant B",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
({ id: aliceId } = await seedUser(stack.db, {
|
|
146
|
+
email: "alice.kms@example.com",
|
|
147
|
+
displayName: "Alice",
|
|
148
|
+
passwordHash: await hashPassword("alice-pw-1234"),
|
|
149
|
+
emailVerified: true,
|
|
150
|
+
}));
|
|
151
|
+
await seedTenantMembership(stack.db, {
|
|
152
|
+
userId: aliceId,
|
|
153
|
+
tenantId: TENANT_A_ID,
|
|
154
|
+
roles: ["Admin"],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
({ id: bobId } = await seedUser(stack.db, {
|
|
158
|
+
email: BOB_EMAIL,
|
|
159
|
+
displayName: "Bob",
|
|
160
|
+
passwordHash: await hashPassword(BOB_PASSWORD),
|
|
161
|
+
emailVerified: true,
|
|
162
|
+
}));
|
|
163
|
+
await seedTenantMembership(stack.db, {
|
|
164
|
+
userId: bobId,
|
|
165
|
+
tenantId: TENANT_B_ID,
|
|
166
|
+
roles: ["User"],
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
resetPiiSubjectKmsForTests();
|
|
172
|
+
resetBlindIndexKeyForTests();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
function aliceSession(): SessionUser {
|
|
176
|
+
return { id: aliceId, tenantId: TENANT_A_ID, roles: ["Admin"] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function bobSession(): SessionUser {
|
|
180
|
+
return { id: bobId, tenantId: TENANT_B_ID, roles: ["User"] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function inviteEmail(email: string, role: string): Promise<string> {
|
|
184
|
+
await stack.http.writeOk(AuthHandlers.inviteCreate, { email, role }, aliceSession());
|
|
185
|
+
const sent = emailTransport.sent.at(-1);
|
|
186
|
+
if (!sent) throw new Error("invite-create didn't send a mail");
|
|
187
|
+
return extractTokenFromMail(sent.html);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function rawUserRow(id: string): Promise<Record<string, unknown>> {
|
|
191
|
+
const rows = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
|
|
192
|
+
`SELECT * FROM "${userTable.tableName}" WHERE id = $1`,
|
|
193
|
+
[id],
|
|
194
|
+
);
|
|
195
|
+
const row = rows[0];
|
|
196
|
+
if (!row) throw new Error(`no user row for ${id}`);
|
|
197
|
+
return row;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
describe("auth flows with active KMS + blind index", () => {
|
|
201
|
+
test("seeded user row: ciphertext email + populated bidx column", async () => {
|
|
202
|
+
const row = await rawUserRow(bobId);
|
|
203
|
+
expect(isPiiCiphertext(row["email"])).toBe(true);
|
|
204
|
+
expect(String(row["email_bidx"])).toStartWith("kumiko-bidx:v1:");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("login with plaintext email finds the encrypted row via bidx", async () => {
|
|
208
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
209
|
+
email: BOB_EMAIL,
|
|
210
|
+
password: BOB_PASSWORD,
|
|
211
|
+
});
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
const body = (await res.json()) as { isSuccess: boolean; token?: string };
|
|
214
|
+
expect(body.isSuccess).toBe(true);
|
|
215
|
+
expect(body.token).toBeTruthy();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("Branch 1: logged-in accept matches emails across two ciphertexts", async () => {
|
|
219
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
220
|
+
|
|
221
|
+
const invitations = await selectMany(stack.db, tenantInvitationsTable, { email: BOB_EMAIL });
|
|
222
|
+
expect(invitations).toHaveLength(1);
|
|
223
|
+
|
|
224
|
+
const result = (await stack.http.writeOk(
|
|
225
|
+
AuthHandlers.inviteAccept,
|
|
226
|
+
{ token },
|
|
227
|
+
bobSession(),
|
|
228
|
+
)) as {
|
|
229
|
+
tenantId: string;
|
|
230
|
+
alreadyMember: boolean;
|
|
231
|
+
};
|
|
232
|
+
expect(result.tenantId).toBe(TENANT_A_ID);
|
|
233
|
+
expect(result.alreadyMember).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("Branch 2: invite-accept-with-login (anon + password)", async () => {
|
|
237
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
238
|
+
|
|
239
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
240
|
+
token,
|
|
241
|
+
email: BOB_EMAIL,
|
|
242
|
+
password: BOB_PASSWORD,
|
|
243
|
+
});
|
|
244
|
+
expect(res.status).toBe(200);
|
|
245
|
+
const body = (await res.json()) as { isSuccess: boolean; tenantId: string };
|
|
246
|
+
expect(body.isSuccess).toBe(true);
|
|
247
|
+
expect(body.tenantId).toBe(TENANT_A_ID);
|
|
248
|
+
|
|
249
|
+
const memberships = await selectMany(stack.db, tenantMembershipsTable, { userId: bobId });
|
|
250
|
+
expect(memberships).toHaveLength(2);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("Branch 3: invite-signup-complete creates the user from the DECRYPTED invitation email", async () => {
|
|
254
|
+
const token = await inviteEmail(CAROL_EMAIL, "Admin");
|
|
255
|
+
|
|
256
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
|
|
257
|
+
token,
|
|
258
|
+
password: CAROL_PASSWORD,
|
|
259
|
+
});
|
|
260
|
+
expect(res.status).toBe(200);
|
|
261
|
+
const body = (await res.json()) as { isSuccess: boolean; user: { id: string } };
|
|
262
|
+
expect(body.isSuccess).toBe(true);
|
|
263
|
+
|
|
264
|
+
// Carol-Row: eigener user-Subject-Ciphertext (nicht der invitation-
|
|
265
|
+
// Ciphertext durchgereicht) + Klartext-abgeleiteter displayName.
|
|
266
|
+
const row = await rawUserRow(body.user.id);
|
|
267
|
+
expect(isPiiCiphertext(row["email"])).toBe(true);
|
|
268
|
+
expect(String(row["email"])).toContain(body.user.id);
|
|
269
|
+
const decrypted = await decryptPiiFieldValues(
|
|
270
|
+
{ displayName: row["display_name"] },
|
|
271
|
+
["displayName"],
|
|
272
|
+
kms,
|
|
273
|
+
{ requestId: "test" },
|
|
274
|
+
);
|
|
275
|
+
expect(decrypted["displayName"]).toBe("carol.kms");
|
|
276
|
+
|
|
277
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
278
|
+
email: CAROL_EMAIL,
|
|
279
|
+
password: CAROL_PASSWORD,
|
|
280
|
+
});
|
|
281
|
+
expect(loginRes.status).toBe(200);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
29
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
30
|
import { z } from "zod";
|
|
31
|
+
import { decryptStoredPii } from "../../shared";
|
|
31
32
|
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
32
33
|
import {
|
|
33
34
|
INVITATION_STATUS,
|
|
@@ -106,7 +107,10 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
106
107
|
return invalidInviteToken();
|
|
107
108
|
|
|
108
109
|
const invitationTenantId = invitation.tenantId;
|
|
109
|
-
const invitationEmail =
|
|
110
|
+
const invitationEmail = await decryptStoredPii(
|
|
111
|
+
invitation.email,
|
|
112
|
+
"auth:invite-accept-with-login",
|
|
113
|
+
);
|
|
110
114
|
const invitationRole = invitation.role;
|
|
111
115
|
const invitationVersion = invitation.version;
|
|
112
116
|
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
25
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
26
26
|
import { z } from "zod";
|
|
27
|
+
import { decryptStoredPii } from "../../shared";
|
|
27
28
|
// kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
|
|
28
29
|
import {
|
|
29
30
|
INVITATION_STATUS,
|
|
@@ -100,7 +101,7 @@ export function createInviteAcceptHandler() {
|
|
|
100
101
|
return invalidInviteToken();
|
|
101
102
|
|
|
102
103
|
const invitationTenantId = invitation.tenantId;
|
|
103
|
-
const invitationEmail = invitation.email;
|
|
104
|
+
const invitationEmail = await decryptStoredPii(invitation.email, "auth:invite-accept");
|
|
104
105
|
const invitationRole = invitation.role;
|
|
105
106
|
const invitationVersion = invitation.version;
|
|
106
107
|
|
|
@@ -110,7 +111,9 @@ export function createInviteAcceptHandler() {
|
|
|
110
111
|
const userRow = await fetchOne<UserEmailRow>(ctx.db.raw, userTable, {
|
|
111
112
|
id: event.user.id,
|
|
112
113
|
});
|
|
113
|
-
const userEmail = userRow?.email
|
|
114
|
+
const userEmail = userRow?.email
|
|
115
|
+
? await decryptStoredPii(userRow.email, "auth:invite-accept")
|
|
116
|
+
: undefined;
|
|
114
117
|
if (!userRow || !userEmail || userEmail.toLowerCase() !== invitationEmail) {
|
|
115
118
|
return inviteEmailMismatch();
|
|
116
119
|
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
33
33
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
34
34
|
import { z } from "zod";
|
|
35
|
+
import { decryptStoredPii } from "../../shared";
|
|
35
36
|
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
36
37
|
import {
|
|
37
38
|
INVITATION_STATUS,
|
|
@@ -109,7 +110,10 @@ export function createInviteSignupCompleteHandler() {
|
|
|
109
110
|
return invalidInviteToken();
|
|
110
111
|
|
|
111
112
|
const invitationTenantId = invitation.tenantId;
|
|
112
|
-
const invitationEmail =
|
|
113
|
+
const invitationEmail = await decryptStoredPii(
|
|
114
|
+
invitation.email,
|
|
115
|
+
"auth:invite-signup-complete",
|
|
116
|
+
);
|
|
113
117
|
const invitationRole = invitation.role;
|
|
114
118
|
const invitationVersion = invitation.version;
|
|
115
119
|
|
|
@@ -125,7 +125,9 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
125
125
|
renderContent: spec.renderContent,
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
|
-
|
|
128
|
+
// Row value is ciphertext with an active KMS; the byte-exact
|
|
129
|
+
// lookup above guarantees the payload IS the stored plaintext.
|
|
130
|
+
email: event.payload.email,
|
|
129
131
|
appUrl: opts.appUrl,
|
|
130
132
|
token,
|
|
131
133
|
expiresAt: expiresAt.toString(),
|
|
@@ -136,7 +138,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
136
138
|
|
|
137
139
|
const data: TokenRequestData<TSuccessKind> = {
|
|
138
140
|
kind: spec.successKind,
|
|
139
|
-
email:
|
|
141
|
+
email: event.payload.email,
|
|
140
142
|
token,
|
|
141
143
|
expiresAt: expiresAt.toString(),
|
|
142
144
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { guardEmailMessage, withPiiCiphertextGuard } from "../pii-guard";
|
|
3
|
+
import { createInMemoryTransport } from "../types";
|
|
4
|
+
|
|
5
|
+
const CIPHERTEXT = "kumiko-pii:v1:user:6b2f4a0e-1c9d-4f3a-9d2e-00000000000a:8e2Rkjj+ww==";
|
|
6
|
+
const originalNodeEnv = process.env["NODE_ENV"];
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
if (originalNodeEnv === undefined) delete process.env["NODE_ENV"];
|
|
10
|
+
else process.env["NODE_ENV"] = originalNodeEnv;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("guardEmailMessage", () => {
|
|
14
|
+
test("clean message passes through untouched", () => {
|
|
15
|
+
const msg = { to: "marc@example.com", subject: "Hi", html: "<p>Hi</p>" };
|
|
16
|
+
expect(guardEmailMessage(msg)).toBe(msg);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("ciphertext recipient is always refused — even in production", () => {
|
|
20
|
+
process.env["NODE_ENV"] = "production";
|
|
21
|
+
expect(() => guardEmailMessage({ to: CIPHERTEXT, subject: "Hi", html: "x" })).toThrow(
|
|
22
|
+
/recipient address is a PII ciphertext/,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("ciphertext in body fails loud outside production", () => {
|
|
27
|
+
expect(() =>
|
|
28
|
+
guardEmailMessage({ to: "marc@example.com", subject: "Hi", html: `<p>${CIPHERTEXT}</p>` }),
|
|
29
|
+
).toThrow(/subject\/body contains a PII ciphertext/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("production redacts body/subject instead of throwing", () => {
|
|
33
|
+
process.env["NODE_ENV"] = "production";
|
|
34
|
+
const out = guardEmailMessage({
|
|
35
|
+
to: "marc@example.com",
|
|
36
|
+
subject: `Re: ${CIPHERTEXT}`,
|
|
37
|
+
html: `<p>${CIPHERTEXT}</p>`,
|
|
38
|
+
});
|
|
39
|
+
expect(out.subject).toBe("Re: [pii-redacted]");
|
|
40
|
+
expect(out.html).toBe("<p>[pii-redacted]</p>");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("withPiiCiphertextGuard", () => {
|
|
45
|
+
test("wrapped transport refuses a ciphertext recipient before sending", async () => {
|
|
46
|
+
const inner = createInMemoryTransport();
|
|
47
|
+
const guarded = withPiiCiphertextGuard(inner);
|
|
48
|
+
await expect(guarded.send({ to: CIPHERTEXT, subject: "x", html: "y" })).rejects.toThrow(
|
|
49
|
+
/recipient address is a PII ciphertext/,
|
|
50
|
+
);
|
|
51
|
+
expect(inner.sent).toHaveLength(0);
|
|
52
|
+
|
|
53
|
+
await guarded.send({ to: "ok@example.com", subject: "x", html: "y" });
|
|
54
|
+
expect(inner.sent).toHaveLength(1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
NotificationRenderer,
|
|
7
7
|
RenderedMessage,
|
|
8
8
|
} from "../delivery";
|
|
9
|
+
import { guardEmailMessage } from "./pii-guard";
|
|
9
10
|
import type { EmailTransport } from "./types";
|
|
10
11
|
|
|
11
12
|
export type EmailChannelOptions = {
|
|
@@ -53,7 +54,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
53
54
|
|
|
54
55
|
async send(address, message, _ctx, rendered) {
|
|
55
56
|
const { html, subject } = rendered ?? (await renderMessage(message));
|
|
56
|
-
await transport.send({ to: address, subject, html });
|
|
57
|
+
await transport.send(guardEmailMessage({ to: address, subject, html }));
|
|
57
58
|
return { status: "sent", address };
|
|
58
59
|
},
|
|
59
60
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createEmailChannel, type EmailChannelOptions } from "./email-channel";
|
|
2
2
|
export { createChannelEmailFeature } from "./feature";
|
|
3
|
+
export { guardEmailMessage, withPiiCiphertextGuard } from "./pii-guard";
|
|
3
4
|
export {
|
|
4
5
|
createSmtpTransport,
|
|
5
6
|
createSmtpTransportFromEnv,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { PII_CIPHERTEXT_PREFIX } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
+
import type { EmailMessage, EmailTransport } from "./types";
|
|
3
|
+
|
|
4
|
+
const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
5
|
+
const CIPHERTEXT_RE = /kumiko-pii:v1:[^"\s<>\\]*/g;
|
|
6
|
+
|
|
7
|
+
// A PII ciphertext never belongs in an outgoing mail. A ciphertext RECIPIENT
|
|
8
|
+
// is always refused (the address is garbage — better no mail than a leaked
|
|
9
|
+
// blob); ciphertext in subject/body fails loud in dev and is redacted+logged
|
|
10
|
+
// in prod.
|
|
11
|
+
export function guardEmailMessage(message: EmailMessage): EmailMessage {
|
|
12
|
+
if (message.to.includes(PII_CIPHERTEXT_PREFIX)) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"[channel-email] refusing to send: recipient address is a PII ciphertext " +
|
|
15
|
+
`("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const leaking =
|
|
19
|
+
message.subject.includes(PII_CIPHERTEXT_PREFIX) || message.html.includes(PII_CIPHERTEXT_PREFIX);
|
|
20
|
+
if (!leaking) return message;
|
|
21
|
+
const detail =
|
|
22
|
+
"[channel-email] mail subject/body contains a PII ciphertext " +
|
|
23
|
+
`("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before rendering.`;
|
|
24
|
+
if (!isProductionEnv()) throw new Error(detail);
|
|
25
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for a redacted prod leak
|
|
26
|
+
console.error(detail);
|
|
27
|
+
return {
|
|
28
|
+
...message,
|
|
29
|
+
subject: message.subject.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
30
|
+
html: message.html.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function withPiiCiphertextGuard(transport: EmailTransport): EmailTransport {
|
|
35
|
+
return { ...transport, send: async (message) => transport.send(guardEmailMessage(message)) };
|
|
36
|
+
}
|
package/src/config/table.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const configValueEntity = createEntity({
|
|
|
25
25
|
// breaking the null-vs-missing distinction the resolver already draws.
|
|
26
26
|
value: createTextField({}),
|
|
27
27
|
// user-scope row: userId populated. tenant- / system-scope: null.
|
|
28
|
-
userId: createTextField({
|
|
28
|
+
userId: createTextField({ allowPlaintext: "pseudonymous-fk" }),
|
|
29
29
|
},
|
|
30
30
|
indexes: [
|
|
31
31
|
{ unique: true, columns: ["key", "tenantId", "userId"], name: "read_config_values_unique" },
|