forked from bai/curriculum-project-hub
f8c3e16a52
A-4 FeishuEventReceipt: - 新增 FeishuEventReceipt 表(eventId @unique),lark ws 至少一次投递的幂等兜底 - MessageReceiveEvent 加 header.event_id 字段 - trigger 入口查重:已存在的 event_id → 跳过(不建第二次 run) - resetDb 加入 FeishuEventReceipt A-8 审计写入: - AuditEntry 加 projectId(权限拒绝/slash 命令等无 run 的审计点可定位) - 新建 src/audit.ts:writeAudit(prisma, entry) best-effort 写入(吞错,不阻断 trigger) - trigger 五个审计点:trigger.denied / trigger.role_denied / run.created / run.lock_race / run.finished / run.failed - 新增 audit unit 测试(3) + trigger 集成测试(去重 + 审计,2) 59 tests 全过。
60 lines
2.6 KiB
TypeScript
60 lines
2.6 KiB
TypeScript
/**
|
|
* Audit write path — A-8. The `AuditEntry` table existed but had no writer;
|
|
* callers (trigger path, permission denials, slash commands) had no way to
|
|
* record what happened. This module is that single write seam.
|
|
*
|
|
* ADR reference: spec Audit. The skeleton pins only the `runId` relation;
|
|
* event type / actor / timestamp / details are content-OPEN. This helper
|
|
* exposes `action` (event type) + `actorUserId` (actor) + `metadata`
|
|
* (details) + `projectId` (locates audit points that don't have a run, e.g.
|
|
* permission denials) + `runId` (loose string ref, not a Prisma relation —
|
|
* see schema). `createdAt` is `@default(now())` so we don't pass it.
|
|
*
|
|
* Best-effort by design: an audit failure must never break the caller's path
|
|
* (a trigger failing because the audit log is down would be a spec breach —
|
|
* audit is observability, not control). Errors are swallowed here. There's no
|
|
* logger in this layer yet; a future logger can be threaded in without
|
|
* changing the signature.
|
|
*/
|
|
import type { PrismaClient, Prisma } from "@prisma/client";
|
|
|
|
export interface AuditInput {
|
|
readonly runId?: string | undefined;
|
|
readonly projectId?: string | undefined;
|
|
readonly actorUserId?: string | undefined;
|
|
readonly action: string;
|
|
readonly metadata: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Write one audit entry. Best-effort: any Prisma error is swallowed so the
|
|
* caller's path (trigger, permission gate, slash command) is unaffected.
|
|
*
|
|
* Optional fields are only included when defined — under
|
|
* `exactOptionalPropertyTypes`, assigning `undefined` to an optional key in a
|
|
* Prisma `create` payload is a type error (and would also write an explicit
|
|
* NULL rather than omit the column). Conditional assignment keeps the payload
|
|
* honest. The data object is typed as `AuditEntryUncheckedCreateInput` (the
|
|
* direct-FK variant) so it matches one branch of Prisma's `create` input
|
|
* union exactly — the relation-connect branch has no `projectId`/`runId`
|
|
* columns and would otherwise reject these fields.
|
|
*/
|
|
export async function writeAudit(
|
|
prisma: PrismaClient,
|
|
entry: AuditInput,
|
|
): Promise<void> {
|
|
const data: Prisma.AuditEntryUncheckedCreateInput = {
|
|
action: entry.action,
|
|
metadata: entry.metadata as Prisma.InputJsonValue,
|
|
};
|
|
if (entry.runId !== undefined) data.runId = entry.runId;
|
|
if (entry.projectId !== undefined) data.projectId = entry.projectId;
|
|
if (entry.actorUserId !== undefined) data.actorUserId = entry.actorUserId;
|
|
|
|
try {
|
|
await prisma.auditEntry.create({ data });
|
|
} catch {
|
|
// Swallow: audit is best-effort and must not break the caller's path.
|
|
}
|
|
}
|