feat: ACN Application Security Standard V7.30 compliance (19/23 items)

CRITICAL — Authentication & Access:
- TOTP MFA: otpauth-based, QR setup UI, sign-in flow integration,
  admin disable override, /account/security self-service page
- Session Timeouts: 8h absolute (maxAge), 30min idle (updateAge)
- Failed Auth Logging: Pino warn for invalid password/user/totp,
  info for successful login, audit entries for all auth events
- Concurrent Session Limit: ActiveSession model, oldest-kick strategy,
  max 3 per user (configurable in SystemSettings)

CRITICAL — HTTP Security:
- HSTS: max-age=31536000; includeSubDomains
- CSP: script/style/img/font/connect-src with Gemini/OpenAI whitelist
- X-XSS-Protection: 0 (CSP replaces legacy)
- Auth page cache: no-store, no-cache, must-revalidate
- Rate Limiting: 100/15min general API, 5/15min auth (Map-based)

Data Protection:
- XSS Sanitization: DOMPurify on comment bodies
- autocomplete="new-password" on all password/secret fields
- SameSite=Strict on all cookies (Credentials-only, no OAuth)
- File Upload Magic Bytes validation (PNG/JPEG/WebP/GIF/BMP/TIFF)

Logging & Monitoring:
- Login/Logout audit entries (Auth entityType)
- External API call logging with timing (OpenAI, Gemini)
- Input validation failure logging at warn level
- Concurrent session tracking in ActiveSession table

Documentation:
- docs/security-architecture.md (11 sections)
- docs/sdlc.md (CI pipeline, security gates, incident response)
- .gitea/PULL_REQUEST_TEMPLATE.md (security checklist)

Schema: User.totpSecret/totpEnabled, SystemSettings.sessionMaxAge/
sessionIdleTimeout/maxConcurrentSessions, ActiveSession model

