@cosmicdrift/kumiko-bundled-features 0.116.1 → 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__/feature.test.ts +24 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +39 -1
- package/src/personal-access-tokens/feature.ts +11 -1
- 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
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createPersonalAccessTokensFeature } from "../feature";
|
|
3
|
+
|
|
4
|
+
describe("createPersonalAccessTokensFeature toggleable-option (tier-gating)", () => {
|
|
5
|
+
test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
|
|
6
|
+
expect(createPersonalAccessTokensFeature({ scopes: {} }).toggleableDefault).toBeUndefined();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
|
|
10
|
+
const feature = createPersonalAccessTokensFeature({
|
|
11
|
+
scopes: {},
|
|
12
|
+
toggleable: { default: false },
|
|
13
|
+
});
|
|
14
|
+
expect(feature.toggleableDefault).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("toggleable:{default:true} declares toggleable, enabled-by-default", () => {
|
|
18
|
+
const feature = createPersonalAccessTokensFeature({
|
|
19
|
+
scopes: {},
|
|
20
|
+
toggleable: { default: true },
|
|
21
|
+
});
|
|
22
|
+
expect(feature.toggleableDefault).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -17,6 +17,10 @@ export type PersonalAccessTokensOptions = {
|
|
|
17
17
|
// Per-token request rate limit for PAT-authenticated calls. Defaults to
|
|
18
18
|
// PAT_DEFAULT_RATE_LIMIT (120/60s). run-prod-app builds the limiter from this.
|
|
19
19
|
readonly rateLimit?: PatRateLimit;
|
|
20
|
+
/** Make the whole feature tier-gatable via the tier-engine. Use
|
|
21
|
+
* { default: false } for fail-closed gating (feature off until a tier grants
|
|
22
|
+
* it). Omit to keep PAT always-on (default). */
|
|
23
|
+
readonly toggleable?: { readonly default: boolean };
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
export type PatFeatureExports = {
|
|
@@ -35,9 +39,13 @@ export function createPersonalAccessTokensFeature(
|
|
|
35
39
|
const { scopes } = options;
|
|
36
40
|
return defineFeature(PAT_FEATURE, (r) => {
|
|
37
41
|
r.describe(
|
|
38
|
-
"Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `read_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher.",
|
|
42
|
+
"Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `read_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
|
|
39
43
|
);
|
|
40
44
|
r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
|
|
45
|
+
// Opt-in tier-gating (mirrors ledger/tags): when set, the feature declares
|
|
46
|
+
// itself r.toggleable so the dispatcher gate + tier-engine can switch PAT
|
|
47
|
+
// on/off per tenant. { default: false } = fail-closed until a tier grants it.
|
|
48
|
+
if (options.toggleable !== undefined) r.toggleable(options.toggleable);
|
|
41
49
|
// Resolver reads memberships + users on every PAT request to build live
|
|
42
50
|
// roles — make both boot-time deps so a mis-wiring fails validateBoot.
|
|
43
51
|
r.requires("user", "tenant");
|
|
@@ -46,6 +54,8 @@ export function createPersonalAccessTokensFeature(
|
|
|
46
54
|
// whose replay (no token events) would wipe every live token (#498/#494).
|
|
47
55
|
r.unmanagedTable(buildEntityTableMeta("api-token", apiTokenEntity), {
|
|
48
56
|
reason: "read_side.api_tokens_direct_write",
|
|
57
|
+
// create.write encrypts `name` via encryptForDirectWrite (#820).
|
|
58
|
+
piiEncryptedOnWrite: true,
|
|
49
59
|
});
|
|
50
60
|
|
|
51
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
|
});
|