@cosmicdrift/kumiko-bundled-features 0.1.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 +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Integration test for the rotate-job circuit-breaker. Seeds a handful of
|
|
2
|
+
// rows and feeds the job a provider that always fails to unwrap, then
|
|
3
|
+
// asserts the job bails after maxFailures instead of spraying the log
|
|
4
|
+
// with every row's identical error.
|
|
5
|
+
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import {
|
|
9
|
+
createEnvMasterKeyProvider,
|
|
10
|
+
encryptValue,
|
|
11
|
+
type MasterKeyProvider,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
13
|
+
import {
|
|
14
|
+
createTestUser,
|
|
15
|
+
pushTables,
|
|
16
|
+
setupTestStack,
|
|
17
|
+
type TestStack,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { eq, sql } from "drizzle-orm";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
21
|
+
import { createSecretsFeature } from "../feature";
|
|
22
|
+
import { rotateJob } from "../handlers/rotate.job";
|
|
23
|
+
import { createSecretsContext } from "../secrets-context";
|
|
24
|
+
import { tenantSecretsTable } from "../table";
|
|
25
|
+
|
|
26
|
+
const admin = createTestUser({
|
|
27
|
+
id: "00000000-0000-4000-8000-000000000010",
|
|
28
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
29
|
+
roles: ["TenantAdmin"],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// A provider that encrypts happily on wrapDek but always rejects
|
|
33
|
+
// unwrapDek. Simulates "KEK is unreachable / corrupt" — the failure mode
|
|
34
|
+
// the circuit-breaker exists to contain. The returned object carries a
|
|
35
|
+
// `calls` counter so tests can observe how many unwrap attempts happened
|
|
36
|
+
// before the breaker tripped — that's how we tell maxFailures=1 apart
|
|
37
|
+
// from maxFailures=N+rows (both leave the rows on V1, only the call-count
|
|
38
|
+
// differs).
|
|
39
|
+
type BrokenProvider = MasterKeyProvider & { calls(): number };
|
|
40
|
+
function createBrokenUnwrapProvider(): BrokenProvider {
|
|
41
|
+
const base = createEnvMasterKeyProvider({
|
|
42
|
+
env: {
|
|
43
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
44
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2", // current != stored version so rotation is attempted
|
|
45
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: randomBytes(32).toString("base64"),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
let callCount = 0;
|
|
49
|
+
return {
|
|
50
|
+
wrapDek: base.wrapDek.bind(base),
|
|
51
|
+
currentVersion: base.currentVersion.bind(base),
|
|
52
|
+
isAvailable: base.isAvailable.bind(base),
|
|
53
|
+
unwrapDek: async () => {
|
|
54
|
+
callCount++;
|
|
55
|
+
throw new Error("simulated KEK failure");
|
|
56
|
+
},
|
|
57
|
+
calls: () => callCount,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let stack: TestStack;
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
// Seeding the table uses a sane V1-only provider so writes land on V1.
|
|
65
|
+
const seedProvider = createEnvMasterKeyProvider({
|
|
66
|
+
env: {
|
|
67
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
68
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
stack = await setupTestStack({
|
|
73
|
+
features: [createSecretsFeature()],
|
|
74
|
+
masterKeyProvider: seedProvider,
|
|
75
|
+
extraContext: ({ db }) => ({
|
|
76
|
+
secrets: createSecretsContext({ db, masterKeyProvider: seedProvider }),
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
await pushTables(stack.db, {
|
|
80
|
+
tenant_secrets: tenantSecretsTable,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Seed 20 V1 rows directly — too many for any maxFailures default.
|
|
84
|
+
for (let i = 0; i < 20; i++) {
|
|
85
|
+
const envelope = await encryptValue(`secret-${i}`, seedProvider);
|
|
86
|
+
await stack.db.insert(tenantSecretsTable).values({
|
|
87
|
+
tenantId: admin.tenantId,
|
|
88
|
+
key: `test:secret:k-${i}`,
|
|
89
|
+
envelope: {
|
|
90
|
+
ciphertext: envelope.ciphertext.toString("base64"),
|
|
91
|
+
iv: envelope.iv.toString("base64"),
|
|
92
|
+
authTag: envelope.authTag.toString("base64"),
|
|
93
|
+
encryptedDek: envelope.encryptedDek.toString("base64"),
|
|
94
|
+
kekVersion: envelope.kekVersion,
|
|
95
|
+
},
|
|
96
|
+
kekVersion: envelope.kekVersion,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterAll(async () => {
|
|
102
|
+
// Clean up the seeded fixtures so downstream suites don't see them.
|
|
103
|
+
await stack.db.delete(tenantSecretsTable).where(eq(tenantSecretsTable.tenantId, admin.tenantId));
|
|
104
|
+
await stack.cleanup();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
type Log = NonNullable<AppContext["log"]>;
|
|
108
|
+
function silentLogger(): Log {
|
|
109
|
+
const noop = () => {};
|
|
110
|
+
const logger: Log = {
|
|
111
|
+
info: noop,
|
|
112
|
+
warn: noop,
|
|
113
|
+
error: noop,
|
|
114
|
+
debug: noop,
|
|
115
|
+
child: () => logger,
|
|
116
|
+
};
|
|
117
|
+
return logger;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type RotateJobCtx = Pick<AppContext, "db" | "masterKeyProvider" | "log">;
|
|
121
|
+
function jobCtx(provider: MasterKeyProvider): Parameters<typeof rotateJob>[1] {
|
|
122
|
+
const ctx: RotateJobCtx = {
|
|
123
|
+
db: stack.db,
|
|
124
|
+
masterKeyProvider: provider,
|
|
125
|
+
log: silentLogger(),
|
|
126
|
+
};
|
|
127
|
+
return ctx as unknown as Parameters<typeof rotateJob>[1];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function countV1Rows(): Promise<number> {
|
|
131
|
+
const rows = await stack.db
|
|
132
|
+
.select({ kekVersion: tenantSecretsTable.kekVersion })
|
|
133
|
+
.from(tenantSecretsTable)
|
|
134
|
+
.where(sql`${tenantSecretsTable.tenantId} = ${admin.tenantId}`);
|
|
135
|
+
return rows.filter((r) => r.kekVersion === 1).length;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe("rotate-job circuit-breaker", () => {
|
|
139
|
+
test("bails after maxFailures consecutive errors — doesn't loop through all rows", async () => {
|
|
140
|
+
const broken = createBrokenUnwrapProvider();
|
|
141
|
+
|
|
142
|
+
// maxFailures: 3 means the job gives up after 3 failed rows. Without
|
|
143
|
+
// the breaker it would attempt all 20 and log 20 warns.
|
|
144
|
+
await rotateJob({ batchSize: 10, maxFailures: 3 }, jobCtx(broken));
|
|
145
|
+
|
|
146
|
+
// All 20 rows still at V1 — the broken provider never let any rewrap
|
|
147
|
+
// succeed. Plus: the breaker tripped at ≤3 attempts, not 20.
|
|
148
|
+
expect(await countV1Rows()).toBe(20);
|
|
149
|
+
expect(broken.calls()).toBeLessThanOrEqual(3);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("maxFailures=1 trips on the very first failure — single attempt then stop", async () => {
|
|
153
|
+
const broken = createBrokenUnwrapProvider();
|
|
154
|
+
|
|
155
|
+
await rotateJob({ batchSize: 10, maxFailures: 1 }, jobCtx(broken));
|
|
156
|
+
|
|
157
|
+
// Same end-state (all rows on V1) but the internal counter proves the
|
|
158
|
+
// breaker fired after exactly one failure instead of draining the batch.
|
|
159
|
+
expect(await countV1Rows()).toBe(20);
|
|
160
|
+
expect(broken.calls()).toBe(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("maxFailures scales the call budget linearly — higher threshold → more attempts", async () => {
|
|
164
|
+
// With an all-broken provider the job re-fetches the same rows each
|
|
165
|
+
// batch (nothing rotated, nothing excluded), so the breaker is
|
|
166
|
+
// eventually the ONLY thing that stops it. Raising maxFailures from 3
|
|
167
|
+
// to 25 must raise the call-count accordingly — proves the breaker
|
|
168
|
+
// honours its parameter rather than silently capping.
|
|
169
|
+
const broken = createBrokenUnwrapProvider();
|
|
170
|
+
|
|
171
|
+
await rotateJob({ batchSize: 10, maxFailures: 25 }, jobCtx(broken));
|
|
172
|
+
|
|
173
|
+
expect(await countV1Rows()).toBe(20);
|
|
174
|
+
expect(broken.calls()).toBe(25);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Event-shape contract for the tenantSecret aggregate + the
|
|
2
|
+
// tenantSecretRead side-stream. Executor-based writes (set/delete)
|
|
3
|
+
// produce auto-lifecycle events; get() writes a standalone read-audit
|
|
4
|
+
// event on a fresh aggregate-stream (one-event-per-read). This test
|
|
5
|
+
// pins both paths so a silent rename breaks here, not in a compliance
|
|
6
|
+
// audit query.
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
|
+
import {
|
|
11
|
+
createEnvMasterKeyProvider,
|
|
12
|
+
type MasterKeyProvider,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
14
|
+
import {
|
|
15
|
+
createTestUser,
|
|
16
|
+
pushTables,
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { eq } from "drizzle-orm";
|
|
21
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
22
|
+
import { createSecretsFeature } from "../feature";
|
|
23
|
+
import {
|
|
24
|
+
createSecretsContext,
|
|
25
|
+
secretReadSchema,
|
|
26
|
+
TENANT_SECRET_READ_EVENT,
|
|
27
|
+
} from "../secrets-context";
|
|
28
|
+
import { tenantSecretsTable } from "../table";
|
|
29
|
+
|
|
30
|
+
const admin = createTestUser({
|
|
31
|
+
id: "00000000-0000-4000-8000-000000000010",
|
|
32
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
33
|
+
roles: ["TenantAdmin"],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let stack: TestStack;
|
|
37
|
+
let provider: MasterKeyProvider;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
provider = createEnvMasterKeyProvider({
|
|
41
|
+
env: {
|
|
42
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
43
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
stack = await setupTestStack({
|
|
48
|
+
features: [createSecretsFeature()],
|
|
49
|
+
extraContext: ({ db }) => ({
|
|
50
|
+
secrets: createSecretsContext({ db, masterKeyProvider: provider }),
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
await pushTables(stack.db, { tenantSecretsTable });
|
|
54
|
+
await createEventsTable(stack.db);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await stack.cleanup();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
await stack.db.delete(eventsTable);
|
|
63
|
+
await stack.db.delete(tenantSecretsTable);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("tenantSecret lifecycle events", () => {
|
|
67
|
+
test("set-then-list writes one tenantSecret.created event", async () => {
|
|
68
|
+
await stack.http.writeOk(
|
|
69
|
+
"secrets:write:set",
|
|
70
|
+
{ key: "example.api.key", value: "secret-value-xyz" },
|
|
71
|
+
admin,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const created = await stack.db
|
|
75
|
+
.select()
|
|
76
|
+
.from(eventsTable)
|
|
77
|
+
.where(eq(eventsTable.type, "tenant-secret.created"));
|
|
78
|
+
expect(created.length).toBe(1);
|
|
79
|
+
// aggregateType stable; downstream MSPs filter by this.
|
|
80
|
+
expect(created[0]?.aggregateType).toBe("tenant-secret");
|
|
81
|
+
// Plaintext never lands on the event-stream — only the envelope.
|
|
82
|
+
const serialized = JSON.stringify(created[0]?.payload);
|
|
83
|
+
expect(serialized).not.toContain("secret-value-xyz");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("delete writes a tenantSecret.deleted event on the same stream", async () => {
|
|
87
|
+
await stack.http.writeOk(
|
|
88
|
+
"secrets:write:set",
|
|
89
|
+
{ key: "example.to.delete", value: "one-time" },
|
|
90
|
+
admin,
|
|
91
|
+
);
|
|
92
|
+
await stack.http.writeOk("secrets:write:delete", { key: "example.to.delete" }, admin);
|
|
93
|
+
|
|
94
|
+
const events = await stack.db
|
|
95
|
+
.select()
|
|
96
|
+
.from(eventsTable)
|
|
97
|
+
.where(eq(eventsTable.aggregateType, "tenant-secret"));
|
|
98
|
+
|
|
99
|
+
// Exactly 2 events on the same aggregate-stream: created + deleted.
|
|
100
|
+
expect(events.length).toBe(2);
|
|
101
|
+
expect(events.map((e) => e.type)).toEqual(["tenant-secret.created", "tenant-secret.deleted"]);
|
|
102
|
+
const ids = new Set(events.map((e) => e.aggregateId));
|
|
103
|
+
expect(ids.size).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("tenantSecretRead side-stream", () => {
|
|
108
|
+
test("read-event type constant is stable", () => {
|
|
109
|
+
// Downstream compliance exports match on this string. Silent rename
|
|
110
|
+
// would cost audit continuity.
|
|
111
|
+
expect(TENANT_SECRET_READ_EVENT).toBe("secrets:event:read");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("read-audit payload conforms to secretReadSchema", () => {
|
|
115
|
+
// Canonical shape — whoever touches the schema should update this
|
|
116
|
+
// test in lockstep.
|
|
117
|
+
expect(() =>
|
|
118
|
+
secretReadSchema.parse({
|
|
119
|
+
key: "example.key",
|
|
120
|
+
userId: admin.id,
|
|
121
|
+
handlerName: "billing:write:charge",
|
|
122
|
+
}),
|
|
123
|
+
).not.toThrow();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Feature-level integration test for secrets. Pins that the CRUD handlers
|
|
2
|
+
// actually encrypt end-to-end: set stores an envelope (no plaintext), list
|
|
3
|
+
// returns the redactedPreview only, get decrypts back. The sample
|
|
4
|
+
// (samples/secrets-demo) shows the broader rotation + cross-feature flow;
|
|
5
|
+
// this test covers just the feature's own handlers.
|
|
6
|
+
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
9
|
+
import {
|
|
10
|
+
createEnvMasterKeyProvider,
|
|
11
|
+
type MasterKeyProvider,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
13
|
+
import {
|
|
14
|
+
createTestUser,
|
|
15
|
+
pushTables,
|
|
16
|
+
setupTestStack,
|
|
17
|
+
type TestStack,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { and, eq } from "drizzle-orm";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
21
|
+
import { createSecretsFeature } from "../feature";
|
|
22
|
+
import { createSecretsContext } from "../secrets-context";
|
|
23
|
+
import { type StoredEnvelope, tenantSecretsTable } from "../table";
|
|
24
|
+
|
|
25
|
+
const admin = createTestUser({
|
|
26
|
+
id: "00000000-0000-4000-8000-000000000010",
|
|
27
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
28
|
+
roles: ["TenantAdmin"],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let stack: TestStack;
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
const provider: MasterKeyProvider = createEnvMasterKeyProvider({
|
|
35
|
+
env: {
|
|
36
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
37
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
stack = await setupTestStack({
|
|
42
|
+
features: [createSecretsFeature()],
|
|
43
|
+
extraContext: ({ db }) => ({
|
|
44
|
+
secrets: createSecretsContext({ db, masterKeyProvider: provider }),
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
// Post-ES: the pre-ES audit table is gone — read-audit rides on the
|
|
48
|
+
// events-table as tenantSecretRead domain-events. Only the projection
|
|
49
|
+
// table (tenant_secrets) still needs an explicit push here, since it
|
|
50
|
+
// belongs to an ES entity (and entity-tables aren't auto-pushed by
|
|
51
|
+
// setupTestStack).
|
|
52
|
+
await pushTables(stack.db, { tenant_secrets: tenantSecretsTable });
|
|
53
|
+
await createEventsTable(stack.db);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(async () => {
|
|
57
|
+
await stack.cleanup();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("secrets feature — CRUD round-trip", () => {
|
|
61
|
+
test("set + list + delete over HTTP", async () => {
|
|
62
|
+
// SET: encrypts and stores
|
|
63
|
+
await stack.http.writeOk(
|
|
64
|
+
"secrets:write:set",
|
|
65
|
+
{ key: "api.key.x", value: "this-is-secret-value-xyz" },
|
|
66
|
+
admin,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// LIST: preview only, never plaintext
|
|
70
|
+
const list = await stack.http.queryOk<
|
|
71
|
+
Array<{ key: string; redactedPreview: string | null; kekVersion: number }>
|
|
72
|
+
>("secrets:query:list", {}, admin);
|
|
73
|
+
const row = list.find((r) => r.key === "api.key.x");
|
|
74
|
+
expect(row).toBeDefined();
|
|
75
|
+
expect(row?.redactedPreview).not.toBe("this-is-secret-value-xyz");
|
|
76
|
+
expect(row?.kekVersion).toBe(1);
|
|
77
|
+
|
|
78
|
+
// DB row holds an envelope, no plaintext
|
|
79
|
+
const [dbRow] = await stack.db
|
|
80
|
+
.select()
|
|
81
|
+
.from(tenantSecretsTable)
|
|
82
|
+
.where(
|
|
83
|
+
and(
|
|
84
|
+
eq(tenantSecretsTable.tenantId, admin.tenantId),
|
|
85
|
+
eq(tenantSecretsTable.key, "api.key.x"),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
if (!dbRow) throw new Error("row missing");
|
|
89
|
+
const env = dbRow.envelope as StoredEnvelope;
|
|
90
|
+
expect(env.ciphertext).toBeTruthy();
|
|
91
|
+
expect(env.kekVersion).toBe(1);
|
|
92
|
+
expect(JSON.stringify(dbRow)).not.toContain("this-is-secret-value-xyz");
|
|
93
|
+
|
|
94
|
+
// DELETE: removes row
|
|
95
|
+
await stack.http.writeOk("secrets:write:delete", { key: "api.key.x" }, admin);
|
|
96
|
+
const afterDelete = await stack.http.queryOk<Array<{ key: string }>>(
|
|
97
|
+
"secrets:query:list",
|
|
98
|
+
{},
|
|
99
|
+
admin,
|
|
100
|
+
);
|
|
101
|
+
expect(afterDelete.some((r) => r.key === "api.key.x")).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("delete on missing key returns 404", async () => {
|
|
105
|
+
const err = await stack.http.writeErr("secrets:write:delete", { key: "never.set.this" }, admin);
|
|
106
|
+
expect(err.code).toBe("not_found");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("non-TenantAdmin cannot set", async () => {
|
|
110
|
+
const user = createTestUser({
|
|
111
|
+
id: "00000000-0000-4000-8000-000000000099",
|
|
112
|
+
tenantId: admin.tenantId,
|
|
113
|
+
roles: ["User"],
|
|
114
|
+
});
|
|
115
|
+
const err = await stack.http.writeErr("secrets:write:set", { key: "attack", value: "x" }, user);
|
|
116
|
+
expect(err.code).toBe("access_denied");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineFeature,
|
|
3
|
+
type FeatureDefinition,
|
|
4
|
+
type HandlerContext,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
|
+
import { deleteWrite } from "./handlers/delete.write";
|
|
9
|
+
import { listQuery } from "./handlers/list.query";
|
|
10
|
+
import { rotateJob } from "./handlers/rotate.job";
|
|
11
|
+
import { setWrite } from "./handlers/set.write";
|
|
12
|
+
import { secretReadSchema } from "./secrets-context";
|
|
13
|
+
import { tenantSecretEntity } from "./table";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
createSecretsContext,
|
|
17
|
+
type SecretsContext,
|
|
18
|
+
type SecretsContextOptions,
|
|
19
|
+
TENANT_SECRET_READ_EVENT,
|
|
20
|
+
} from "./secrets-context";
|
|
21
|
+
export { type StoredEnvelope, type StoredMetadata, tenantSecretsTable } from "./table";
|
|
22
|
+
|
|
23
|
+
// AppContext carries ctx.secrets via extraContext. requireSecretsContext
|
|
24
|
+
// wraps that raw context so every `.get(...)` call auto-includes the
|
|
25
|
+
// current user + handler as audit metadata — feature code can't forget
|
|
26
|
+
// to log the read (silent bypass of audit was the v1 gap).
|
|
27
|
+
export function requireSecretsContext(ctx: HandlerContext, handlerName: string): SecretsContext {
|
|
28
|
+
if (!ctx.secrets) {
|
|
29
|
+
throw new InternalError({
|
|
30
|
+
message:
|
|
31
|
+
`[${handlerName}] ctx.secrets missing — pass ` +
|
|
32
|
+
"createSecretsContext({db, masterKeyProvider}) via extraContext.secrets at boot.",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const raw = ctx.secrets;
|
|
36
|
+
const userId = ctx._userId;
|
|
37
|
+
if (!userId) {
|
|
38
|
+
throw new InternalError({
|
|
39
|
+
message: `[${handlerName}] ctx._userId missing — cannot audit secret reads without a caller identity.`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
get: (tenantId, key, overrideAudit) =>
|
|
44
|
+
raw.get(tenantId, key, overrideAudit ?? { userId, handlerName }),
|
|
45
|
+
set: raw.set.bind(raw),
|
|
46
|
+
delete: raw.delete.bind(raw),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createSecretsFeature(): FeatureDefinition {
|
|
51
|
+
return defineFeature("secrets", (r) => {
|
|
52
|
+
// ES entity: set/delete go through the executor, `tenantSecret.created/
|
|
53
|
+
// .updated/.deleted` events land on the aggregate stream. Reads fire a
|
|
54
|
+
// separate `tenantSecretRead` event per call (see secrets-context.get
|
|
55
|
+
// for the one-event-per-read rationale).
|
|
56
|
+
r.entity("tenant-secret", tenantSecretEntity);
|
|
57
|
+
|
|
58
|
+
// Read-audit domain-event. Registered here so ops tools + MSPs can
|
|
59
|
+
// discover the type; secrets-context.get parses payloads against
|
|
60
|
+
// `secretReadSchema` at write time because the low-level append() path
|
|
61
|
+
// skips ctx.appendEvent's schema-validation guard.
|
|
62
|
+
r.defineEvent("read", secretReadSchema);
|
|
63
|
+
|
|
64
|
+
// Per-tenant handlers (set/delete/list) run in the default tenant-scope,
|
|
65
|
+
// giving them the automatic ctx.db tenant-filter as extra defense.
|
|
66
|
+
// The rotation job deliberately reaches for ctx.db as DbConnection
|
|
67
|
+
// (raw, cross-tenant) because rotation is a deployment-wide operation —
|
|
68
|
+
// no feature-wide r.systemScope() needed.
|
|
69
|
+
r.writeHandler(setWrite);
|
|
70
|
+
r.writeHandler(deleteWrite);
|
|
71
|
+
r.queryHandler(listQuery);
|
|
72
|
+
// Manual-only by design: ops triggers rotation after a KEK version flip.
|
|
73
|
+
// BullMQ delivers to exactly one worker, so running it against a busy
|
|
74
|
+
// table on multiple instances is still safe.
|
|
75
|
+
r.job("rotate", { trigger: { manual: true } }, rotateJob);
|
|
76
|
+
|
|
77
|
+
// Pre-ES had a separate `retention-cleanup` job scrubbing the audit
|
|
78
|
+
// table on a compliance-driven schedule (90d default). Post-ES the
|
|
79
|
+
// read-audit lives on the events-table as tenantSecretRead events;
|
|
80
|
+
// retention for those flows through the framework-wide `pruneEvents`
|
|
81
|
+
// ops-tool (see docs/plans/architecture/event-dispatcher.md §retention).
|
|
82
|
+
// No per-feature retention-job anymore.
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { requireSecretsContext } from "../feature";
|
|
5
|
+
|
|
6
|
+
export const deleteWrite = defineWriteHandler({
|
|
7
|
+
name: "delete",
|
|
8
|
+
schema: z.object({
|
|
9
|
+
key: z.string().min(1).max(100),
|
|
10
|
+
}),
|
|
11
|
+
access: { roles: ["TenantAdmin"] },
|
|
12
|
+
handler: async (event, ctx) => {
|
|
13
|
+
const secrets = requireSecretsContext(ctx, "secrets:write:delete");
|
|
14
|
+
const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
|
|
15
|
+
deletedBy: event.user.id,
|
|
16
|
+
});
|
|
17
|
+
if (!removed) return failNotFound("tenant-secret", event.payload.key);
|
|
18
|
+
return { isSuccess: true, data: { key: event.payload.key } };
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { tenantSecretsTable } from "../table";
|
|
5
|
+
|
|
6
|
+
// Lists all secrets for the current tenant. Returns redactedPreview, never
|
|
7
|
+
// the plaintext. Decryption would be pointless here anyway — this is the
|
|
8
|
+
// TenantAdmin UI, not feature code that needs the value.
|
|
9
|
+
export const listQuery = defineQueryHandler({
|
|
10
|
+
name: "list",
|
|
11
|
+
schema: z.object({}),
|
|
12
|
+
access: { roles: ["TenantAdmin"] },
|
|
13
|
+
handler: async (event, ctx) => {
|
|
14
|
+
const rows = await ctx.db.raw
|
|
15
|
+
.select({
|
|
16
|
+
key: tenantSecretsTable.key,
|
|
17
|
+
kekVersion: tenantSecretsTable.kekVersion,
|
|
18
|
+
metadata: tenantSecretsTable.metadata,
|
|
19
|
+
lastRotatedAt: tenantSecretsTable.lastRotatedAt,
|
|
20
|
+
// Post-ES the projection uses the framework base-columns — `inserted_at`
|
|
21
|
+
// replaces the legacy `created_at`. Response stays on the `createdAt`
|
|
22
|
+
// key so Admin-UIs don't have to re-map.
|
|
23
|
+
createdAt: tenantSecretsTable.insertedAt,
|
|
24
|
+
})
|
|
25
|
+
.from(tenantSecretsTable)
|
|
26
|
+
.where(eq(tenantSecretsTable.tenantId, event.user.tenantId))
|
|
27
|
+
.orderBy(tenantSecretsTable.key);
|
|
28
|
+
|
|
29
|
+
return rows.map((r) => ({
|
|
30
|
+
key: r.key,
|
|
31
|
+
redactedPreview: r.metadata.redactedPreview ?? null,
|
|
32
|
+
hint: r.metadata.hint ?? null,
|
|
33
|
+
kekVersion: r.kekVersion,
|
|
34
|
+
lastRotatedAt: r.lastRotatedAt,
|
|
35
|
+
createdAt: r.createdAt,
|
|
36
|
+
}));
|
|
37
|
+
},
|
|
38
|
+
});
|