@cosmicdrift/kumiko-bundled-features 0.118.0 → 0.119.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/tables.ts +1 -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/hooks/tenant-invitation.userdata-hook.ts +6 -1
- package/src/user-profile/handlers/change-email.write.ts +3 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// crypto-shredding forget-subject — end-to-end over real HTTP dispatch:
|
|
2
|
+
//
|
|
3
|
+
// - DPO erases a user subject → DEK gone (getKey throws KeyErased),
|
|
4
|
+
// subject-forgotten audit event appended
|
|
5
|
+
// - tenant subjects shred the same way
|
|
6
|
+
// - repeat forget is a no-op erase but still audited
|
|
7
|
+
// - no KMS configured → 500 with actionable message
|
|
8
|
+
// - Member role → 403 (DPO/SystemAdmin only)
|
|
9
|
+
|
|
10
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
|
+
import {
|
|
13
|
+
configurePiiSubjectKms,
|
|
14
|
+
InMemoryKmsAdapter,
|
|
15
|
+
resetPiiSubjectKmsForTests,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
19
|
+
import { setupTestStack, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
21
|
+
import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
22
|
+
import { createCryptoShreddingFeature } from "../feature";
|
|
23
|
+
|
|
24
|
+
const FORGET = "crypto-shredding:write:forget-subject";
|
|
25
|
+
|
|
26
|
+
let stack: TestStack;
|
|
27
|
+
let kms: InMemoryKmsAdapter;
|
|
28
|
+
|
|
29
|
+
const TENANT: TenantId = testTenantId(1);
|
|
30
|
+
const TARGET_USER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-000000000001";
|
|
31
|
+
const TARGET_TENANT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-0000000000a1";
|
|
32
|
+
const REASON = "authority request #42 (Art. 17)";
|
|
33
|
+
|
|
34
|
+
const dpoUser = {
|
|
35
|
+
id: "aaaaaaaa-aaaa-4aaa-8aaa-0000000000d1",
|
|
36
|
+
tenantId: TENANT,
|
|
37
|
+
roles: ["DataProtectionOfficer"],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const memberUser = {
|
|
41
|
+
id: "aaaaaaaa-aaaa-4aaa-8aaa-0000000000e1",
|
|
42
|
+
tenantId: TENANT,
|
|
43
|
+
roles: ["Member"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
stack = await setupTestStack({ features: [createCryptoShreddingFeature()] });
|
|
48
|
+
await createEventsTable(stack.db);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await stack.cleanup();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
await resetTestTables(stack.db, [eventsTable]);
|
|
57
|
+
kms = new InMemoryKmsAdapter();
|
|
58
|
+
configurePiiSubjectKms(kms);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
resetPiiSubjectKmsForTests();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function forgottenEvents(): Promise<Array<{ payload: Record<string, unknown> }>> {
|
|
66
|
+
return (await selectMany(stack.db, eventsTable, {
|
|
67
|
+
type: SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
68
|
+
})) as Array<{ payload: Record<string, unknown> }>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("crypto-shredding :: forget-subject", () => {
|
|
72
|
+
test("DPO forgets a user subject → key erased + audit event", async () => {
|
|
73
|
+
const subject = { kind: "user", userId: TARGET_USER_ID } as const;
|
|
74
|
+
await kms.createKey(subject);
|
|
75
|
+
|
|
76
|
+
const result = await stack.http.writeOk<{ subjectKey: string }>(
|
|
77
|
+
FORGET,
|
|
78
|
+
{ subject, reason: REASON },
|
|
79
|
+
dpoUser,
|
|
80
|
+
);
|
|
81
|
+
expect(result.subjectKey).toBe(`user:${TARGET_USER_ID}`);
|
|
82
|
+
|
|
83
|
+
await expect(kms.getKey(subject)).rejects.toThrow("Subject key erased");
|
|
84
|
+
|
|
85
|
+
const events = await forgottenEvents();
|
|
86
|
+
expect(events).toHaveLength(1);
|
|
87
|
+
expect(events[0]?.payload).toMatchObject({
|
|
88
|
+
subjectKey: `user:${TARGET_USER_ID}`,
|
|
89
|
+
reason: REASON,
|
|
90
|
+
forgottenBy: dpoUser.id,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("tenant subject shreds the same way", async () => {
|
|
95
|
+
const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
|
|
96
|
+
await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
|
|
97
|
+
|
|
98
|
+
const result = await stack.http.writeOk<{ subjectKey: string }>(
|
|
99
|
+
FORGET,
|
|
100
|
+
{ subject, reason: REASON },
|
|
101
|
+
dpoUser,
|
|
102
|
+
);
|
|
103
|
+
expect(result.subjectKey).toBe(`tenant:${TARGET_TENANT_ID}`);
|
|
104
|
+
|
|
105
|
+
await expect(
|
|
106
|
+
kms.getKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId }),
|
|
107
|
+
).rejects.toThrow("Subject key erased");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("repeat forget: erase is a no-op but each attempt is audited", async () => {
|
|
111
|
+
const subject = { kind: "user", userId: TARGET_USER_ID } as const;
|
|
112
|
+
await kms.createKey(subject);
|
|
113
|
+
|
|
114
|
+
await stack.http.writeOk(FORGET, { subject, reason: REASON }, dpoUser);
|
|
115
|
+
await stack.http.writeOk(FORGET, { subject, reason: `${REASON} (repeat)` }, dpoUser);
|
|
116
|
+
|
|
117
|
+
expect(await forgottenEvents()).toHaveLength(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("no KMS configured → 500 with boot hint", async () => {
|
|
121
|
+
resetPiiSubjectKmsForTests();
|
|
122
|
+
|
|
123
|
+
const err = await stack.http.writeErr(
|
|
124
|
+
FORGET,
|
|
125
|
+
{ subject: { kind: "user", userId: TARGET_USER_ID }, reason: REASON },
|
|
126
|
+
dpoUser,
|
|
127
|
+
);
|
|
128
|
+
expect(err.httpStatus).toBe(500);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("Member role → 403", async () => {
|
|
132
|
+
const err = await stack.http.writeErr(
|
|
133
|
+
FORGET,
|
|
134
|
+
{ subject: { kind: "user", userId: TARGET_USER_ID }, reason: REASON },
|
|
135
|
+
memberUser,
|
|
136
|
+
);
|
|
137
|
+
expect(err.httpStatus).toBe(403);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("reason shorter than 10 chars → schema reject", async () => {
|
|
141
|
+
const err = await stack.http.writeErr(
|
|
142
|
+
FORGET,
|
|
143
|
+
{ subject: { kind: "user", userId: TARGET_USER_ID }, reason: "short" },
|
|
144
|
+
dpoUser,
|
|
145
|
+
);
|
|
146
|
+
expect(err.httpStatus).toBe(400);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { CRYPTO_SHREDDING_FEATURE_NAME } from "./constants";
|
|
3
|
+
import { forgetSubjectWrite, subjectForgottenSchema } from "./handlers/forget-subject.write";
|
|
4
|
+
|
|
5
|
+
export function createCryptoShreddingFeature(): FeatureDefinition {
|
|
6
|
+
return defineFeature(CRYPTO_SHREDDING_FEATURE_NAME, (r) => {
|
|
7
|
+
r.describe(
|
|
8
|
+
"Operator-level crypto-shredding trigger. `forget-subject` erases a user or tenant subject key in the configured KMS adapter, making every PII field encrypted under it permanently unreadable (reads render the `[[erased]]` sentinel), and appends a `subject-forgotten` audit event. Requires a KMS adapter (`runProdApp({ kms })`). The automated Art.-17 deletion pipeline in `user-data-rights` erases keys itself; this command covers manual forgets (authority requests, operator recovery, tenant destroy).",
|
|
9
|
+
);
|
|
10
|
+
r.uiHints({
|
|
11
|
+
displayLabel: "Crypto-Shredding",
|
|
12
|
+
category: "compliance",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
r.defineEvent("subject-forgotten", subjectForgottenSchema);
|
|
16
|
+
r.writeHandler(forgetSubjectWrite);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
3
|
+
import {
|
|
4
|
+
configuredPiiSubjectKms,
|
|
5
|
+
type SubjectId,
|
|
6
|
+
subjectIdToKey,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
+
import { nullBlindIndexesForSubject } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { CRYPTO_SHREDDING_AGGREGATE_TYPE, SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
13
|
+
|
|
14
|
+
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
15
|
+
z.object({ kind: z.literal("user"), userId: z.uuid() }),
|
|
16
|
+
z.object({ kind: z.literal("tenant"), tenantId: z.uuid() }),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export const forgetSubjectSchema = z.object({
|
|
20
|
+
subject: subjectIdSchema,
|
|
21
|
+
reason: z.string().min(10),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const subjectForgottenSchema = z.object({
|
|
25
|
+
subjectKey: z.string().min(1),
|
|
26
|
+
reason: z.string().min(10),
|
|
27
|
+
forgottenBy: z.string().min(1),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Manual crypto-shred for a DPO / platform operator: erases the subject's
|
|
31
|
+
// DEK immediately (all its PII ciphertext becomes unreadable, reads render
|
|
32
|
+
// "[[erased]]") and appends the audit event. Forget is final — the adapter
|
|
33
|
+
// keeps a tombstone, so the subject can never get a new key.
|
|
34
|
+
//
|
|
35
|
+
// The automated Art.-17 path (user-data-rights forget-cleanup) calls
|
|
36
|
+
// kms.eraseKey directly inside its per-user sub-tx; this command is the
|
|
37
|
+
// standalone trigger for cases outside that pipeline (authority requests,
|
|
38
|
+
// tenant-destroy in Sprint 5, operator recovery).
|
|
39
|
+
export const forgetSubjectWrite = defineWriteHandler({
|
|
40
|
+
name: "forget-subject",
|
|
41
|
+
schema: forgetSubjectSchema,
|
|
42
|
+
access: { roles: [ROLES.DataProtectionOfficer, ROLES.SystemAdmin] },
|
|
43
|
+
handler: async (event, ctx) => {
|
|
44
|
+
const kms = configuredPiiSubjectKms();
|
|
45
|
+
if (!kms) {
|
|
46
|
+
return writeFailure(
|
|
47
|
+
new InternalError({
|
|
48
|
+
message:
|
|
49
|
+
"[crypto-shredding] forget-subject called but no KMS adapter is configured — " +
|
|
50
|
+
"pass runProdApp({ kms }) / configurePiiSubjectKms(adapter) at boot.",
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const raw = event.payload.subject;
|
|
56
|
+
const subject: SubjectId =
|
|
57
|
+
raw.kind === "user"
|
|
58
|
+
? { kind: "user", userId: raw.userId }
|
|
59
|
+
: { kind: "tenant", tenantId: raw.tenantId as TenantId }; // @cast-boundary uuid-validated command payload → branded id
|
|
60
|
+
const subjectKey = subjectIdToKey(subject);
|
|
61
|
+
|
|
62
|
+
// Erase BEFORE the audit append: if the append throws, the key is gone
|
|
63
|
+
// but no event exists — a retry is a no-op erase plus the event. The
|
|
64
|
+
// reverse order could leave an audit trail claiming a shred that never
|
|
65
|
+
// happened.
|
|
66
|
+
await kms.eraseKey(subject, {
|
|
67
|
+
requestId: requestContext.get()?.requestId ?? "crypto-shredding:forget-subject",
|
|
68
|
+
userId: event.user.id,
|
|
69
|
+
eraseReason: event.payload.reason,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Blind-Index-Sweep (#818): bidx-Spalten des erased Subjects sofort
|
|
73
|
+
// nullen — sonst bliebe der deterministische HMAC bis zum nächsten
|
|
74
|
+
// Rebuild equality-matchbar. Bewusst ctx.db.raw: der Ciphertext-Prefix
|
|
75
|
+
// adressiert das Subject tenant-übergreifend.
|
|
76
|
+
await nullBlindIndexesForSubject(ctx.db.raw, ctx.registry.features, subjectKey);
|
|
77
|
+
|
|
78
|
+
await ctx.unsafeAppendEvent({
|
|
79
|
+
aggregateId: raw.kind === "user" ? raw.userId : raw.tenantId,
|
|
80
|
+
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
81
|
+
type: SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
82
|
+
payload: {
|
|
83
|
+
subjectKey,
|
|
84
|
+
reason: event.payload.reason,
|
|
85
|
+
forgottenBy: event.user.id,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return { isSuccess: true as const, data: { subjectKey } };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
3
|
+
CRYPTO_SHREDDING_FEATURE_NAME,
|
|
4
|
+
SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
5
|
+
} from "./constants";
|
|
6
|
+
export { createCryptoShreddingFeature } from "./feature";
|
|
7
|
+
export {
|
|
8
|
+
forgetSubjectSchema,
|
|
9
|
+
subjectForgottenSchema,
|
|
10
|
+
subjectIdSchema,
|
|
11
|
+
} from "./handlers/forget-subject.write";
|
package/src/delivery/tables.ts
CHANGED
|
@@ -76,7 +76,7 @@ export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
|
|
|
76
76
|
export const notificationPreferenceEntity = createEntity({
|
|
77
77
|
table: "read_notification_preferences",
|
|
78
78
|
fields: {
|
|
79
|
-
userId: createTextField({ required: true,
|
|
79
|
+
userId: createTextField({ required: true, allowPlaintext: "pseudonymous-fk" }),
|
|
80
80
|
notificationType: createTextField({ required: true }), // qualified name or "*"
|
|
81
81
|
channel: createTextField({ required: true }), // "inApp", "email", "push", or "*"
|
|
82
82
|
enabled: createBooleanField({ default: true }),
|
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
// `channel-email` (App-wide-Mail-Sender via delivery) bleibt unangetastet
|
|
32
32
|
// — additive Feature.
|
|
33
33
|
|
|
34
|
-
import
|
|
34
|
+
import {
|
|
35
|
+
type EmailTransport,
|
|
36
|
+
withPiiCiphertextGuard,
|
|
37
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
35
38
|
import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
36
39
|
import {
|
|
37
40
|
access,
|
|
@@ -216,5 +219,5 @@ export async function createTransportForTenant(
|
|
|
216
219
|
`extension options must be a MailTransportPlugin.`,
|
|
217
220
|
);
|
|
218
221
|
}
|
|
219
|
-
return usage.options.build(ctx, tenantId);
|
|
222
|
+
return withPiiCiphertextGuard(await usage.options.build(ctx, tenantId));
|
|
220
223
|
}
|
|
@@ -4,7 +4,15 @@ import {
|
|
|
4
4
|
createInMemoryLoginRateLimiter,
|
|
5
5
|
type PatResolver,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
7
|
-
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
|
+
import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import {
|
|
9
|
+
configureBlindIndexKey,
|
|
10
|
+
configurePiiSubjectKms,
|
|
11
|
+
InMemoryKmsAdapter,
|
|
12
|
+
isPiiCiphertext,
|
|
13
|
+
resetBlindIndexKeyForTests,
|
|
14
|
+
resetPiiSubjectKmsForTests,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
16
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
17
|
import {
|
|
10
18
|
setupTestStack,
|
|
@@ -202,3 +210,33 @@ describe("PAT auth", () => {
|
|
|
202
210
|
expect(res.status).toBe(401);
|
|
203
211
|
});
|
|
204
212
|
});
|
|
213
|
+
|
|
214
|
+
describe("PAT with active KMS (#820): token name is userOwned PII", () => {
|
|
215
|
+
test("create stores ciphertext at rest, list returns the plaintext name", async () => {
|
|
216
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
217
|
+
configureBlindIndexKey(Buffer.alloc(32, 7).toString("base64"));
|
|
218
|
+
try {
|
|
219
|
+
const actor = await actorFor("kms-pat@example.com");
|
|
220
|
+
const token = await mintToken(actor);
|
|
221
|
+
|
|
222
|
+
const rows = await stack.http.queryOk<Array<{ id: string; name: string }>>(
|
|
223
|
+
PatQueries.mine,
|
|
224
|
+
{},
|
|
225
|
+
actor,
|
|
226
|
+
);
|
|
227
|
+
expect(rows[0]?.name).toBe("test");
|
|
228
|
+
|
|
229
|
+
const stored = await selectMany<{ name: string }>(stack.db, apiTokenTable, {
|
|
230
|
+
id: rows[0]?.id,
|
|
231
|
+
});
|
|
232
|
+
expect(isPiiCiphertext(stored[0]?.name)).toBe(true);
|
|
233
|
+
|
|
234
|
+
// Der Token selbst funktioniert weiter (resolver liest tokenHash, nie name).
|
|
235
|
+
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
236
|
+
expect(res.status).toBe(200);
|
|
237
|
+
} finally {
|
|
238
|
+
resetPiiSubjectKmsForTests();
|
|
239
|
+
resetBlindIndexKeyForTests();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -54,6 +54,8 @@ export function createPersonalAccessTokensFeature(
|
|
|
54
54
|
// whose replay (no token events) would wipe every live token (#498/#494).
|
|
55
55
|
r.unmanagedTable(buildEntityTableMeta("api-token", apiTokenEntity), {
|
|
56
56
|
reason: "read_side.api_tokens_direct_write",
|
|
57
|
+
// create.write encrypts `name` via encryptForDirectWrite (#820).
|
|
58
|
+
piiEncryptedOnWrite: true,
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
const handlers = {
|
|
@@ -3,8 +3,9 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
3
3
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
4
|
import { Temporal } from "temporal-polyfill";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { encryptForDirectWrite } from "../../shared";
|
|
6
7
|
import { mintPatToken } from "../hash";
|
|
7
|
-
import { apiTokenTable } from "../schema/api-token";
|
|
8
|
+
import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
|
|
8
9
|
|
|
9
10
|
// Mint a PAT for the calling user in their active tenant. The plaintext token
|
|
10
11
|
// is returned ONCE (data.token) and never again — only the hash is stored.
|
|
@@ -22,20 +23,25 @@ export const createPatWrite = defineWriteHandler({
|
|
|
22
23
|
const { raw, hash, prefix } = mintPatToken();
|
|
23
24
|
const now = Temporal.Now.instant();
|
|
24
25
|
const id = generateId();
|
|
25
|
-
await
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
:
|
|
37
|
-
|
|
38
|
-
|
|
26
|
+
const row = await encryptForDirectWrite(
|
|
27
|
+
apiTokenEntity,
|
|
28
|
+
{
|
|
29
|
+
id,
|
|
30
|
+
userId: event.user.id,
|
|
31
|
+
tenantId: event.user.tenantId,
|
|
32
|
+
name: event.payload.name,
|
|
33
|
+
tokenHash: hash,
|
|
34
|
+
prefix,
|
|
35
|
+
scopes: JSON.stringify(event.payload.scopes),
|
|
36
|
+
createdAt: now,
|
|
37
|
+
expiresAt: event.payload.expiresInDays
|
|
38
|
+
? now.add({ hours: 24 * event.payload.expiresInDays })
|
|
39
|
+
: null,
|
|
40
|
+
revokedAt: null,
|
|
41
|
+
},
|
|
42
|
+
"pat:create",
|
|
43
|
+
);
|
|
44
|
+
await insertOne(ctx.db, apiTokenTable, row);
|
|
39
45
|
return { isSuccess: true, data: { id, token: raw, prefix } };
|
|
40
46
|
},
|
|
41
47
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { decryptStoredPii } from "../../shared";
|
|
4
5
|
import { apiTokenTable } from "../schema/api-token";
|
|
5
6
|
|
|
6
7
|
// The caller's own tokens — metadata only, never the hash or plaintext.
|
|
@@ -25,15 +26,17 @@ export const listPatQuery = defineQueryHandler({
|
|
|
25
26
|
{ userId: query.user.id },
|
|
26
27
|
{ orderBy: { col: "createdAt", direction: "desc" } },
|
|
27
28
|
);
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
return Promise.all(
|
|
30
|
+
rows.map(async (r) => ({
|
|
31
|
+
id: r.id,
|
|
32
|
+
name: await decryptStoredPii(r.name, "pat:list"),
|
|
33
|
+
prefix: r.prefix,
|
|
34
|
+
scopes: parseScopeNames(r.scopes),
|
|
35
|
+
createdAt: r.createdAt,
|
|
36
|
+
expiresAt: r.expiresAt,
|
|
37
|
+
revokedAt: r.revokedAt,
|
|
38
|
+
})),
|
|
39
|
+
);
|
|
37
40
|
},
|
|
38
41
|
});
|
|
39
42
|
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import {
|
|
5
|
+
configureBlindIndexKey,
|
|
6
|
+
configurePiiSubjectKms,
|
|
7
|
+
InMemoryKmsAdapter,
|
|
8
|
+
isPiiCiphertext,
|
|
9
|
+
resetBlindIndexKeyForTests,
|
|
10
|
+
resetPiiSubjectKmsForTests,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
4
12
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
13
|
import {
|
|
6
14
|
setupTestStack,
|
|
@@ -538,3 +546,44 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
|
|
|
538
546
|
expect(res.status).toBe(200);
|
|
539
547
|
});
|
|
540
548
|
});
|
|
549
|
+
|
|
550
|
+
describe("sessions with active KMS (#820): ip/userAgent are userOwned PII", () => {
|
|
551
|
+
test("sessionCreator stores ciphertext at rest, mine returns plaintext", async () => {
|
|
552
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
553
|
+
configureBlindIndexKey(Buffer.alloc(32, 7).toString("base64"));
|
|
554
|
+
try {
|
|
555
|
+
const { userId } = await h.seedUser("kms-session@example.com", "pw-long-enough");
|
|
556
|
+
const cbs = createSessionCallbacks({ db: stack.db });
|
|
557
|
+
const sid = await cbs.sessionCreator(
|
|
558
|
+
{ id: userId, tenantId: TENANT, roles: ["User"] },
|
|
559
|
+
{ ip: "203.0.113.7", userAgent: "TestBrowser/1.0" },
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const stored = await selectMany<{ ip: string | null; userAgent: string | null }>(
|
|
563
|
+
stack.db,
|
|
564
|
+
userSessionTable,
|
|
565
|
+
{ id: sid },
|
|
566
|
+
);
|
|
567
|
+
expect(isPiiCiphertext(stored[0]?.ip)).toBe(true);
|
|
568
|
+
expect(isPiiCiphertext(stored[0]?.userAgent)).toBe(true);
|
|
569
|
+
|
|
570
|
+
// Login (bidx-Lookup auf verschluesselter user.email) + mine ueber HTTP:
|
|
571
|
+
// die manuell erzeugte Session kommt mit Klartext-ip/userAgent zurueck.
|
|
572
|
+
const login = await h.login("kms-session@example.com", "pw-long-enough");
|
|
573
|
+
const res = await h.authedPost("/api/query", login.token, {
|
|
574
|
+
type: SessionQueries.mine,
|
|
575
|
+
payload: {},
|
|
576
|
+
});
|
|
577
|
+
expect(res.status).toBe(200);
|
|
578
|
+
const body = (await res.json()) as {
|
|
579
|
+
data: Array<{ id: string; ip: string | null; userAgent: string | null }>;
|
|
580
|
+
};
|
|
581
|
+
const manual = body.data.find((s) => s.id === sid);
|
|
582
|
+
expect(manual?.ip).toBe("203.0.113.7");
|
|
583
|
+
expect(manual?.userAgent).toBe("TestBrowser/1.0");
|
|
584
|
+
} finally {
|
|
585
|
+
resetPiiSubjectKmsForTests();
|
|
586
|
+
resetBlindIndexKeyForTests();
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
});
|
package/src/sessions/feature.ts
CHANGED
|
@@ -90,6 +90,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
90
90
|
// which are direct-write stores too.
|
|
91
91
|
r.unmanagedTable(buildEntityTableMeta("user-session", userSessionEntity), {
|
|
92
92
|
reason: "read_side.user_sessions_direct_write",
|
|
93
|
+
// sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
|
|
94
|
+
piiEncryptedOnWrite: true,
|
|
93
95
|
});
|
|
94
96
|
|
|
95
97
|
const handlers = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { decryptStoredPii } from "../../shared";
|
|
4
5
|
import { userSessionTable } from "../schema/user-session";
|
|
5
6
|
|
|
6
7
|
// Admin view of every session in the active tenant. ctx.db (TenantDb)
|
|
@@ -22,14 +23,16 @@ export const listQuery = defineQueryHandler({
|
|
|
22
23
|
}>(ctx.db, userSessionTable, undefined, {
|
|
23
24
|
orderBy: { col: "createdAt", direction: "desc" },
|
|
24
25
|
});
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
return Promise.all(
|
|
27
|
+
rows.map(async (r) => ({
|
|
28
|
+
id: r.id,
|
|
29
|
+
userId: r.userId,
|
|
30
|
+
createdAt: r.createdAt,
|
|
31
|
+
expiresAt: r.expiresAt,
|
|
32
|
+
revokedAt: r.revokedAt,
|
|
33
|
+
ip: r.ip ? await decryptStoredPii(r.ip, "sessions:list") : r.ip,
|
|
34
|
+
userAgent: r.userAgent ? await decryptStoredPii(r.userAgent, "sessions:list") : r.userAgent,
|
|
35
|
+
})),
|
|
36
|
+
);
|
|
34
37
|
},
|
|
35
38
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { decryptStoredPii } from "../../shared";
|
|
4
5
|
import { userSessionTable } from "../schema/user-session";
|
|
5
6
|
|
|
6
7
|
// "My live sessions" — the backing data for a devices/sessions UI. Returns
|
|
@@ -26,13 +27,15 @@ export const mineQuery = defineQueryHandler({
|
|
|
26
27
|
},
|
|
27
28
|
);
|
|
28
29
|
const currentSid = query.user.sid;
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
return Promise.all(
|
|
31
|
+
rows.map(async (r) => ({
|
|
32
|
+
id: r.id,
|
|
33
|
+
createdAt: r.createdAt,
|
|
34
|
+
expiresAt: r.expiresAt,
|
|
35
|
+
ip: r.ip ? await decryptStoredPii(r.ip, "sessions:mine") : r.ip,
|
|
36
|
+
userAgent: r.userAgent ? await decryptStoredPii(r.userAgent, "sessions:mine") : r.userAgent,
|
|
37
|
+
current: currentSid === r.id,
|
|
38
|
+
})),
|
|
39
|
+
);
|
|
37
40
|
},
|
|
38
41
|
});
|
|
@@ -53,15 +53,17 @@ export const userSessionEntity = createEntity({
|
|
|
53
53
|
revokedAt: createTimestampField({
|
|
54
54
|
access: { write: access.privileged },
|
|
55
55
|
}),
|
|
56
|
+
// Subject is the session's user, not the session row — a user-forget
|
|
57
|
+
// must shred these, so the key hangs off userId (sid-self would orphan).
|
|
56
58
|
ip: createTextField({
|
|
57
59
|
maxLength: 64,
|
|
58
60
|
access: { write: access.privileged },
|
|
59
|
-
|
|
61
|
+
userOwned: { ownerField: "userId" },
|
|
60
62
|
}),
|
|
61
63
|
userAgent: createTextField({
|
|
62
64
|
maxLength: 512,
|
|
63
65
|
access: { write: access.privileged },
|
|
64
|
-
|
|
66
|
+
userOwned: { ownerField: "userId" },
|
|
65
67
|
}),
|
|
66
68
|
},
|
|
67
69
|
});
|