refactor(settings): adopt environment-only runtime secret flow

This commit is contained in:
2026-03-30 19:55:06 +02:00
parent fed7aa5b61
commit a19d2cbae0
19 changed files with 757 additions and 172 deletions
+21 -13
View File
@@ -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<string, never>, ctx: ToolContext) {
const caller = createSettingsCaller(createScopedCallerContext(ctx));
return caller.clearStoredRuntimeSecrets();
},
async test_ai_connection(_params: Record<string, never>, ctx: ToolContext) {
const caller = createSettingsCaller(createScopedCallerContext(ctx));
return caller.testAiConnection();
@@ -9358,7 +9366,7 @@ const executors = {
},
async get_ai_configured(_params: Record<string, never>, 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) };
},
+1
View File
@@ -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",
+60 -10
View File
@@ -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<string, unknown> = {};
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" },