Tests: 310 engine + 37 staffing pass. TypeScript clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-27 14:16:39 +01:00
parent 70ae830623
commit 9d43e4b113
31 changed files with 1337 additions and 107 deletions
+13 -10
View File
@@ -8,7 +8,7 @@ import { calculateAllocation, checkDuplicateAssignment, countWorkingDays } from
import { computeBudgetStatus } from "@capakraken/engine";
import type { PermissionKey } from "@capakraken/shared";
import { parseTaskAction } from "@capakraken/shared";
import { createAiClient, createDalleClient, isAiConfigured, isDalleConfigured, parseAiError } from "../ai-client.js";
import { createAiClient, createDalleClient, isAiConfigured, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { getTaskAction } from "../lib/task-actions.js";
import { fmtEur } from "../lib/format-utils.js";
import { resolveRecipients } from "../lib/notification-targeting.js";
@@ -5327,15 +5327,18 @@ const executors = {
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
const temperature = settings!.aiTemperature ?? 1;
const completion = await client.chat.completions.create({
messages: [
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
{ role: "user", content: prompt },
],
max_completion_tokens: maxTokens,
model,
...(temperature !== 1 ? { temperature } : {}),
});
const provider = settings!.aiProvider ?? "openai";
const completion = await loggedAiCall(provider, model, prompt.length, () =>
client.chat.completions.create({
messages: [
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
{ role: "user", content: prompt },
],
max_completion_tokens: maxTokens,
model,
...(temperature !== 1 ? { temperature } : {}),
}),
);
const narrative = completion.choices[0]?.message?.content?.trim() ?? "";
if (!narrative) return { error: "AI returned an empty response." };
+13 -9
View File
@@ -7,7 +7,7 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
const MAX_TOOL_ITERATIONS = 8;
@@ -167,15 +167,19 @@ export const assistantRouter = createTRPCRouter({
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
const provider = settings!.aiProvider ?? "openai";
const msgLen = openaiMessages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
try {
response = await client.chat.completions.create({
model,
messages: openaiMessages,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools: availableTools as any,
max_completion_tokens: maxTokens,
temperature,
});
response = await loggedAiCall(provider, model, msgLen, () =>
client.chat.completions.create({
model,
messages: openaiMessages,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools: availableTools as any,
max_completion_tokens: maxTokens,
temperature,
}),
);
} catch (err) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
+13 -10
View File
@@ -1,4 +1,4 @@
import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -133,17 +133,20 @@ ${dataContext}`;
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
const temperature = settings!.aiTemperature ?? 1;
const provider = settings!.aiProvider ?? "openai";
let narrative = "";
try {
const completion = await client.chat.completions.create({
messages: [
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
{ role: "user", content: prompt },
],
max_completion_tokens: maxTokens,
model,
...(temperature !== 1 ? { temperature } : {}),
});
const completion = await loggedAiCall(provider, model, prompt.length, () =>
client.chat.completions.create({
messages: [
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
{ role: "user", content: prompt },
],
max_completion_tokens: maxTokens,
model,
...(temperature !== 1 ? { temperature } : {}),
}),
);
narrative = completion.choices[0]?.message?.content?.trim() ?? "";
} catch (err) {
throw new TRPCError({
+20 -8
View File
@@ -12,10 +12,11 @@ import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { validateImageDataUrl } from "../lib/image-validation.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
@@ -520,13 +521,15 @@ export const projectRouter = createTRPCRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
try {
response = await dalleClient.images.generate({
model,
prompt: finalPrompt,
size: "1024x1024",
n: 1,
response_format: "b64_json",
});
response = await loggedAiCall("dalle", model, finalPrompt.length, () =>
dalleClient.images.generate({
model,
prompt: finalPrompt,
size: "1024x1024",
n: 1,
response_format: "b64_json",
}),
);
} catch (err) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
@@ -568,6 +571,15 @@ export const projectRouter = createTRPCRouter({
});
}
// Validate magic bytes match declared MIME type
const magicCheck = validateImageDataUrl(input.imageDataUrl);
if (!magicCheck.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `File validation failed: ${magicCheck.reason}`,
});
}
if (input.imageDataUrl.length > MAX_COVER_SIZE) {
throw new TRPCError({
code: "BAD_REQUEST",
+10 -7
View File
@@ -1,4 +1,4 @@
import { createAiClient, isAiConfigured } from "../ai-client.js";
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
@@ -795,13 +795,16 @@ export const resourceRouter = createTRPCRouter({
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
const temperature = settings!.aiTemperature ?? 1;
const provider = settings!.aiProvider ?? "openai";
async function callChatCompletions(withTemperature: boolean) {
return client.chat.completions.create({
messages: [{ role: "user", content: prompt }],
max_completion_tokens: maxTokens,
model,
...(withTemperature && temperature !== 1 ? { temperature } : {}),
});
return loggedAiCall(provider, model, prompt.length, () =>
client.chat.completions.create({
messages: [{ role: "user", content: prompt }],
max_completion_tokens: maxTokens,
model,
...(withTemperature && temperature !== 1 ? { temperature } : {}),
}),
);
}
let summary = "";
+145 -1
View File
@@ -12,7 +12,7 @@ import { Prisma } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure, publicProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js";
export const userRouter = createTRPCRouter({
@@ -39,6 +39,7 @@ export const userRouter = createTRPCRouter({
lastLoginAt: true,
lastActiveAt: true,
permissionOverrides: true,
totpEnabled: true,
},
orderBy: { name: "asc" },
});
@@ -466,4 +467,147 @@ export const userRouter = createTRPCRouter({
overrides: user.permissionOverrides as PermissionOverrides | null,
};
}),
// ─── TOTP / MFA ─────────────────────────────────────────────────────────────
/** Generate a new TOTP secret for the current user (not yet enabled). */
generateTotpSecret: protectedProcedure.mutation(async ({ ctx }) => {
const { TOTP, Secret } = await import("otpauth");
const secret = new Secret({ size: 20 });
const totp = new TOTP({
issuer: "CapaKraken",
label: ctx.session.user?.email ?? ctx.dbUser!.id,
algorithm: "SHA1",
digits: 6,
period: 30,
secret,
});
// Store the secret (not yet enabled)
await ctx.db.user.update({
where: { id: ctx.dbUser!.id },
data: { totpSecret: secret.base32 },
});
const uri = totp.toString();
return { secret: secret.base32, uri };
}),
/** Verify a TOTP token and enable MFA for the current user. */
verifyAndEnableTotp: protectedProcedure
.input(z.object({ token: z.string().length(6) }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: ctx.dbUser!.id },
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true },
});
if (!user.totpSecret) {
throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." });
}
if (user.totpEnabled) {
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." });
}
const { TOTP, Secret } = await import("otpauth");
const totp = new TOTP({
issuer: "CapaKraken",
label: user.email,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: Secret.fromBase32(user.totpSecret),
});
const delta = totp.validate({ token: input.token, window: 1 });
if (delta === null) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." });
}
await ctx.db.user.update({
where: { id: user.id },
data: { totpEnabled: true },
});
void createAuditEntry({
db: ctx.db,
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
userId: user.id,
source: "ui",
summary: "Enabled TOTP MFA",
});
return { enabled: true };
}),
/** Admin override: disable TOTP for a specific user. */
disableTotp: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { id: true, name: true, email: true, totpEnabled: true },
});
await ctx.db.user.update({
where: { id: input.userId },
data: { totpEnabled: false, totpSecret: null },
});
void createAuditEntry({
db: ctx.db,
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
source: "ui",
summary: "Disabled TOTP MFA (admin override)",
});
return { disabled: true };
}),
/** Verify a TOTP token (used during the login flow — public procedure). */
verifyTotp: publicProcedure
.input(z.object({ userId: z.string(), token: z.string().length(6) }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { id: true, totpSecret: true, totpEnabled: true },
});
if (!user.totpEnabled || !user.totpSecret) {
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." });
}
const { TOTP, Secret } = await import("otpauth");
const totp = new TOTP({
issuer: "CapaKraken",
label: user.id,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: Secret.fromBase32(user.totpSecret),
});
const delta = totp.validate({ token: input.token, window: 1 });
if (delta === null) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
}
return { valid: true };
}),
/** Get MFA status for the current user. */
getMfaStatus: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: ctx.dbUser!.id },
select: { totpEnabled: true },
});
return { totpEnabled: user.totpEnabled };
}),
});