From a19d2cbae03dac1cf3aeda1a411a64d9cb0d2ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 19:55:06 +0200 Subject: [PATCH] refactor(settings): adopt environment-only runtime secret flow --- .../components/admin/SystemSettingsClient.tsx | 345 ++++++++++++------ docs/ai-excellence-due-diligence-roadmap.md | 29 +- .../0001-runtime-secret-provisioning.md | 89 +++++ docs/architecture-hardening-backlog.md | 1 + docs/audience-scoping-backlog.md | 6 +- docs/ci-cd-manual.md | 18 +- docs/cicd-target-architecture.md | 31 ++ docs/sdlc.md | 3 +- docs/security-architecture.md | 2 + .../src/__tests__/assistant-router.test.ts | 16 +- .../assistant-tools-import-export.test.ts | 62 ++++ .../settings-runtime-config-hardening.test.ts | 80 ++++ .../__tests__/system-settings-runtime.test.ts | 25 +- .../api/src/lib/system-settings-runtime.ts | 103 +++++- packages/api/src/router/assistant-tools.ts | 34 +- packages/api/src/router/assistant.ts | 1 + packages/api/src/router/settings.ts | 70 +++- tooling/deploy/.env.production.example | 5 + tooling/deploy/README.md | 9 +- 19 files changed, 757 insertions(+), 172 deletions(-) create mode 100644 docs/architecture-decision-records/0001-runtime-secret-provisioning.md diff --git a/apps/web/src/components/admin/SystemSettingsClient.tsx b/apps/web/src/components/admin/SystemSettingsClient.tsx index 0b5774d..7114494 100644 --- a/apps/web/src/components/admin/SystemSettingsClient.tsx +++ b/apps/web/src/components/admin/SystemSettingsClient.tsx @@ -16,6 +16,13 @@ const CHECKBOX_ROW_CLASS = "flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300"; type Provider = "openai" | "azure"; +type RuntimeSecretSource = "environment" | "database" | "none"; +type RuntimeSecretStatus = { + configured: boolean; + activeSource: RuntimeSecretSource; + hasStoredValue: boolean; + envVarNames: string[]; +}; const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const; type SystemRole = (typeof ALL_ROLES)[number]; @@ -60,12 +67,92 @@ function parseAzureUrl(raw: string): ParsedAzureUrl | null { } } +function getSecretStatusTone(source: RuntimeSecretSource): string { + if (source === "environment") { + return "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300"; + } + if (source === "database") { + return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300"; + } + return "border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300"; +} + +function getSecretStatusLabel(source: RuntimeSecretSource): string { + if (source === "environment") return "Environment"; + if (source === "database") return "Legacy DB"; + return "Missing"; +} + +function RuntimeSecretCard({ + title, + description, + secret, + optionalNote, +}: { + title: string; + description: string; + secret: RuntimeSecretStatus; + optionalNote?: string; +}) { + return ( +
+
+
+

{title}

+

+ {description} +

+
+ + {getSecretStatusLabel(secret.activeSource)} + +
+ +
+

+ Runtime status:{" "} + + {secret.configured ? "configured" : "not configured"} + +

+

+ Provision via{" "} + {secret.envVarNames.map((name) => ( + + {name} + + ))} +

+ {optionalNote ?

{optionalNote}

: null} + {secret.activeSource === "environment" && secret.hasStoredValue ? ( +

+ An older database value still exists, but the environment value currently overrides it. +

+ ) : null} + {secret.activeSource === "database" ? ( +

+ Runtime currently still depends on a legacy database secret. Migrate it to deployment + secrets and clear the stored value afterwards. +

+ ) : null} + {secret.activeSource === "none" ? ( +

+ No runtime secret is available yet. The related integration will stay disabled or fail + connectivity checks until the deployment secret is set. +

+ ) : null} +
+
+ ); +} + export function SystemSettingsClient() { const [provider, setProvider] = useState("openai"); const [endpoint, setEndpoint] = useState(""); const [model, setModel] = useState(""); const [apiVersion, setApiVersion] = useState("2025-01-01-preview"); - const [apiKey, setApiKey] = useState(""); const [maxTokens, setMaxTokens] = useState(2000); const [temperature, setTemperature] = useState(1); const [summaryPrompt, setSummaryPrompt] = useState(""); @@ -73,7 +160,6 @@ export function SystemSettingsClient() { const [testResult, setTestResult] = useState<{ ok: boolean; error?: string; - raw?: string | null; } | null>(null); const [urlPasteValue, setUrlPasteValue] = useState(""); const [urlParseError, setUrlParseError] = useState(false); @@ -94,12 +180,10 @@ export function SystemSettingsClient() { // DALL-E settings const [dalleDeployment, setDalleDeployment] = useState(""); const [dalleEndpoint, setDalleEndpoint] = useState(""); - const [dalleApiKey, setDalleApiKey] = useState(""); // Gemini / Image generation settings type ImageProvider = "dalle" | "gemini"; const [imageProvider, setImageProvider] = useState("dalle"); - const [geminiApiKey, setGeminiApiKey] = useState(""); const [geminiModel, setGeminiModel] = useState(""); const [imageSaved, setImageSaved] = useState(false); @@ -107,7 +191,6 @@ export function SystemSettingsClient() { const [smtpHost, setSmtpHost] = useState(""); const [smtpPort, setSmtpPort] = useState(587); const [smtpUser, setSmtpUser] = useState(""); - const [smtpPassword, setSmtpPassword] = useState(""); const [smtpFrom, setSmtpFrom] = useState(""); const [smtpTls, setSmtpTls] = useState(true); const [smtpSaved, setSmtpSaved] = useState(false); @@ -118,7 +201,6 @@ export function SystemSettingsClient() { // Global anonymization const [anonymizationEnabled, setAnonymizationEnabled] = useState(false); const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de"); - const [anonymizationSeed, setAnonymizationSeed] = useState(""); const [anonymizationSaved, setAnonymizationSaved] = useState(false); // Vacation defaults @@ -128,7 +210,9 @@ export function SystemSettingsClient() { // Timeline const [undoMaxSteps, setUndoMaxSteps] = useState(50); const [timelineSaved, setTimelineSaved] = useState(false); + const [legacyCleanupResult, setLegacyCleanupResult] = useState(null); + const utils = trpc.useUtils(); const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, { staleTime: 0, }); @@ -163,7 +247,6 @@ export function SystemSettingsClient() { // Global anonymization setAnonymizationEnabled(settings.anonymizationEnabled ?? false); setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de"); - setAnonymizationSeed(""); // Vacation setVacationDefaultDays(settings.vacationDefaultDays ?? 28); // Timeline @@ -171,6 +254,10 @@ export function SystemSettingsClient() { } }, [settings]); + function invalidateSystemSettings() { + void utils.settings.getSystemSettings.invalidate(); + } + function handleUrlPaste(raw: string) { setUrlPasteValue(raw); if (!raw) { @@ -196,6 +283,8 @@ export function SystemSettingsClient() { onSuccess: () => { setSaved(true); setTestResult(null); + setLegacyCleanupResult(null); + invalidateSystemSettings(); setTimeout(() => setSaved(false), 3000); }, }); @@ -208,6 +297,7 @@ export function SystemSettingsClient() { const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({ onSuccess: () => { setScoreSaved(true); + invalidateSystemSettings(); setTimeout(() => setScoreSaved(false), 3000); }, }); @@ -220,6 +310,8 @@ export function SystemSettingsClient() { onSuccess: () => { setSmtpSaved(true); setSmtpTestResult(null); + setLegacyCleanupResult(null); + invalidateSystemSettings(); setTimeout(() => setSmtpSaved(false), 3000); }, }); @@ -232,6 +324,8 @@ export function SystemSettingsClient() { const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({ onSuccess: () => { setAnonymizationSaved(true); + setLegacyCleanupResult(null); + invalidateSystemSettings(); setTimeout(() => setAnonymizationSaved(false), 3000); }, }); @@ -239,6 +333,7 @@ export function SystemSettingsClient() { const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({ onSuccess: () => { setVacationSaved(true); + invalidateSystemSettings(); setTimeout(() => setVacationSaved(false), 3000); }, }); @@ -246,6 +341,7 @@ export function SystemSettingsClient() { const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({ onSuccess: () => { setTimelineSaved(true); + invalidateSystemSettings(); setTimeout(() => setTimelineSaved(false), 3000); }, }); @@ -253,10 +349,26 @@ export function SystemSettingsClient() { const saveImageMutation = trpc.settings.updateSystemSettings.useMutation({ onSuccess: () => { setImageSaved(true); + setLegacyCleanupResult(null); + invalidateSystemSettings(); setTimeout(() => setImageSaved(false), 3000); }, }); + const clearRuntimeSecretsMutation = trpc.settings.clearStoredRuntimeSecrets.useMutation({ + onSuccess: (data) => { + setLegacyCleanupResult( + data.clearedFields.length > 0 + ? `Cleared ${data.clearedFields.length} legacy database secret field${data.clearedFields.length === 1 ? "" : "s"}.` + : "No legacy database secrets were left to clear.", + ); + invalidateSystemSettings(); + }, + onError: (error) => { + setLegacyCleanupResult(error.message); + }, + }); + const [geminiTestResult, setGeminiTestResult] = useState<{ ok: boolean; model?: string; error?: string } | null>(null); const testGeminiMut = trpc.settings.testGeminiConnection.useMutation({ onSuccess: (data) => setGeminiTestResult(data as any), @@ -268,7 +380,6 @@ export function SystemSettingsClient() { smtpHost: smtpHost || undefined, smtpPort, smtpUser: smtpUser || undefined, - ...(smtpPassword ? { smtpPassword } : {}), smtpFrom: smtpFrom || undefined, smtpTls, }); @@ -288,9 +399,7 @@ export function SystemSettingsClient() { // DALL-E fields azureDalleDeployment: dalleDeployment || undefined, azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined, - ...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}), // Gemini fields - ...(geminiApiKey ? { geminiApiKey } : {}), geminiModel: geminiModel || undefined, }); } @@ -299,7 +408,6 @@ export function SystemSettingsClient() { saveAnonymizationMutation.mutate({ anonymizationEnabled, anonymizationDomain: anonymizationDomain.trim() || "superhartmut.de", - ...(anonymizationSeed.trim() ? { anonymizationSeed: anonymizationSeed.trim() } : {}), anonymizationMode: "global", }); } @@ -330,10 +438,22 @@ export function SystemSettingsClient() { aiMaxCompletionTokens: maxTokens, aiTemperature: temperature, aiSummaryPrompt: summaryPrompt || undefined, - ...(apiKey ? { azureOpenAiApiKey: apiKey } : {}), }); } + function handleClearLegacyRuntimeSecrets() { + if ( + typeof window !== "undefined" + && !window.confirm( + "Clear all legacy runtime secrets from database storage? Environment-based deployment secrets must already be configured.", + ) + ) { + return; + } + + clearRuntimeSecretsMutation.mutate(); + } + if (isLoading) { return (
@@ -342,6 +462,16 @@ export function SystemSettingsClient() { ); } + if (!settings) { + return ( +
+
+ System settings could not be loaded. +
+
+ ); + } + return (
@@ -353,6 +483,46 @@ export function SystemSettingsClient() {
+ {settings.legacyStoredSecretFields.length ? ( +
+
+
+

+ Legacy Runtime Secrets Detected +

+

+ This installation still has database-stored runtime secrets. New secrets are no + longer persisted in the application. Move them to deployment-level secret + management first, then clear the legacy residue here. +

+

+ Affected fields:{" "} + {settings.legacyStoredSecretFields.map((field) => ( + + {field} + + ))} +

+ {legacyCleanupResult ? ( +

+ {legacyCleanupResult} +

+ ) : null} +
+ +
+
+ ) : null} +

@@ -495,33 +665,20 @@ export function SystemSettingsClient() {

)} - {/* API key */} -
- - setApiKey(e.target.value)} - autoComplete="new-password" - /> -

- {provider === "openai" - ? "Your secret key from platform.openai.com → API keys. Starts with sk-." - : "One of the two keys from Azure Portal → your resource → Keys and Endpoint."} - {settings?.hasApiKey && " Leave blank to keep the existing key."} -

-
+ {/* Test result */} {testResult && ( @@ -541,16 +698,6 @@ export function SystemSettingsClient() {

Connection failed: {testResult.error}

- {testResult.raw && ( -
- - Show raw error - -
-                        {testResult.raw}
-                      
-
- )}
)}
@@ -1052,9 +1199,9 @@ export function SystemSettingsClient() { {/* ── Image Generation ────────────────────────────────── */} -
-
-

+
+
+

Image Generation

@@ -1127,30 +1274,15 @@ export function SystemSettingsClient() { placeholder="Leave empty to use same endpoint as chat" />

- -
- - setDalleApiKey(e.target.value)} - placeholder="Leave empty to use same API key as chat" - autoComplete="new-password" - /> -
)}
- {settings?.hasDalleApiKey && ( -

A separate DALL-E API key is stored.

- )} +

)} @@ -1159,24 +1291,6 @@ export function SystemSettingsClient() {

Google Gemini Configuration

-
- - setGeminiApiKey(e.target.value)} - placeholder={settings?.hasGeminiApiKey ? "•••••••• (key is stored)" : "Enter Gemini API key"} - autoComplete="new-password" - /> - {settings?.hasGeminiApiKey && !geminiApiKey && ( -

API key is stored.

- )} -
+
)} @@ -1277,24 +1397,6 @@ export function SystemSettingsClient() { autoComplete="off" />
-
- - setSmtpPassword(e.target.value)} - placeholder="••••••••" - autoComplete="new-password" - /> -
+ +
+ +
High-LCR resources receive more iconic characters first. Real EIDs and emails are never rewritten in Prisma. diff --git a/docs/ai-excellence-due-diligence-roadmap.md b/docs/ai-excellence-due-diligence-roadmap.md index 8f04dfa..bfd4040 100644 --- a/docs/ai-excellence-due-diligence-roadmap.md +++ b/docs/ai-excellence-due-diligence-roadmap.md @@ -11,7 +11,7 @@ At the same time, the codebase still carries several risks that are typical of f 1. some critical cross-cutting concerns are only partially productized 2. several files and routers have grown beyond comfortable ownership size -3. runtime configuration and secret handling are still too application-database centric +3. runtime secret handling is now materially cleaner, but the repo still needs to standardize the operational source of truth around that model 4. the current operational model is improving, but not yet fully standardized 5. production-grade multi-instance safeguards are not complete yet @@ -47,10 +47,10 @@ The previously critical SSE and browser parser coverage issues were addressed du Evidence: [assistant-tools.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/assistant-tools.ts), [resource.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/resource.ts), [allocation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/allocation.ts), [timeline.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/timeline.ts), [vacation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/vacation.ts), and large frontend files such as [SystemSettingsClient.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/admin/SystemSettingsClient.tsx) and [TimelineProjectPanel.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/timeline/TimelineProjectPanel.tsx) are each well past the size where safe ownership stays easy. Risk: AI-generated changes become harder to review, humans lose local reasoning context, and regressions become more likely. -2. Secret handling is still application-database centric. - Evidence: system settings mutate and persist API keys and SMTP credentials in [settings.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/settings.ts). - Risk: operational secrets remain too coupled to the main app data plane for a gold-standard project. - Update: runtime resolution is now env-first for the active secret consumers, but persistence is still transitional and should be reduced further. +2. Runtime secret policy is mostly corrected, but deploy standardization still has to catch up. + Evidence: runtime resolution and admin flows now treat environment-backed secrets as the preferred source in [settings.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/settings.ts), [system-settings-runtime.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/lib/system-settings-runtime.ts), and [SystemSettingsClient.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/admin/SystemSettingsClient.tsx). + Risk: a strong secret policy is only fully effective once staging and production provisioning use one canonical deployment path and operators clear remaining legacy database copies. + Update: the application no longer persists new operational secret values through admin settings; the remaining work is rollout discipline and cleanup completion. 3. Least-privilege is materially better documented now, but it still needs long-lived enforcement rather than relying mainly on one hardening batch. Evidence: the route audience model is now explicit in [route-access-matrix.md](/home/hartmut/Documents/Copilot/capakraken/docs/route-access-matrix.md) and backed by multiple focused auth tests, but the remaining guarantee still depends on continuing test coverage and architecture guardrails as new routes evolve. @@ -80,9 +80,9 @@ This is materially better than a typical startup CRUD app and already has the bo ### Security Posture -`7/10` +`7.5/10` -There are good foundations, and the most obvious real-time and comment-visibility gaps were closed, but secrets policy and long-lived least-privilege enforcement still need structural work. +There are good foundations, and the most obvious real-time, comment-visibility, and runtime-secret-policy gaps were closed, but long-lived least-privilege enforcement and operational standardization still need structural work. ### Maintainability @@ -124,8 +124,8 @@ Goals: - Keep SSE audience scoping under test and CI guardrails. - Keep hardened spreadsheet parser boundaries under regression coverage. - Treat the route access matrix and narrowed auth slices as maintained architecture contracts. -- Move production secrets out of regular application settings, or add an interim encrypted-secrets layer with clear migration path. - Status: in progress. Runtime consumers now prefer environment overrides; the remaining gap is eliminating or encrypting compatibility persistence in the admin settings path. +- Enforce the environment-only runtime secret policy operationally and clear remaining legacy database secret residue. + Status: mostly completed in code. Runtime consumers prefer environment values, admin updates no longer store new secret material, and operators now need to finish rollout/bootstrap documentation plus cleanup of old database copies. Definition of done: @@ -222,12 +222,11 @@ Artifacts to add: ## Suggested Order Of Execution -1. secrets policy -2. router/component decomposition -3. architecture fitness checks in CI -4. full operational standardization -5. production-grade rate limiting -6. performance hotspot reduction +1. router/component decomposition +2. architecture fitness checks in CI +3. full operational standardization +4. production-grade rate limiting +5. performance hotspot reduction ## Success Criteria For The Next 60 Days diff --git a/docs/architecture-decision-records/0001-runtime-secret-provisioning.md b/docs/architecture-decision-records/0001-runtime-secret-provisioning.md new file mode 100644 index 0000000..f53f954 --- /dev/null +++ b/docs/architecture-decision-records/0001-runtime-secret-provisioning.md @@ -0,0 +1,89 @@ +# ADR 0001: Runtime Secret Provisioning + +**Status:** Accepted +**Date:** 2026-03-30 + +## Context + +CapaKraken historically allowed some operational runtime secrets to be persisted through `SystemSettings`. + +That included values such as: + +- primary AI API credentials +- dedicated DALL-E credentials +- Gemini credentials +- SMTP password +- anonymization seed + +This was convenient for fast iteration, but it coupled operational secret material to the main application data plane and blurred the line between configuration metadata and deployment secrets. + +The project is moving toward a production model where the running artifact should be immutable and environment-driven. That model is weakened if operators can still rotate runtime secrets through normal application writes. + +## Decision + +Operational runtime secrets must be provisioned outside the application database. + +Allowed sources: + +- deployment environment variables +- host-level secret files such as `.env.production` on self-managed infrastructure +- platform secret managers or encrypted environment facilities + +Disallowed source for new secret values: + +- admin updates that write runtime secrets into `SystemSettings` + +`SystemSettings` remains valid for non-secret runtime metadata such as: + +- provider selection +- endpoints +- model names +- SMTP host/user/from settings +- anonymization mode and domain + +Legacy secret values that already exist in `SystemSettings` may still be read during migration for compatibility, but they are not the target state and should be cleared after equivalent deployment secrets are provisioned. + +## Consequences + +Positive: + +- production updates become more predictable because images and runtime secrets are managed as separate deployment concerns +- operational secrets stop depending on ordinary application write paths +- admin tooling can expose status and diagnostics without pretending to be the system of record for secrets +- secret rotation becomes an infrastructure operation rather than a product mutation + +Tradeoffs: + +- smaller self-managed installs need a disciplined host bootstrap process +- operators must understand that updating app settings is no longer sufficient for secret rotation +- migration requires visibility into which secrets are still backed by database residue + +## Implementation Notes + +The implementation should follow these rules: + +1. runtime consumers resolve supported secret values from environment first +2. admin settings reads expose presence and source status, not secret values +3. admin settings updates ignore incoming secret payloads +4. the UI explains the expected environment variables for each runtime secret +5. a dedicated cleanup action removes legacy database-stored secret values after migration + +## Operational Guidance + +For staging and production: + +1. provision runtime secrets on the host or platform before starting a new release +2. deploy the already-built application image +3. restart the application so the new process reads the current secret source +4. verify runtime status in admin settings +5. clear any leftover legacy database secret values once the environment-backed source is confirmed + +Secret rotation should follow the same model. In most cases, no application data mutation is needed. The operator updates the deployment secret source and restarts or redeploys the app. + +## Follow-up + +Still required after this decision: + +- complete the canonical image-based staging/production rollout +- ensure staging and production hosts both use the same secret provisioning rules +- periodically verify that legacy database secret fields remain empty diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index 58117d4..cf55fc5 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -20,6 +20,7 @@ - comment entity support is now centralized across shared constants, API registry policy, assistant tool metadata, and the web comment target API without pretending a second consumer exists - `resource` is now onboarded as the second real comment entity, reusing the same ownership and staff-visibility rules as the resource detail route - comment mention autocomplete now uses a dedicated entity-scoped API route instead of inheriting the narrower `user.listAssignable` audience +- runtime secret handling is now environment-first end to end: admin updates no longer persist new operational secrets, runtime status is surfaced explicitly, and legacy database secret copies can be cleared through a dedicated cleanup path ## Next Up diff --git a/docs/audience-scoping-backlog.md b/docs/audience-scoping-backlog.md index 74bbdca..b80cdcc 100644 --- a/docs/audience-scoping-backlog.md +++ b/docs/audience-scoping-backlog.md @@ -52,9 +52,9 @@ These files already have unrelated local edits. Audience parity work that would ## Next Major Themes -1. convert the still-open runtime secret model away from application-database centric storage -2. add broader authorization regression coverage and long-lived guardrails around the narrowed route audiences -3. reduce oversized routers and UI ownership surfaces so audience rules stay reviewable +1. add broader authorization regression coverage and long-lived guardrails around the narrowed route audiences +2. reduce oversized routers and UI ownership surfaces so audience rules stay reviewable +3. keep runtime secret policy and role/audience boundaries aligned as adjacent architecture guardrails ## Slice Definition diff --git a/docs/ci-cd-manual.md b/docs/ci-cd-manual.md index 8cd410e..25d27fd 100644 --- a/docs/ci-cd-manual.md +++ b/docs/ci-cd-manual.md @@ -154,6 +154,11 @@ SMTP_PORT=587 SMTP_USER=notifications@example.com SMTP_PASSWORD= SMTP_FROM=CapaKraken +OPENAI_API_KEY= +AZURE_OPENAI_API_KEY= +AZURE_DALLE_API_KEY= +GEMINI_API_KEY= +ANONYMIZATION_SEED= ``` Generate a secure `NEXTAUTH_SECRET`: @@ -162,6 +167,12 @@ Generate a secure `NEXTAUTH_SECRET`: openssl rand -base64 32 ``` +Runtime secret policy: + +- production secrets are injected through the deployment environment or host secret store +- admin settings must not be used to enter or rotate AI, SMTP, or anonymization secrets +- the admin UI is only for status checks and cleanup of legacy database-stored secret values + --- ## 5. Deployment @@ -169,13 +180,13 @@ openssl rand -base64 32 ### docker-compose (simplest) ```bash -# On your server +# On your server, after updating the host-side env/secret source git pull docker compose -f docker-compose.prod.yml up -d --build # Run database migrations docker compose -f docker-compose.prod.yml exec app \ - pnpm db:push + pnpm --filter @capakraken/db db:migrate:deploy # Seed initial data (first deployment only) docker compose -f docker-compose.prod.yml exec app \ @@ -193,6 +204,7 @@ git pull origin main pnpm install pnpm db:generate pnpm db:validate +pnpm --filter @capakraken/db db:migrate:deploy pnpm --filter @capakraken/web exec next build rm -rf apps/web/.next/cache # clear stale cache @@ -203,6 +215,8 @@ PORT=3100 pnpm --filter @capakraken/web start & Use the repo-level `pnpm db:*` commands for Prisma/database operations. They load `.env`, `.env.local`, `.env.$NODE_ENV`, and `.env.$NODE_ENV.local` automatically before invoking Prisma. +If you rotate runtime secrets during a manual deploy, update the host-side environment source first, then restart the app so the new process reads the updated values. Do not patch those values through admin settings. + ### nginx configuration The existing nginx reverse proxy should forward to port 3100: diff --git a/docs/cicd-target-architecture.md b/docs/cicd-target-architecture.md index b1e7fb3..ccbec44 100644 --- a/docs/cicd-target-architecture.md +++ b/docs/cicd-target-architecture.md @@ -30,6 +30,7 @@ That removes "works on the server but not in CI" drift and makes rollbacks much The existing `CI` workflow continues to validate: +- architecture guardrails for SSE audience scoping - typecheck - lint - unit tests @@ -38,6 +39,12 @@ The existing `CI` workflow continues to validate: This remains the quality gate before merge. +The guardrail step currently enforces three invariants: + +- no role-based SSE audience fan-out in [event-bus.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/sse/event-bus.ts) +- no role-derived subscription audiences in [subscription-policy.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/sse/subscription-policy.ts) +- no client-provided audience parsing in [route.ts](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/app/api/sse/timeline/route.ts) + ### 2. Image Build The new manual workflow [release-image.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/release-image.yml) builds two images from [Dockerfile.prod](/home/hartmut/Documents/Copilot/capakraken/Dockerfile.prod): @@ -149,6 +156,28 @@ NEXTAUTH_SECRET= GitHub Actions only injects the short-lived image references through `deploy.env`. The deploy script then loads both files before calling Docker Compose, so compose interpolation and container runtime env use the same source of truth. +### Runtime Secret Provisioning Policy + +Production and staging secrets should be provisioned at the host or platform-secret layer, not through admin mutations and not through application database writes. + +That includes at least: + +```env +OPENAI_API_KEY= +AZURE_OPENAI_API_KEY= +AZURE_DALLE_API_KEY= +GEMINI_API_KEY= +SMTP_PASSWORD= +ANONYMIZATION_SEED= +``` + +Operational rule: + +- keep these values in `.env.production` only for smaller self-managed hosts, or preferably in the host's secret manager / encrypted environment facility +- do not rotate or patch these values through `SystemSettings` +- use the admin settings page only to verify runtime source/status and to clear leftover legacy database copies +- after migration, legacy database secret fields should be empty in both staging and production + ## Database Policy For release environments, use: @@ -183,6 +212,8 @@ The intended production update path is: That means the production host no longer builds from Git. It only receives a versioned image and starts it after migrations complete. +The same principle applies to secrets: the running container reads them from the deployment environment at start time, so an update only needs a new image tag unless secret material itself is being rotated. + ## Current Status The repository now contains the CI/CD scaffolding, but the existing manual production setup remains untouched: diff --git a/docs/sdlc.md b/docs/sdlc.md index f8b61bb..c5b717f 100644 --- a/docs/sdlc.md +++ b/docs/sdlc.md @@ -46,7 +46,8 @@ See `.github/PULL_REQUEST_TEMPLATE.md` for the security checklist that must be c - No secrets in source code - Environment variables for all credentials (`DATABASE_URL`, API keys) -- `SystemSettings` table for runtime-configurable secrets (AI keys, SMTP credentials) +- Runtime application secrets are provisioned outside the application data plane through environment variables or a deployment-time secret manager +- `SystemSettings` may still contain legacy secret residue during migration, but new secret values must not be written there - `.env` files excluded from version control via `.gitignore` ## Incident Response diff --git a/docs/security-architecture.md b/docs/security-architecture.md index bdd9b99..ad95936 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -65,6 +65,8 @@ publicProcedure - Runtime secrets now resolve env-first for AI, Gemini, SMTP, and anonymization seed values. Database-backed `SystemSettings` values remain transitional compatibility storage, not the preferred production source of truth. - Recommended runtime overrides: `OPENAI_API_KEY`, `AZURE_OPENAI_API_KEY`, `AZURE_DALLE_API_KEY`, `GEMINI_API_KEY`, `SMTP_PASSWORD`, `ANONYMIZATION_SEED` - Admin settings reads expose only presence flags (`hasApiKey`, `hasSmtpPassword`, `hasGeminiApiKey`) instead of returning secret values to the browser, and those flags also reflect environment-backed runtime overrides +- The admin settings mutation no longer persists new secret values into `SystemSettings`; secret inputs must be provisioned through environment or a deployment-time secret manager, and legacy database copies can be cleared explicitly +- The admin UI now exposes runtime secret source/status plus an explicit "clear legacy DB secrets" cleanup path so operators can complete the migration without direct database writes ### Anonymization diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index 32f6613..02967ce 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -618,10 +618,12 @@ describe("assistant router tool gating", () => { const adminNames = getToolNames([], SystemRole.ADMIN); const userNames = getToolNames([], SystemRole.USER); + const managerNames = getToolNames([], SystemRole.MANAGER); expect(adminNames).toContain("get_system_settings"); expect(adminNames).toContain("update_system_settings"); expect(adminNames).toContain("test_ai_connection"); expect(adminNames).toContain("test_smtp_connection"); + expect(adminNames).toContain("clear_stored_runtime_secrets"); expect(adminNames).toContain("test_gemini_connection"); expect(adminNames).toContain("list_system_role_configs"); expect(adminNames).toContain("update_system_role_config"); @@ -632,12 +634,22 @@ describe("assistant router tool gating", () => { expect(adminNames).toContain("delete_webhook"); expect(adminNames).toContain("test_webhook"); expect(adminNames).toContain("get_ai_configured"); - expect(adminNames).toContain("list_system_role_configs"); + + expect(managerNames).not.toContain("get_system_settings"); + expect(managerNames).not.toContain("update_system_settings"); + expect(managerNames).not.toContain("clear_stored_runtime_secrets"); + expect(managerNames).not.toContain("test_ai_connection"); + expect(managerNames).not.toContain("get_ai_configured"); + expect(managerNames).not.toContain("list_system_role_configs"); + expect(managerNames).not.toContain("update_system_role_config"); + expect(managerNames).not.toContain("list_webhooks"); + expect(managerNames).not.toContain("create_webhook"); expect(userNames).not.toContain("get_system_settings"); expect(userNames).not.toContain("update_system_settings"); expect(userNames).not.toContain("test_ai_connection"); expect(userNames).not.toContain("get_ai_configured"); + expect(userNames).not.toContain("clear_stored_runtime_secrets"); expect(userNames).not.toContain("list_system_role_configs"); expect(userNames).not.toContain("update_system_role_config"); expect(userNames).not.toContain("list_webhooks"); @@ -996,6 +1008,8 @@ describe("assistant router tool gating", () => { expect(toolDescriptions.get("update_system_settings")).toContain("Always confirm first"); expect(toolDescriptions.get("get_ai_configured")).toContain("Admin role"); expect(toolDescriptions.get("list_system_role_configs")).toContain("Admin role"); + expect(toolDescriptions.get("update_system_settings")).toContain("Runtime secrets must be provisioned"); + expect(toolDescriptions.get("clear_stored_runtime_secrets")).toContain("deployment secret management"); expect(toolDescriptions.get("update_system_role_config")).toContain("Admin role"); expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked"); expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first"); diff --git a/packages/api/src/__tests__/assistant-tools-import-export.test.ts b/packages/api/src/__tests__/assistant-tools-import-export.test.ts index 32a68f2..7488df6 100644 --- a/packages/api/src/__tests__/assistant-tools-import-export.test.ts +++ b/packages/api/src/__tests__/assistant-tools-import-export.test.ts @@ -150,6 +150,7 @@ function createToolContext( describe("assistant import/export and dispo tools", () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); apiRateLimiter.reset(); totpValidateMock.mockReset(); vi.mocked(approveEstimateVersion).mockReset(); @@ -288,6 +289,67 @@ describe("assistant import/export and dispo tools", () => { expect(JSON.parse(result.content)).toEqual({ configured: true }); }); + it("treats environment-backed AI configuration as configured for assistant checks", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-secret"); + + const ctx = createToolContext( + { + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + azureOpenAiApiKey: null, + }), + }, + }, + { userRole: SystemRole.USER }, + ); + + const result = await executeTool("get_ai_configured", "{}", ctx); + + expect(JSON.parse(result.content)).toEqual({ configured: true }); + }); + + it("clears legacy runtime secrets through the real settings router path", async () => { + const findUnique = vi.fn().mockResolvedValue({ + azureOpenAiApiKey: "db-openai", + azureDalleApiKey: null, + geminiApiKey: "db-gemini", + smtpPassword: null, + anonymizationSeed: "db-seed", + }); + const update = vi.fn().mockResolvedValue({ id: "singleton" }); + const auditCreate = vi.fn().mockResolvedValue(undefined); + const ctx = createToolContext( + { + systemSettings: { + findUnique, + update, + }, + auditLog: { + create: auditCreate, + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool("clear_stored_runtime_secrets", "{}", ctx); + + expect(JSON.parse(result.content)).toEqual({ + ok: true, + clearedFields: ["azureOpenAiApiKey", "geminiApiKey", "anonymizationSeed"], + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "singleton" }, + data: { + azureOpenAiApiKey: null, + geminiApiKey: null, + anonymizationSeed: null, + }, + }); + expect(auditCreate).toHaveBeenCalled(); + }); + it("masks webhook secrets in assistant responses", async () => { const ctx = createToolContext( { diff --git a/packages/api/src/__tests__/settings-runtime-config-hardening.test.ts b/packages/api/src/__tests__/settings-runtime-config-hardening.test.ts index b0b6bdf..bd040f5 100644 --- a/packages/api/src/__tests__/settings-runtime-config-hardening.test.ts +++ b/packages/api/src/__tests__/settings-runtime-config-hardening.test.ts @@ -118,6 +118,9 @@ describe("runtime config hardening", () => { expect(result.hasApiKey).toBe(true); expect(result.hasSmtpPassword).toBe(true); expect(result.hasGeminiApiKey).toBe(true); + expect(result.runtimeSecrets.azureOpenAiApiKey.activeSource).toBe("environment"); + expect(result.runtimeSecrets.smtpPassword.activeSource).toBe("environment"); + expect(result.legacyStoredSecretFields).toEqual([]); }); it("prefers environment API keys during AI connection tests", async () => { @@ -150,4 +153,81 @@ describe("runtime config hardening", () => { }), ); }); + + it("does not persist incoming secret fields through updateSystemSettings", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const upsert = vi.fn().mockResolvedValue({}); + const caller = createAdminCaller({ + systemSettings: { + findUnique, + upsert, + }, + }); + + const result = await caller.updateSystemSettings({ + azureOpenAiApiKey: "sk-should-not-store", + smtpPassword: "smtp-should-not-store", + geminiApiKey: "gemini-should-not-store", + azureDalleApiKey: "dalle-should-not-store", + anonymizationSeed: "seed-should-not-store", + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + }); + + expect(result).toEqual({ + ok: true, + ignoredSecretFields: [ + "azureOpenAiApiKey", + "smtpPassword", + "anonymizationSeed", + "azureDalleApiKey", + "geminiApiKey", + ], + secretStorageMode: "environment-only", + }); + expect(upsert).toHaveBeenCalledWith({ + where: { id: "singleton" }, + create: { + id: "singleton", + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + }, + update: { + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + }, + }); + }); + + it("can clear legacy runtime secrets from database storage", async () => { + const findUnique = vi.fn().mockResolvedValue({ + azureOpenAiApiKey: "db-key", + azureDalleApiKey: null, + geminiApiKey: "db-gemini", + smtpPassword: "db-smtp", + anonymizationSeed: null, + }); + const update = vi.fn().mockResolvedValue({}); + const caller = createAdminCaller({ + systemSettings: { + findUnique, + update, + }, + }); + + const result = await caller.clearStoredRuntimeSecrets(); + + expect(result).toEqual({ + ok: true, + clearedFields: ["azureOpenAiApiKey", "geminiApiKey", "smtpPassword"], + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "singleton" }, + data: { + azureOpenAiApiKey: null, + geminiApiKey: null, + smtpPassword: null, + }, + }); + }); }); diff --git a/packages/api/src/__tests__/system-settings-runtime.test.ts b/packages/api/src/__tests__/system-settings-runtime.test.ts index 9a265ed..f2b32ae 100644 --- a/packages/api/src/__tests__/system-settings-runtime.test.ts +++ b/packages/api/src/__tests__/system-settings-runtime.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; +import { getRuntimeSecretStatuses, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; describe("system settings runtime resolution", () => { afterEach(() => { @@ -39,4 +39,27 @@ describe("system settings runtime resolution", () => { expect(settings.smtpPassword).toBe("db-password"); }); + + it("reports active source and legacy DB presence separately", () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + + const statuses = getRuntimeSecretStatuses({ + aiProvider: "openai", + azureOpenAiApiKey: "db-key", + smtpPassword: "db-password", + }); + + expect(statuses.azureOpenAiApiKey).toEqual({ + configured: true, + activeSource: "environment", + hasStoredValue: true, + envVarNames: ["OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"], + }); + expect(statuses.smtpPassword).toEqual({ + configured: true, + activeSource: "database", + hasStoredValue: true, + envVarNames: ["SMTP_PASSWORD"], + }); + }); }); diff --git a/packages/api/src/lib/system-settings-runtime.ts b/packages/api/src/lib/system-settings-runtime.ts index 04778f0..1be1626 100644 --- a/packages/api/src/lib/system-settings-runtime.ts +++ b/packages/api/src/lib/system-settings-runtime.ts @@ -7,6 +7,23 @@ type RuntimeAwareSystemSettings = { anonymizationSeed?: string | null; }; +export const RUNTIME_SECRET_FIELDS = [ + "azureOpenAiApiKey", + "azureDalleApiKey", + "geminiApiKey", + "smtpPassword", + "anonymizationSeed", +] as const; + +export type RuntimeSecretField = (typeof RUNTIME_SECRET_FIELDS)[number]; + +export type RuntimeSecretStatus = { + configured: boolean; + activeSource: "environment" | "database" | "none"; + hasStoredValue: boolean; + envVarNames: string[]; +}; + function readEnvOverride(...names: string[]): string | null { for (const name of names) { const value = process.env[name]?.trim(); @@ -26,16 +43,92 @@ function resolvePrimaryAiApiKey(provider: string | null | undefined): string | n return readEnvOverride("OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"); } +function getPrimaryAiEnvVarNames(provider: string | null | undefined): string[] { + if (provider === "azure") { + return ["AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"]; + } + + return ["OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"]; +} + +function resolveSecretEnvOverride( + field: RuntimeSecretField, + provider: string | null | undefined, +): string | null { + if (field === "azureOpenAiApiKey") { + return resolvePrimaryAiApiKey(provider); + } + if (field === "azureDalleApiKey") { + return readEnvOverride("AZURE_DALLE_API_KEY"); + } + if (field === "geminiApiKey") { + return readEnvOverride("GEMINI_API_KEY"); + } + if (field === "smtpPassword") { + return readEnvOverride("SMTP_PASSWORD"); + } + + return readEnvOverride("ANONYMIZATION_SEED"); +} + +function getSecretEnvVarNames( + field: RuntimeSecretField, + provider: string | null | undefined, +): string[] { + if (field === "azureOpenAiApiKey") { + return getPrimaryAiEnvVarNames(provider); + } + if (field === "azureDalleApiKey") { + return ["AZURE_DALLE_API_KEY"]; + } + if (field === "geminiApiKey") { + return ["GEMINI_API_KEY"]; + } + if (field === "smtpPassword") { + return ["SMTP_PASSWORD"]; + } + + return ["ANONYMIZATION_SEED"]; +} + +export function getRuntimeSecretStatuses( + settings: RuntimeAwareSystemSettings | null | undefined, +): Record { + const provider = settings?.aiProvider; + + return Object.fromEntries( + RUNTIME_SECRET_FIELDS.map((field) => { + const envValue = resolveSecretEnvOverride(field, provider); + const storedValue = settings?.[field]?.trim() || null; + const activeSource = envValue + ? "environment" + : storedValue + ? "database" + : "none"; + + return [ + field, + { + configured: !!(envValue || storedValue), + activeSource, + hasStoredValue: !!storedValue, + envVarNames: getSecretEnvVarNames(field, provider), + } satisfies RuntimeSecretStatus, + ]; + }), + ) as Record; +} + export function resolveSystemSettingsRuntime( settings: T | null | undefined, ): T & Required> { const resolved = { ...(settings ?? {}) } as T & Required>; - resolved.azureOpenAiApiKey = resolvePrimaryAiApiKey(resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null; - resolved.azureDalleApiKey = readEnvOverride("AZURE_DALLE_API_KEY") ?? settings?.azureDalleApiKey ?? null; - resolved.geminiApiKey = readEnvOverride("GEMINI_API_KEY") ?? settings?.geminiApiKey ?? null; - resolved.smtpPassword = readEnvOverride("SMTP_PASSWORD") ?? settings?.smtpPassword ?? null; - resolved.anonymizationSeed = readEnvOverride("ANONYMIZATION_SEED") ?? settings?.anonymizationSeed ?? null; + resolved.azureOpenAiApiKey = resolveSecretEnvOverride("azureOpenAiApiKey", resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null; + resolved.azureDalleApiKey = resolveSecretEnvOverride("azureDalleApiKey", resolved.aiProvider) ?? settings?.azureDalleApiKey ?? null; + resolved.geminiApiKey = resolveSecretEnvOverride("geminiApiKey", resolved.aiProvider) ?? settings?.geminiApiKey ?? null; + resolved.smtpPassword = resolveSecretEnvOverride("smtpPassword", resolved.aiProvider) ?? settings?.smtpPassword ?? null; + resolved.anonymizationSeed = resolveSecretEnvOverride("anonymizationSeed", resolved.aiProvider) ?? settings?.anonymizationSeed ?? null; return resolved; } diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index bec040a..163c0c5 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -76,12 +76,14 @@ import { insightsRouter } from "./insights.js"; import { scenarioRouter } from "./scenario.js"; import { allocationRouter } from "./allocation.js"; import { staffingRouter } from "./staffing.js"; +import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; // ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ────── export const MUTATION_TOOLS = new Set([ "import_csv_data", "update_system_settings", + "clear_stored_runtime_secrets", "test_ai_connection", "test_smtp_connection", "test_gemini_connection", @@ -4772,14 +4774,13 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "update_system_settings", - description: "Update system settings through the real settings router. Admin role required. Always confirm first.", + description: "Update non-secret system settings through the real settings router. Runtime secrets must be provisioned via deployment environment or secret manager. Admin role required. Always confirm first.", parameters: { type: "object", properties: { aiProvider: { type: "string", enum: ["openai", "azure"] }, azureOpenAiEndpoint: { type: "string" }, azureOpenAiDeployment: { type: "string" }, - azureOpenAiApiKey: { type: "string" }, azureApiVersion: { type: "string" }, aiMaxCompletionTokens: { type: "integer" }, aiTemperature: { type: "number" }, @@ -4789,17 +4790,13 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ smtpHost: { type: "string" }, smtpPort: { type: "integer" }, smtpUser: { type: "string" }, - smtpPassword: { type: "string" }, smtpFrom: { type: "string" }, smtpTls: { type: "boolean" }, anonymizationEnabled: { type: "boolean" }, anonymizationDomain: { type: "string" }, - anonymizationSeed: { type: "string" }, anonymizationMode: { type: "string", enum: ["global"] }, azureDalleDeployment: { type: "string" }, azureDalleEndpoint: { type: "string" }, - azureDalleApiKey: { type: "string" }, - geminiApiKey: { type: "string" }, geminiModel: { type: "string" }, imageProvider: { type: "string", enum: ["dalle", "gemini"] }, vacationDefaultDays: { type: "integer" }, @@ -4809,6 +4806,17 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, { + { + type: "function", + function: { + name: "clear_stored_runtime_secrets", + description: "Clear legacy database-stored runtime secrets after they have been migrated to deployment secret management. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, type: "function", function: { name: "test_ai_connection", @@ -9306,7 +9314,6 @@ const executors = { aiProvider?: "openai" | "azure"; azureOpenAiEndpoint?: string; azureOpenAiDeployment?: string; - azureOpenAiApiKey?: string; azureApiVersion?: string; aiMaxCompletionTokens?: number; aiTemperature?: number; @@ -9322,17 +9329,13 @@ const executors = { smtpHost?: string; smtpPort?: number; smtpUser?: string; - smtpPassword?: string; smtpFrom?: string; smtpTls?: boolean; anonymizationEnabled?: boolean; anonymizationDomain?: string; - anonymizationSeed?: string; anonymizationMode?: "global"; azureDalleDeployment?: string; azureDalleEndpoint?: string; - azureDalleApiKey?: string; - geminiApiKey?: string; geminiModel?: string; imageProvider?: "dalle" | "gemini"; vacationDefaultDays?: number; @@ -9342,6 +9345,11 @@ const executors = { return caller.updateSystemSettings(params); }, + async clear_stored_runtime_secrets(_params: Record, ctx: ToolContext) { + const caller = createSettingsCaller(createScopedCallerContext(ctx)); + return caller.clearStoredRuntimeSecrets(); + }, + async test_ai_connection(_params: Record, ctx: ToolContext) { const caller = createSettingsCaller(createScopedCallerContext(ctx)); return caller.testAiConnection(); @@ -9358,7 +9366,7 @@ const executors = { }, async get_ai_configured(_params: Record, ctx: ToolContext) { - const settings = await ctx.db.systemSettings.findUnique({ + const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, select: { aiProvider: true, @@ -9366,7 +9374,7 @@ const executors = { azureOpenAiDeployment: true, azureOpenAiApiKey: true, }, - }); + })); return { configured: isAiConfigured(settings) }; }, diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index feef38d..108bbb7 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -349,6 +349,7 @@ const ADMIN_ONLY_TOOLS = new Set([ "commit_dispo_import_batch", "get_system_settings", "update_system_settings", + "clear_stored_runtime_secrets", "get_ai_configured", "test_ai_connection", "test_smtp_connection", diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index 781158c..e52cf56 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -6,7 +6,7 @@ import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared"; import { testSmtpConnection } from "../lib/email.js"; import { createAuditEntry } from "../lib/audit.js"; import { logger } from "../lib/logger.js"; -import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; +import { getRuntimeSecretStatuses, RUNTIME_SECRET_FIELDS, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; /** Fields that must never appear in audit log values */ const SENSITIVE_FIELDS = new Set([ @@ -23,6 +23,10 @@ export const settingsRouter = createTRPCRouter({ where: { id: "singleton" }, }); const runtimeSettings = resolveSystemSettingsRuntime(settings); + const runtimeSecrets = getRuntimeSecretStatuses(settings); + const legacyStoredSecretFields = RUNTIME_SECRET_FIELDS.filter( + (field) => runtimeSecrets[field].hasStoredValue, + ); const defaultWeights = { skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH, @@ -42,6 +46,8 @@ export const settingsRouter = createTRPCRouter({ aiSummaryPrompt: settings?.aiSummaryPrompt ?? null, defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT, hasApiKey: !!runtimeSettings.azureOpenAiApiKey, + runtimeSecrets, + legacyStoredSecretFields, scoreWeights: (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights, scoreVisibleRoles: (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"], // SMTP @@ -125,13 +131,14 @@ export const settingsRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const data: Record = {}; + const ignoredSecretFields: string[] = []; if (input.aiProvider !== undefined) data.aiProvider = input.aiProvider; if (input.azureOpenAiEndpoint !== undefined) data.azureOpenAiEndpoint = input.azureOpenAiEndpoint || null; if (input.azureOpenAiDeployment !== undefined) data.azureOpenAiDeployment = input.azureOpenAiDeployment || null; if (input.azureOpenAiApiKey !== undefined) - data.azureOpenAiApiKey = input.azureOpenAiApiKey || null; + ignoredSecretFields.push("azureOpenAiApiKey"); if (input.azureApiVersion !== undefined) data.azureApiVersion = input.azureApiVersion || null; if (input.aiMaxCompletionTokens !== undefined) @@ -148,16 +155,13 @@ export const settingsRouter = createTRPCRouter({ if (input.smtpHost !== undefined) data.smtpHost = input.smtpHost || null; if (input.smtpPort !== undefined) data.smtpPort = input.smtpPort; if (input.smtpUser !== undefined) data.smtpUser = input.smtpUser || null; - if (input.smtpPassword !== undefined) data.smtpPassword = input.smtpPassword || null; + if (input.smtpPassword !== undefined) ignoredSecretFields.push("smtpPassword"); if (input.smtpFrom !== undefined) data.smtpFrom = input.smtpFrom || null; if (input.smtpTls !== undefined) data.smtpTls = input.smtpTls; // Global anonymization if (input.anonymizationEnabled !== undefined) data.anonymizationEnabled = input.anonymizationEnabled; if (input.anonymizationDomain !== undefined) data.anonymizationDomain = input.anonymizationDomain || "superhartmut.de"; - if (input.anonymizationSeed !== undefined) { - data.anonymizationSeed = input.anonymizationSeed || null; - data.anonymizationAliases = null; - } + if (input.anonymizationSeed !== undefined) ignoredSecretFields.push("anonymizationSeed"); if (input.anonymizationMode !== undefined) { data.anonymizationMode = input.anonymizationMode; data.anonymizationAliases = null; @@ -168,10 +172,10 @@ export const settingsRouter = createTRPCRouter({ if (input.azureDalleEndpoint !== undefined) data.azureDalleEndpoint = input.azureDalleEndpoint || null; if (input.azureDalleApiKey !== undefined) - data.azureDalleApiKey = input.azureDalleApiKey || null; + ignoredSecretFields.push("azureDalleApiKey"); // Gemini if (input.geminiApiKey !== undefined) - data.geminiApiKey = input.geminiApiKey || null; + ignoredSecretFields.push("geminiApiKey"); if (input.geminiModel !== undefined) data.geminiModel = input.geminiModel || null; // Image provider @@ -182,6 +186,14 @@ export const settingsRouter = createTRPCRouter({ // Timeline if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps; + if (Object.keys(data).length === 0) { + return { + ok: true, + ignoredSecretFields, + secretStorageMode: "environment-only" as const, + }; + } + // Fetch current settings for before-snapshot const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); @@ -215,9 +227,47 @@ export const settingsRouter = createTRPCRouter({ source: "ui", }); - return { ok: true }; + return { + ok: true, + ignoredSecretFields, + secretStorageMode: "environment-only" as const, + }; }), + clearStoredRuntimeSecrets: adminProcedure.mutation(async ({ ctx }) => { + const existing = await ctx.db.systemSettings.findUnique({ + where: { id: "singleton" }, + select: Object.fromEntries( + RUNTIME_SECRET_FIELDS.map((field) => [field, true]), + ) as Record<(typeof RUNTIME_SECRET_FIELDS)[number], true>, + }); + + const clearedFields = RUNTIME_SECRET_FIELDS.filter((field) => !!existing?.[field]); + + if (clearedFields.length === 0) { + return { ok: true, clearedFields: [] as string[] }; + } + + await ctx.db.systemSettings.update({ + where: { id: "singleton" }, + data: Object.fromEntries(clearedFields.map((field) => [field, null])), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "SystemSettings", + entityId: "singleton", + entityName: "Runtime Secrets", + action: "UPDATE", + userId: ctx.dbUser?.id, + after: { clearedFields }, + source: "ui", + summary: `Cleared ${clearedFields.length} legacy runtime secret field(s) from database storage`, + }); + + return { ok: true, clearedFields }; + }), + testAiConnection: adminProcedure.mutation(async ({ ctx }) => { const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, diff --git a/tooling/deploy/.env.production.example b/tooling/deploy/.env.production.example index a51a4f6..515ec37 100644 --- a/tooling/deploy/.env.production.example +++ b/tooling/deploy/.env.production.example @@ -6,8 +6,13 @@ NEXTAUTH_SECRET=replace-with-a-long-random-secret # Optional but commonly needed application settings. SENTRY_DSN= +OPENAI_API_KEY= +AZURE_OPENAI_API_KEY= +AZURE_DALLE_API_KEY= +GEMINI_API_KEY= SMTP_HOST= SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= SMTP_FROM=CapaKraken +ANONYMIZATION_SEED= diff --git a/tooling/deploy/README.md b/tooling/deploy/README.md index b0e8cb6..5f14611 100644 --- a/tooling/deploy/README.md +++ b/tooling/deploy/README.md @@ -25,9 +25,12 @@ On the target host, the deploy directory should contain: 1. Copy `tooling/deploy/.env.production.example` to the target host as `.env.production`. 2. Fill in the required secrets and URLs. -3. Ensure Docker Engine and Docker Compose v2 are installed. -4. Ensure the target host can pull from `ghcr.io`. -5. Run the image release workflow, then the staging or production deploy workflow with the same image tag. +3. Provision runtime AI/SMTP/anonymization secrets on the host through `.env.production` or the platform's secret facility. +4. Keep admin settings for status/verification only; do not use them to enter or rotate operational secrets. +5. After migration, use the admin cleanup action to remove any legacy database-stored runtime secrets. +6. Ensure Docker Engine and Docker Compose v2 are installed. +7. Ensure the target host can pull from `ghcr.io`. +8. Run the image release workflow, then the staging or production deploy workflow with the same image tag. ## Manual Host Test