From c8e82ac221cb3f549d737082c297669f3fb926eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 11:00:42 +0200 Subject: [PATCH] feat(settings): restrict AI readiness checks to admins --- docs/route-access-matrix.md | 9 +++ .../src/__tests__/assistant-router.test.ts | 2 +- .../__tests__/settings-router-auth.test.ts | 67 +++++++++++++++++++ packages/api/src/router/assistant.ts | 1 + packages/api/src/router/settings.ts | 4 +- 5 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/__tests__/settings-router-auth.test.ts diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index cac59e2..9aa9817 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -79,6 +79,15 @@ Reasoning: - system role defaults define the effective permission model and therefore belong to the smallest operational audience +### `packages/api/src/router/settings.ts` + +- `getSystemSettings`, `updateSystemSettings`, connection tests, `getAiConfigured`: `admin-only` + +Reasoning: + +- even the boolean AI readiness check leaks whether admin-managed infrastructure is wired and available +- the route has no current web consumer outside admin operations, so narrowing it does not block normal user workflows + ### `packages/api/src/router/country.ts` - `list`, `resolveByIdentifier`, `getCityById`: `authenticated-safe-lookup` diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index 88c7c95..8623011 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -626,11 +626,11 @@ describe("assistant router tool gating", () => { 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("list_system_role_configs"); expect(userNames).not.toContain("update_system_role_config"); expect(userNames).not.toContain("list_webhooks"); expect(userNames).not.toContain("create_webhook"); - expect(userNames).toContain("get_ai_configured"); }); it("keeps holiday calendar catalog tools admin-only while leaving preview available", () => { diff --git a/packages/api/src/__tests__/settings-router-auth.test.ts b/packages/api/src/__tests__/settings-router-auth.test.ts new file mode 100644 index 0000000..7de76b8 --- /dev/null +++ b/packages/api/src/__tests__/settings-router-auth.test.ts @@ -0,0 +1,67 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { settingsRouter } from "../router/settings.js"; +import { createCallerFactory } from "../trpc.js"; + +function createProtectedContext( + db: Record, + systemRole: SystemRole, +) { + return { + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole, + permissionOverrides: null, + }, + }; +} + +describe("settings router authorization", () => { + it("forbids non-admin users from reading AI configuration status", async () => { + const findUnique = vi.fn(); + const caller = createCallerFactory(settingsRouter)(createProtectedContext({ + systemSettings: { + findUnique, + }, + }, SystemRole.USER)); + + await expect(caller.getAiConfigured()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("allows admins to read AI configuration status", async () => { + const findUnique = vi.fn().mockResolvedValue({ + aiProvider: "azure", + azureOpenAiEndpoint: "https://example.openai.azure.com", + azureOpenAiDeployment: "gpt-4o", + azureOpenAiApiKey: "secret", + }); + const caller = createCallerFactory(settingsRouter)(createProtectedContext({ + systemSettings: { + findUnique, + }, + }, SystemRole.ADMIN)); + + const result = await caller.getAiConfigured(); + + expect(result).toEqual({ configured: true }); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "singleton" }, + select: { + aiProvider: true, + azureOpenAiEndpoint: true, + azureOpenAiDeployment: true, + azureOpenAiApiKey: true, + }, + }); + }); +}); diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 166e226..b50948c 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -344,6 +344,7 @@ const ADMIN_ONLY_TOOLS = new Set([ "commit_dispo_import_batch", "get_system_settings", "update_system_settings", + "get_ai_configured", "test_ai_connection", "test_smtp_connection", "test_gemini_connection", diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index d9718c7..f2cb53b 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; +import { adminProcedure, createTRPCRouter } from "../trpc.js"; import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js"; import { DEFAULT_SUMMARY_PROMPT } from "./resource.js"; import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared"; @@ -361,7 +361,7 @@ export const settingsRouter = createTRPCRouter({ } }), - getAiConfigured: protectedProcedure.query(async ({ ctx }) => { + getAiConfigured: adminProcedure.query(async ({ ctx }) => { const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, select: {