Fundament: Audit mit IP/User-Agent, Mail-Absender je Mandant
- Migration audit_request_context: ip_address, user_agent an audit_logs (Spec §26) - writeAuditLog/writePlatformAudit erfassen IP (X-Forwarded-For) und User-Agent aus dem Request; außerhalb eines Requests (Worker/Skripte) null - Audit-Viewer liest die neuen Spalten statt Heuristik aus before/after - deliverMail nutzt Anzeigename und Reply-To aus TenantSettings (Spec §33.2); Absenderadresse bleibt Plattform-Domain (SPF/DKIM), Header-Injection bereinigt Gate: tsc, lint, build, 24/24 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
-- Spec §26: audit entries carry IP address, user agent and outcome.
|
||||||
|
ALTER TABLE "audit_logs" ADD COLUMN "ip_address" TEXT;
|
||||||
|
ALTER TABLE "audit_logs" ADD COLUMN "user_agent" TEXT;
|
||||||
@@ -284,6 +284,8 @@ model AuditLog {
|
|||||||
entityId String? @map("entity_id")
|
entityId String? @map("entity_id")
|
||||||
before Json?
|
before Json?
|
||||||
after Json?
|
after Json?
|
||||||
|
ipAddress String? @map("ip_address")
|
||||||
|
userAgent String? @map("user_agent")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
|
|||||||
+30
-3
@@ -1,20 +1,42 @@
|
|||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit trail (SPEC §5 AuditLog, §10): every writing action creates an entry.
|
* Audit trail (spec §26): every writing action creates an entry.
|
||||||
* Uses the raw client on purpose — audit writes must never be silently
|
* Uses the raw client on purpose — audit writes must never be silently
|
||||||
* filtered, and tenantId is passed explicitly by the caller.
|
* filtered, and tenantId is passed explicitly by the caller.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
type AuditAction = "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP address and user agent of the current request, if there is one.
|
||||||
|
* Outside a request scope (workers, scripts, tests) `headers()` throws → both null.
|
||||||
|
* Behind the Coolify/Traefik proxy the client IP is the first X-Forwarded-For hop.
|
||||||
|
*/
|
||||||
|
async function requestContext(): Promise<{ ipAddress: string | null; userAgent: string | null }> {
|
||||||
|
try {
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const h = await headers();
|
||||||
|
const forwarded = h.get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||||
|
const ip = forwarded || h.get("x-real-ip")?.trim() || null;
|
||||||
|
const ua = h.get("user-agent");
|
||||||
|
return { ipAddress: ip ? ip.slice(0, 64) : null, userAgent: ua ? ua.slice(0, 512) : null };
|
||||||
|
} catch {
|
||||||
|
return { ipAddress: null, userAgent: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function writeAuditLog(entry: {
|
export async function writeAuditLog(entry: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
actorId?: string;
|
actorId?: string;
|
||||||
action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied";
|
action: AuditAction;
|
||||||
scope?: "tenant" | "platform";
|
scope?: "tenant" | "platform";
|
||||||
entity: string;
|
entity: string;
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
before?: unknown;
|
before?: unknown;
|
||||||
after?: unknown;
|
after?: unknown;
|
||||||
}) {
|
}) {
|
||||||
|
const ctx = await requestContext();
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: entry.tenantId,
|
tenantId: entry.tenantId,
|
||||||
@@ -25,6 +47,8 @@ export async function writeAuditLog(entry: {
|
|||||||
entityId: entry.entityId,
|
entityId: entry.entityId,
|
||||||
before: entry.before as object | undefined,
|
before: entry.before as object | undefined,
|
||||||
after: entry.after as object | undefined,
|
after: entry.after as object | undefined,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -35,12 +59,13 @@ export async function writeAuditLog(entry: {
|
|||||||
*/
|
*/
|
||||||
export async function writePlatformAudit(entry: {
|
export async function writePlatformAudit(entry: {
|
||||||
actorId?: string;
|
actorId?: string;
|
||||||
action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied";
|
action: AuditAction;
|
||||||
entity: string;
|
entity: string;
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
before?: unknown;
|
before?: unknown;
|
||||||
after?: unknown;
|
after?: unknown;
|
||||||
}) {
|
}) {
|
||||||
|
const ctx = await requestContext();
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: null,
|
tenantId: null,
|
||||||
@@ -51,6 +76,8 @@ export async function writePlatformAudit(entry: {
|
|||||||
entityId: entry.entityId,
|
entityId: entry.entityId,
|
||||||
before: entry.before as object | undefined,
|
before: entry.before as object | undefined,
|
||||||
after: entry.after as object | undefined,
|
after: entry.after as object | undefined,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from "@/server/db";
|
import { prisma } from "@/server/db";
|
||||||
import { getMailConfig, mailFrom } from "./config";
|
import { getMailConfig, mailFrom, type MailConfig } from "./config";
|
||||||
import { getMailProvider } from "./provider-smtp";
|
import { getMailProvider } from "./provider-smtp";
|
||||||
import { TransientMailError } from "./provider";
|
import { TransientMailError } from "./provider";
|
||||||
import { renderTemplate } from "./templates";
|
import { renderTemplate } from "./templates";
|
||||||
@@ -42,12 +42,13 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rendered = renderTemplate(job.template, job.locale, job.vars);
|
const rendered = renderTemplate(job.template, job.locale, job.vars);
|
||||||
|
const sender = await tenantSender(job.mailLogId, config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { messageId } = await provider.send({
|
const { messageId } = await provider.send({
|
||||||
from: mailFrom(config),
|
from: sender.from,
|
||||||
to: job.to,
|
to: job.to,
|
||||||
replyTo: config.replyTo,
|
replyTo: sender.replyTo,
|
||||||
subject: rendered.subject,
|
subject: rendered.subject,
|
||||||
html: rendered.html,
|
html: rendered.html,
|
||||||
text: rendered.text,
|
text: rendered.text,
|
||||||
@@ -89,3 +90,28 @@ export async function markMailFailed(mailLogId: string, error: string): Promise<
|
|||||||
data: { status: "failed", error: error.slice(0, 500) },
|
data: { status: "failed", error: error.slice(0, 500) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant-specific display name and reply-to (spec §33.2). The sender ADDRESS always stays
|
||||||
|
* the platform address (SPF/DKIM alignment); only the display name and Reply-To vary.
|
||||||
|
* Raw client on purpose: the worker has no tenant context; the MailLog row carries the tenant.
|
||||||
|
*/
|
||||||
|
async function tenantSender(mailLogId: string, config: MailConfig): Promise<{ from: string; replyTo?: string }> {
|
||||||
|
const log = await prisma.mailLog.findUnique({ where: { id: mailLogId }, select: { tenantId: true } });
|
||||||
|
if (!log?.tenantId) return { from: mailFrom(config), replyTo: config.replyTo };
|
||||||
|
const settings = await prisma.tenantSettings.findUnique({
|
||||||
|
where: { tenantId: log.tenantId },
|
||||||
|
select: { mailFromName: true, mailReplyTo: true },
|
||||||
|
});
|
||||||
|
const name = sanitizeDisplayName(settings?.mailFromName);
|
||||||
|
return {
|
||||||
|
from: name ? mailFrom({ ...config, fromName: name }) : mailFrom(config),
|
||||||
|
replyTo: settings?.mailReplyTo?.trim() || config.replyTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip characters that could break the From header (quotes, angle brackets, CR/LF). */
|
||||||
|
function sanitizeDisplayName(v: string | null | undefined): string | null {
|
||||||
|
const cleaned = (v ?? "").replace(/[\r\n"<>]/g, "").trim().slice(0, 80);
|
||||||
|
return cleaned || null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,19 +100,6 @@ export function diffAudit(before: unknown, after: unknown): DiffRow[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract request metadata if a writer stored it (writeAuditLog does not capture it yet). */
|
|
||||||
function requestMeta(...sources: unknown[]): { ip: string | null; userAgent: string | null } {
|
|
||||||
for (const s of sources) {
|
|
||||||
if (s && typeof s === "object") {
|
|
||||||
const o = s as Record<string, unknown>;
|
|
||||||
const ip = typeof o.ip === "string" ? o.ip : null;
|
|
||||||
const userAgent = typeof o.userAgent === "string" ? o.userAgent : null;
|
|
||||||
if (ip || userAgent) return { ip, userAgent };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ip: null, userAgent: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAuditEntry(ctx: ServiceCtx, id: unknown) {
|
export async function getAuditEntry(ctx: ServiceCtx, id: unknown) {
|
||||||
assertCan(ctx, "audit:read");
|
assertCan(ctx, "audit:read");
|
||||||
const entryId = z.string().min(1).max(64).parse(id);
|
const entryId = z.string().min(1).max(64).parse(id);
|
||||||
@@ -123,6 +110,8 @@ export async function getAuditEntry(ctx: ServiceCtx, id: unknown) {
|
|||||||
...row,
|
...row,
|
||||||
actorName: actor?.name ?? null,
|
actorName: actor?.name ?? null,
|
||||||
diff: diffAudit(row.before ?? undefined, row.after ?? undefined),
|
diff: diffAudit(row.before ?? undefined, row.after ?? undefined),
|
||||||
...requestMeta(row.after, row.before),
|
// captured by writeAuditLog from the request (spec §26); null for worker/script writes
|
||||||
|
ip: row.ipAddress,
|
||||||
|
userAgent: row.userAgent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user