feat(admin): build the AI Policy console (§6) — approved, spec now implemented

New admin tab "AI" (app/(main)/admin/_tabs/ai-policy.tsx): provider-class
toggles, server model allow-list, BYOK provider allow-list, seats/usage
(front-end for the already-real lib/ai/entitlement.ts), retrieval on/off,
consent text + version bump.

Real backend, not cosmetic: AiConsoleConfig persisted via config-manager
(lib/ai/types.ts, ai-policy.json in the CONFIG dir). New GET/PUT
/api/admin/ai/policy. Enforcement wired at every real chokepoint, not just
the picker: /api/ai/server/chat checks classesEnabled.server and the model
allow-list, /api/ai/retrieve checks retrievalEnabled, /api/ai/server/models
filters by allow-list. GET /api/ai/policy folds classesEnabled into the
classes list clients see.

Resolved the spec's 3 open questions as recommended: BYOK allow-list stays
client-side/advisory (wired into ai-assistant-settings.tsx's addProfile),
tier picker stays cosmetic, master aiAssistantEnabled toggle stays in the
existing Policy tab (this tab links to it instead of duplicating it).

Defaults preserve today's behavior exactly (classesEnabled/allowlists all
start empty/null) — turning this on changes nothing until an admin touches it.
This commit is contained in:
Bernd Rodler
2026-08-06 08:48:30 +02:00
parent 61651b1ed1
commit 30e5059b94
12 changed files with 591 additions and 5 deletions
+92
View File
@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types';
export const runtime = 'nodejs';
const VALID_CLASSES: AiClass[] = ['local', 'server', 'public'];
/**
* GET/PUT /api/admin/ai/policy - the admin console's writable config
* (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6): per-class enable, model/
* provider allow-lists, retrieval on/off, BYOK consent text. Separate from
* /api/admin/ai/entitlement (seats/ledger - runtime state) and from the
* generic /api/admin/policy (FeatureGates - the master aiAssistantEnabled
* toggle stays there, this console only links to it, per spec §6 open
* question 3).
*/
export async function GET(request: NextRequest) {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
try {
await configManager.ensureLoaded();
return NextResponse.json(configManager.getAiConsoleConfig(), { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
logger.error('ai console policy read error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function validate(body: Partial<AiConsoleConfig>): string | null {
if (body.classesEnabled !== undefined) {
if (typeof body.classesEnabled !== 'object' || body.classesEnabled === null) return 'classesEnabled must be an object';
for (const key of Object.keys(body.classesEnabled)) {
if (!VALID_CLASSES.includes(key as AiClass)) return `classesEnabled has an unknown class "${key}"`;
}
}
if (body.serverModelAllowlist !== undefined && body.serverModelAllowlist !== null) {
if (!Array.isArray(body.serverModelAllowlist) || !body.serverModelAllowlist.every((m) => typeof m === 'string')) {
return 'serverModelAllowlist must be an array of strings or null';
}
}
if (body.publicProviderAllowlist !== undefined && body.publicProviderAllowlist !== null) {
if (!Array.isArray(body.publicProviderAllowlist) || !body.publicProviderAllowlist.every((m) => typeof m === 'string')) {
return 'publicProviderAllowlist must be an array of strings or null';
}
}
if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') {
return 'retrievalEnabled must be a boolean';
}
if (body.consent !== undefined && body.consent !== null) {
if (typeof body.consent !== 'object' || typeof body.consent.version !== 'string' || typeof body.consent.text !== 'string') {
return 'consent must be { version: string, text: string } or null';
}
}
return null;
}
export async function PUT(request: NextRequest) {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
let body: Partial<AiConsoleConfig>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const validationError = validate(body);
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
try {
await configManager.ensureLoaded();
const next = await configManager.setAiConsoleConfig(body);
await auditLog('ai.console_policy.update', {
classesEnabled: next.classesEnabled,
retrievalEnabled: next.retrievalEnabled,
consentVersion: next.consent?.version ?? null,
serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null,
publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null,
}, ip);
return NextResponse.json(next);
} catch (error) {
logger.error('ai console policy update error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+14 -3
View File
@@ -17,14 +17,25 @@ export async function GET() {
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const consoleConfig = configManager.getAiConsoleConfig();
const classes = [...DEFAULT_AI_ENTITLEMENT.classes];
if (process.env.AI_SERVER_BASE_URL) classes.push('server');
// A class must be BOTH infra-available AND not explicitly disabled by
// the admin console (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6) to reach
// users. Missing classesEnabled entries default to allowed, so this
// changes nothing until an admin actually touches the console.
const classAllowed = (cls: (typeof DEFAULT_AI_ENTITLEMENT.classes)[number]) => consoleConfig.classesEnabled[cls] !== false;
const classes: typeof DEFAULT_AI_ENTITLEMENT.classes = [];
if (classAllowed('local')) classes.push('local');
if (classAllowed('public')) classes.push('public');
if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server');
const aiPolicy: AiPolicy = {
enabled: policy.features.aiAssistantEnabled,
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
publicConsentVersion: null,
publicConsentVersion: consoleConfig.consent?.version ?? null,
retrievalEnabled: consoleConfig.retrievalEnabled,
consent: consoleConfig.consent,
publicProviderAllowlist: consoleConfig.publicProviderAllowlist,
};
return NextResponse.json(aiPolicy, {
+9
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
@@ -31,6 +32,14 @@ export async function POST(request: NextRequest) {
return new NextResponse(null, { status: 404 });
}
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
// §6): an admin can disable mail-content-to-embeddings augmentation
// independent of disabling the `server` chat class outright.
await configManager.ensureLoaded();
if (!configManager.getAiConsoleConfig().retrievalEnabled) {
return NextResponse.json({ error: 'retrieval is disabled by admin policy' }, { status: 403 });
}
let body: { query?: unknown; limit?: unknown };
try {
body = await request.json();
+15
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
@@ -35,6 +36,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
// §6): the admin console can disable the whole `server` class even when
// AI_SERVER_BASE_URL stays configured (e.g. keeping infra up for staging
// while turning it off for users).
await configManager.ensureLoaded();
if (configManager.getAiConsoleConfig().classesEnabled.server === false) {
return NextResponse.json({ error: 'the server-hosted AI class is disabled by admin policy' }, { status: 403 });
}
const seat = await checkAndAssignSeat(auth.username);
if (!seat.allowed) {
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
@@ -58,6 +68,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
}
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
if (allowlist && !allowlist.includes(model)) {
return NextResponse.json({ error: `model "${model}" is not on the admin allow-list` }, { status: 403 });
}
const baseUrl = process.env.AI_SERVER_BASE_URL;
if (!baseUrl) {
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
+12 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
export const runtime = 'nodejs';
@@ -37,7 +38,17 @@ export async function GET(request: NextRequest) {
// lists them in the same /api/tags response, but calling /api/chat with
// one fails outright. `capabilities` absent (older Ollama) fails open
// rather than hiding every model on an upgrade.
const chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
let chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
// Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6). null = every
// completion-capable model (today's behavior, unchanged).
await configManager.ensureLoaded();
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
if (allowlist) {
const allowed = new Set(allowlist);
chatModels = chatModels.filter((m) => allowed.has(m.name));
}
return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) });
} catch (cause) {
return NextResponse.json(