feat: implement sensitive operation audit retention (TASK-WP6-04)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s

This commit is contained in:
suyx
2026-08-04 11:51:09 +08:00
parent c589b8bb4e
commit 1c46311e05
21 changed files with 1670 additions and 11 deletions
+148
View File
@@ -0,0 +1,148 @@
import type BetterSqlite3 from "better-sqlite3";
import type {
AdminAuditQuery,
AdminOperationAuditItem,
AdminOperationAuditResponse,
PrivateContentAccessAuditItem,
PrivateContentAccessAuditResponse,
} from "@dada/shared-contracts";
interface AuditCursor {
logId: string;
occurredAt: number;
}
interface AdminOperationRow {
actor_ref: string;
actor_type: "system" | "super_admin";
after_summary: string | null;
before_summary: string | null;
expires_at: number;
log_id: string;
occurred_at: number;
operation_type: string;
result: "failed" | "succeeded";
target_ref: string;
target_type: string;
}
interface PrivateContentAccessRow {
actor_ref: string;
content_type: "image" | "prompt";
expires_at: number;
log_id: string;
occurred_at: number;
target_ref: string;
}
export class AdminAuditQueryError extends Error {
constructor() {
super("admin_audit_query_invalid");
this.name = "AdminAuditQueryError";
}
}
function encodeCursor(row: { log_id: string; occurred_at: number }) {
return Buffer.from(JSON.stringify([row.occurred_at, row.log_id]), "utf8").toString("base64url");
}
function decodeCursor(cursor: string | undefined): AuditCursor | undefined {
if (!cursor) return undefined;
try {
const parsed: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
if (!Array.isArray(parsed) || parsed.length !== 2 || !Number.isSafeInteger(parsed[0])
|| typeof parsed[1] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(parsed[1])) {
throw new AdminAuditQueryError();
}
return { occurredAt: parsed[0] as number, logId: parsed[1] };
} catch (error) {
if (error instanceof AdminAuditQueryError) throw error;
throw new AdminAuditQueryError();
}
}
function normalizeLimit(limit: number | undefined) {
if (limit === undefined) return 50;
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new AdminAuditQueryError();
return limit;
}
function pageRows<Row extends { log_id: string; occurred_at: number }>(rows: Row[], limit: number) {
const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, limit) : rows;
return { items, nextCursor: hasMore ? encodeCursor(items[items.length - 1]!) : null };
}
function iso(value: number) {
return new Date(value).toISOString();
}
export function listAdminOperationAudit(
database: BetterSqlite3.Database,
query: AdminAuditQuery,
clock: () => number = Date.now,
): AdminOperationAuditResponse {
const cursor = decodeCursor(query.cursor);
const limit = normalizeLimit(query.limit);
const rows = (cursor
? database.prepare(`
SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id,
occurred_at, operation_type, result, target_ref, target_type
FROM admin_operation_logs
WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?)
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
`).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1)
: database.prepare(`
SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id,
occurred_at, operation_type, result, target_ref, target_type
FROM admin_operation_logs
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
`).all(limit + 1)) as AdminOperationRow[];
const page = pageRows(rows, limit);
const items: AdminOperationAuditItem[] = page.items.map((row) => ({
actor_ref: row.actor_ref,
actor_type: row.actor_type,
after_summary: row.after_summary,
before_summary: row.before_summary,
expires_at: iso(row.expires_at),
log_id: row.log_id,
occurred_at: iso(row.occurred_at),
operation_type: row.operation_type,
result: row.result,
target_ref: row.target_ref,
target_type: row.target_type,
}));
return { generated_at: iso(clock()), items, next_cursor: page.nextCursor };
}
export function listPrivateContentAccessAudit(
database: BetterSqlite3.Database,
query: AdminAuditQuery,
clock: () => number = Date.now,
): PrivateContentAccessAuditResponse {
const cursor = decodeCursor(query.cursor);
const limit = normalizeLimit(query.limit);
const rows = (cursor
? database.prepare(`
SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref
FROM private_content_access_logs
WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?)
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
`).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1)
: database.prepare(`
SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref
FROM private_content_access_logs
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
`).all(limit + 1)) as PrivateContentAccessRow[];
const page = pageRows(rows, limit);
const items: PrivateContentAccessAuditItem[] = page.items.map((row) => ({
actor_ref: row.actor_ref,
content_type: row.content_type,
expires_at: iso(row.expires_at),
log_id: row.log_id,
occurred_at: iso(row.occurred_at),
target_ref: row.target_ref,
}));
return { generated_at: iso(clock()), items, next_cursor: page.nextCursor };
}
+80
View File
@@ -9,10 +9,13 @@ import {
AccountProfileUpdateRequestSchema, AccountProfileUpdateRequestSchema,
AccountProfileUpdateResponseSchema, AccountProfileUpdateResponseSchema,
AccountSettingsResponseSchema, AccountSettingsResponseSchema,
AdminAuditQuerySchema,
AdminAuthenticatedUserSchema, AdminAuthenticatedUserSchema,
AdminGenerationRecordSchema, AdminGenerationRecordSchema,
AdminGenerationListResponseSchema, AdminGenerationListResponseSchema,
AdminOverviewResponseSchema, AdminOverviewResponseSchema,
AdminOperationAuditItemSchema,
AdminOperationAuditResponseSchema,
AdminServicesResponseSchema, AdminServicesResponseSchema,
AdminServiceHealthCheckRequestSchema, AdminServiceHealthCheckRequestSchema,
AdminServiceLimitRequestSchema, AdminServiceLimitRequestSchema,
@@ -78,6 +81,8 @@ import {
PrivateContentNoticeAckRequestSchema, PrivateContentNoticeAckRequestSchema,
PrivateContentNoticeAckResponseSchema, PrivateContentNoticeAckResponseSchema,
PrivateContentPromptResponseSchema, PrivateContentPromptResponseSchema,
PrivateContentAccessAuditItemSchema,
PrivateContentAccessAuditResponseSchema,
FailedEmptyTrashRequestSchema, FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema, FailedEmptyTrashResponseSchema,
ExportFormatSchema, ExportFormatSchema,
@@ -129,6 +134,7 @@ import {
type AdminLoginCompleteRequest, type AdminLoginCompleteRequest,
type AdminLoginSendRequest, type AdminLoginSendRequest,
type AdminOverviewResponse, type AdminOverviewResponse,
type AdminAuditQuery,
type AdminDiagnosticsResponse, type AdminDiagnosticsResponse,
type AdminServicesStorageResponse, type AdminServicesStorageResponse,
type AccountDeletionCompleteRequest, type AccountDeletionCompleteRequest,
@@ -195,6 +201,11 @@ import {
registrationFieldError, registrationFieldError,
} from "./registration-errors.js"; } from "./registration-errors.js";
import type { RegistrationService } from "./registration.js"; import type { RegistrationService } from "./registration.js";
import {
AdminAuditQueryError,
listAdminOperationAudit,
listPrivateContentAccessAudit,
} from "./admin-audit.js";
import type { AssetPreviewGrantService } from "./preview-grants.js"; import type { AssetPreviewGrantService } from "./preview-grants.js";
import type { RecentAssetService } from "./recent-assets.js"; import type { RecentAssetService } from "./recent-assets.js";
import type { AmapAdapter } from "./amap-adapter.js"; import type { AmapAdapter } from "./amap-adapter.js";
@@ -749,11 +760,16 @@ export async function createApp(options: CreateAppOptions = {}) {
AdminLoginCompleteRequestSchema, AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema, AdminLoginCompleteResponseSchema,
AdminSessionResponseSchema, AdminSessionResponseSchema,
AdminAuditQuerySchema,
AdminOperationAuditItemSchema,
AdminOperationAuditResponseSchema,
AdminGenerationRecordSchema, AdminGenerationRecordSchema,
AdminGenerationListResponseSchema, AdminGenerationListResponseSchema,
PrivateContentNoticeAckRequestSchema, PrivateContentNoticeAckRequestSchema,
PrivateContentNoticeAckResponseSchema, PrivateContentNoticeAckResponseSchema,
PrivateContentPromptResponseSchema, PrivateContentPromptResponseSchema,
PrivateContentAccessAuditItemSchema,
PrivateContentAccessAuditResponseSchema,
PrivateContentGenerationParamsSchema, PrivateContentGenerationParamsSchema,
AdminOverviewResponseSchema, AdminOverviewResponseSchema,
AdminServicesResponseSchema, AdminServicesResponseSchema,
@@ -1684,6 +1700,70 @@ export async function createApp(options: CreateAppOptions = {}) {
}, },
); );
app.get(
"/api/v1/admin/audit/operations",
{
schema: {
operationId: "getAdminOperationAudit",
querystring: Type.Ref(AdminAuditQuerySchema),
response: {
200: Type.Ref(AdminOperationAuditResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Null(),
},
tags: ["Admin Operations"],
},
},
async (request, reply) => {
if (!options.registration) return reply.code(503).send(null);
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
const session = token ? options.registration.readAdminSession(token) : undefined;
if (!session) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
}
try {
reply.header("Cache-Control", "private, no-store");
return listAdminOperationAudit(options.registration.database, request.query as AdminAuditQuery);
} catch (error) {
if (error instanceof AdminAuditQueryError) return reply.code(400).send(null);
throw error;
}
},
);
app.get(
"/api/v1/admin/audit/private-content",
{
schema: {
operationId: "getPrivateContentAccessAudit",
querystring: Type.Ref(AdminAuditQuerySchema),
response: {
200: Type.Ref(PrivateContentAccessAuditResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Null(),
},
tags: ["Admin Operations"],
},
},
async (request, reply) => {
if (!options.registration) return reply.code(503).send(null);
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
const session = token ? options.registration.readAdminSession(token) : undefined;
if (!session) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
}
try {
reply.header("Cache-Control", "private, no-store");
return listPrivateContentAccessAudit(options.registration.database, request.query as AdminAuditQuery);
} catch (error) {
if (error instanceof AdminAuditQueryError) return reply.code(400).send(null);
throw error;
}
},
);
app.get( app.get(
"/api/v1/admin/overview", "/api/v1/admin/overview",
{ {
+57 -2
View File
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3"; import type BetterSqlite3 from "better-sqlite3";
import { import {
auditRetentionMilliseconds,
ensureAdminOperationAuditSchema, ensureAdminOperationAuditSchema,
ensurePrivateAccessAuditSchema, ensurePrivateAccessAuditSchema,
isSafeAuditRef, isSafeAuditRef,
@@ -281,6 +282,39 @@ export class RegistrationService {
return { code, inviteId }; return { code, inviteId };
} }
createAdminInvite(input: { actorId: string; expiresAt: number; maxUses: number }) {
if (!Number.isSafeInteger(input.expiresAt) || !Number.isSafeInteger(input.maxUses) || input.maxUses < 1) {
throw new Error("Invite request is invalid.");
}
const code = this.options.inviteCodeGenerator();
const inviteId = randomUUID();
const now = this.options.clock();
this.runImmediate("invite_create", () => {
const admin = this.database.prepare(`
SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
`).get(input.actorId);
if (!admin) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
this.database.prepare(`
INSERT INTO invite_codes (
invite_id, code_hmac, max_uses, used_count, expires_at, status, created_at
) VALUES (?, ?, ?, 0, ?, 'enabled', ?)
`).run(inviteId, this.inviteHmac(code), input.maxUses, input.expiresAt, now);
this.recordAdminAudit({
actorRef: input.actorId,
actorType: "super_admin",
afterSummary: { max_uses: input.maxUses, status: "enabled" },
beforeSummary: null,
operationType: "invite_create",
result: "succeeded",
targetRef: inviteId,
targetType: "invite",
}, now);
return { outcome: "committed", value: undefined };
});
return { code, inviteId };
}
async sendRegistrationCode(input: { email: string; inviteCode: string }): Promise<RegistrationSendResult> { async sendRegistrationCode(input: { email: string; inviteCode: string }): Promise<RegistrationSendResult> {
const email = normalizeEmail(input.email); const email = normalizeEmail(input.email);
const inviteCode = normalizeProfileValue(input.inviteCode, 160); const inviteCode = normalizeProfileValue(input.inviteCode, 160);
@@ -1241,14 +1275,35 @@ export class RegistrationService {
return outcome; return outcome;
} }
changeUserStatus(userId: string, status: "suspended" | "deleted") { changeUserStatus(userId: string, status: "suspended" | "deleted", actorId?: string) {
const now = this.options.clock(); const now = this.options.clock();
this.runImmediate("session_revoke", () => { this.runImmediate("session_revoke", () => {
if (actorId) {
const admin = this.database.prepare(`
SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
`).get(actorId);
if (!admin) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
}
const before = this.database.prepare("SELECT status FROM users WHERE user_id = ? AND role = 'user'")
.get(userId) as { status: "active" | "suspended" | "deleted" } | undefined;
const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'") const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'")
.run(status, userId); .run(status, userId);
if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
.run(now, userId); .run(now, userId);
if (actorId) {
this.recordAdminAudit({
actorRef: actorId,
actorType: "super_admin",
afterSummary: { status },
beforeSummary: { status: before?.status ?? "unknown" },
operationType: "user_status_change",
result: "succeeded",
targetRef: userId,
targetType: "user_account",
}, now);
}
return { outcome: "committed", value: undefined }; return { outcome: "committed", value: undefined };
}); });
} }
@@ -1772,7 +1827,7 @@ export class RegistrationService {
serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.beforeSummary),
serializeAuditSummary(input.afterSummary), serializeAuditSummary(input.afterSummary),
now, now,
now + 180 * 24 * 60 * 60 * 1_000, now + auditRetentionMilliseconds,
); );
} }
+46
View File
@@ -9,6 +9,12 @@ import sharp, { type Metadata } from "sharp";
import type { StaticStickerCatalogItem } from "@dada/static-sticker-catalog"; import type { StaticStickerCatalogItem } from "@dada/static-sticker-catalog";
import {
auditRetentionMilliseconds,
isSafeAuditRef,
isSafeAuditSummaryJson,
serializeAuditSummary,
} from "./audit-policy.js";
import { ManagedStorage, type StagedManagedFile } from "./managed-storage.js"; import { ManagedStorage, type StagedManagedFile } from "./managed-storage.js";
import { StickerReleaseError } from "./sticker-release-errors.js"; import { StickerReleaseError } from "./sticker-release-errors.js";
import { classifyCapacity } from "./storage-policy.js"; import { classifyCapacity } from "./storage-policy.js";
@@ -115,6 +121,12 @@ export class StickerReleaseService {
this.database.pragma("journal_mode = WAL"); this.database.pragma("journal_mode = WAL");
this.database.pragma("foreign_keys = ON"); this.database.pragma("foreign_keys = ON");
this.database.pragma("busy_timeout = 5000"); this.database.pragma("busy_timeout = 5000");
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
this.storage = input.storage; this.storage = input.storage;
this.migrate(); this.migrate();
} }
@@ -234,6 +246,13 @@ export class StickerReleaseService {
WHERE release_version = ? AND stable_id = ? WHERE release_version = ? AND stable_id = ?
`).run((input.enabled ?? existing.enabled === 1) ? 1 : 0, part, order, version, input.stableId); `).run((input.enabled ?? existing.enabled === 1) ? 1 : 0, part, order, version, input.stableId);
this.finalizeRelease(version, current, input.actorId); this.finalizeRelease(version, current, input.actorId);
this.insertReleaseAudit({
actorId: input.actorId,
afterSummary: { enabled: input.enabled ?? existing.enabled === 1, order, part, stable_id: input.stableId },
beforeSummary: { enabled: existing.enabled === 1, order: existing.order_index, part: existing.part, stable_id: input.stableId },
operationType: "sticker_release_update",
releaseVersion: version,
});
return version; return version;
}); });
return { item: itemView(this.readItem(releaseVersion, input.stableId)!), release_version: releaseVersion }; return { item: itemView(this.readItem(releaseVersion, input.stableId)!), release_version: releaseVersion };
@@ -342,9 +361,36 @@ export class StickerReleaseService {
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(input.actorId, input.keyDigest, input.requestHash, releaseVersion, input.stableId, iso(this.clock())); `).run(input.actorId, input.keyDigest, input.requestHash, releaseVersion, input.stableId, iso(this.clock()));
this.finalizeRelease(releaseVersion, previous, input.actorId); this.finalizeRelease(releaseVersion, previous, input.actorId);
this.insertReleaseAudit({
actorId: input.actorId,
afterSummary: { enabled: input.enabled, order: input.order, part: input.part, stable_id: input.stableId },
beforeSummary: previous ? { release_version: previous } : null,
operationType: "sticker_release_publish",
releaseVersion,
});
return releaseVersion; return releaseVersion;
} }
private insertReleaseAudit(input: {
actorId: string;
afterSummary: Record<string, unknown>;
beforeSummary: Record<string, unknown> | null;
operationType: "sticker_release_publish" | "sticker_release_update";
releaseVersion: string;
}) {
const occurredAt = this.clock();
this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'super_admin', ?, ?, 'sticker_release', ?, 'succeeded', ?, ?, ?, ?)
`).run(
randomUUID(), input.actorId, input.operationType, input.releaseVersion,
serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary),
occurredAt, occurredAt + auditRetentionMilliseconds,
);
}
private finalizeRelease(releaseVersion: string, previous: string | null, actorId: string) { private finalizeRelease(releaseVersion: string, previous: string | null, actorId: string) {
const rows = this.database.prepare(` const rows = this.database.prepare(`
SELECT stable_id, part, order_index, original_sha256, thumbnail_sha256, enabled SELECT stable_id, part, order_index, original_sha256, thumbnail_sha256, enabled
+164
View File
@@ -0,0 +1,164 @@
.admin-audit-page {
width: min(100% - 48px, 1440px);
margin: 0 auto;
padding: 28px 0 40px;
color: #1a1a18;
}
.admin-audit-heading {
display: flex;
min-height: 72px;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
border-bottom: 2px solid #1a1a18;
}
.admin-audit-heading p,
.admin-audit-heading h2 {
margin: 0;
}
.admin-audit-heading p {
color: #686861;
font-size: 12px;
font-weight: 800;
}
.admin-audit-heading h2 {
padding: 4px 0 12px;
font-size: 28px;
line-height: 40px;
}
.admin-audit-heading time {
padding-bottom: 14px;
color: #686861;
font-size: 12px;
}
.admin-audit-tabs {
display: flex;
gap: 0;
margin-top: 24px;
border-bottom: 1px solid #a9a9a2;
}
.admin-audit-tabs button {
min-height: 40px;
padding: 0 18px;
border: 0;
border-bottom: 3px solid transparent;
color: #4f4f49;
background: transparent;
font-weight: 700;
}
.admin-audit-tabs button[aria-selected="true"] {
border-bottom-color: #1a1a18;
color: #1a1a18;
background: #f4df32;
}
.admin-audit-failure {
display: flex;
min-height: 44px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding: 8px 12px;
border-left: 4px solid #c92a24;
background: #fff1ef;
}
.admin-audit-failure button,
.admin-audit-pagination button {
min-height: 36px;
padding: 0 14px;
border: 1px solid #1a1a18;
background: #fff;
font-weight: 700;
}
.admin-audit-status {
margin: 0;
padding: 48px 16px;
color: #686861;
}
.admin-audit-table-scroll {
overflow-x: auto;
border-bottom: 1px solid #a9a9a2;
}
.admin-audit-page table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
table-layout: fixed;
}
.admin-audit-page th,
.admin-audit-page td {
min-height: 40px;
padding: 10px 12px;
border-bottom: 1px solid #d7d7d1;
overflow-wrap: anywhere;
text-align: left;
vertical-align: top;
font-size: 12px;
}
.admin-audit-page th {
color: #55554f;
background: #efefeb;
font-weight: 800;
}
.admin-audit-page th:nth-child(1) { width: 132px; }
.admin-audit-page th:nth-child(2) { width: 210px; }
.admin-audit-page th:nth-child(3) { width: 180px; }
.admin-audit-page th:nth-child(5) { width: 100px; }
.admin-audit-page th:nth-child(6) { width: 210px; }
.admin-audit-page td small {
display: block;
margin-top: 4px;
color: #686861;
}
.admin-audit-page td strong {
color: #16794b;
}
.admin-audit-page td strong.is-failed {
color: #c92a24;
}
.admin-audit-pagination {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
.admin-audit-retention {
margin: 24px 0 0;
padding-top: 12px;
border-top: 1px solid #d7d7d1;
color: #686861;
font-size: 12px;
}
.admin-audit-page :focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
@media (max-width: 700px) {
.admin-audit-page { width: calc(100% - 24px); }
.admin-audit-heading { align-items: flex-start; flex-direction: column; gap: 4px; }
.admin-audit-heading time { padding-bottom: 12px; }
.admin-audit-tabs { display: grid; grid-template-columns: 1fr 1fr; }
.admin-audit-tabs button { min-width: 0; padding: 8px; }
}
+181
View File
@@ -0,0 +1,181 @@
import type {
AdminOperationAuditItem,
AdminOperationAuditResponse,
PrivateContentAccessAuditItem,
PrivateContentAccessAuditResponse,
} from "@dada/shared-contracts";
import { useCallback, useEffect, useState } from "react";
import "./admin-audit.css";
type AuditTab = "operations" | "private-content";
interface AuditPageState<Item> {
failed: boolean;
generatedAt: string | null;
items: Item[];
loading: boolean;
nextCursor: string | null;
}
const emptyState = <Item,>(): AuditPageState<Item> => ({
failed: false,
generatedAt: null,
items: [],
loading: false,
nextCursor: null,
});
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
second: "2-digit",
}).format(new Date(value));
}
function operationSummary(item: AdminOperationAuditItem) {
if (item.after_summary) return item.after_summary;
if (item.before_summary) return item.before_summary;
return "无变更摘要";
}
export function AdminAuditPage() {
const [tab, setTab] = useState<AuditTab>("operations");
const [operations, setOperations] = useState<AuditPageState<AdminOperationAuditItem>>(emptyState);
const [privateAccess, setPrivateAccess] = useState<AuditPageState<PrivateContentAccessAuditItem>>(emptyState);
const loadOperations = useCallback(async (cursor?: string, append = false) => {
setOperations((current) => ({ ...current, failed: false, loading: true }));
try {
const query = new URLSearchParams({ limit: "50" });
if (cursor) query.set("cursor", cursor);
const response = await fetch(`/api/v1/admin/audit/operations?${query}`, { credentials: "same-origin" });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
return;
}
if (!response.ok) throw new Error("admin_operation_audit_unavailable");
const body = await response.json() as AdminOperationAuditResponse;
setOperations((current) => ({
failed: false,
generatedAt: body.generated_at,
items: append ? [...current.items, ...body.items] : body.items,
loading: false,
nextCursor: body.next_cursor,
}));
} catch {
setOperations((current) => ({ ...current, failed: true, loading: false }));
}
}, []);
const loadPrivateAccess = useCallback(async (cursor?: string, append = false) => {
setPrivateAccess((current) => ({ ...current, failed: false, loading: true }));
try {
const query = new URLSearchParams({ limit: "50" });
if (cursor) query.set("cursor", cursor);
const response = await fetch(`/api/v1/admin/audit/private-content?${query}`, { credentials: "same-origin" });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
return;
}
if (!response.ok) throw new Error("private_content_audit_unavailable");
const body = await response.json() as PrivateContentAccessAuditResponse;
setPrivateAccess((current) => ({
failed: false,
generatedAt: body.generated_at,
items: append ? [...current.items, ...body.items] : body.items,
loading: false,
nextCursor: body.next_cursor,
}));
} catch {
setPrivateAccess((current) => ({ ...current, failed: true, loading: false }));
}
}, []);
useEffect(() => { void loadOperations(); }, [loadOperations]);
function selectTab(next: AuditTab) {
setTab(next);
if (next === "private-content" && !privateAccess.generatedAt && !privateAccess.loading) void loadPrivateAccess();
}
const state = tab === "operations" ? operations : privateAccess;
const reload = tab === "operations" ? loadOperations : loadPrivateAccess;
return (
<main className="admin-audit-page" id="admin-main">
<header className="admin-audit-heading">
<div><p>IMMUTABLE / 180 DAYS</p><h2></h2></div>
{state.generatedAt ? <time dateTime={state.generatedAt}> {formatTime(state.generatedAt)}</time> : null}
</header>
<div aria-label="审计类型" className="admin-audit-tabs" role="tablist">
<button aria-controls="operation-audit-panel" aria-selected={tab === "operations"} id="operation-audit-tab" onClick={() => selectTab("operations")} role="tab" type="button"></button>
<button aria-controls="private-audit-panel" aria-selected={tab === "private-content"} id="private-audit-tab" onClick={() => selectTab("private-content")} role="tab" type="button">访</button>
</div>
{state.failed ? (
<div className="admin-audit-failure" role="alert">
<span>{state.generatedAt ? ",已保留上次结果" : ""}</span>
<button disabled={state.loading} onClick={() => void reload()} type="button"></button>
</div>
) : null}
{tab === "operations" ? (
<section aria-labelledby="operation-audit-tab" id="operation-audit-panel" role="tabpanel">
{operations.loading && operations.items.length === 0 ? <p aria-live="polite" className="admin-audit-status"></p> : null}
{!operations.loading && !operations.failed && operations.items.length === 0 ? <p className="admin-audit-status"></p> : null}
{operations.items.length > 0 ? (
<div className="admin-audit-table-scroll">
<table>
<thead><tr><th></th><th></th><th></th><th></th><th></th><th>Operation ID</th></tr></thead>
<tbody>{operations.items.map((item) => (
<tr key={item.log_id}>
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
<td><code>{item.actor_ref}</code><small>{item.actor_type}</small></td>
<td><code>{item.operation_type}</code></td>
<td><code>{item.target_type}:{item.target_ref}</code><small>{operationSummary(item)}</small></td>
<td><strong className={`is-${item.result}`}>{item.result}</strong></td>
<td><code>{item.log_id}</code></td>
</tr>
))}</tbody>
</table>
</div>
) : null}
</section>
) : (
<section aria-labelledby="private-audit-tab" id="private-audit-panel" role="tabpanel">
{privateAccess.loading && privateAccess.items.length === 0 ? <p aria-live="polite" className="admin-audit-status">访</p> : null}
{!privateAccess.loading && !privateAccess.failed && privateAccess.items.length === 0 ? <p className="admin-audit-status">访</p> : null}
{privateAccess.items.length > 0 ? (
<div className="admin-audit-table-scroll">
<table>
<thead><tr><th></th><th></th><th></th><th></th><th></th><th>Access ID</th></tr></thead>
<tbody>{privateAccess.items.map((item) => (
<tr key={item.log_id}>
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
<td><code>{item.actor_ref}</code></td>
<td><code>{item.target_ref}</code></td>
<td>{item.content_type}</td>
<td><time dateTime={item.expires_at}>{formatTime(item.expires_at)}</time></td>
<td><code>{item.log_id}</code></td>
</tr>
))}</tbody>
</table>
</div>
) : null}
</section>
)}
{state.nextCursor ? (
<div className="admin-audit-pagination">
<button disabled={state.loading} onClick={() => void reload(state.nextCursor!, true)} type="button">{state.loading ? "正在读取" : "下一页"}</button>
</div>
) : null}
<p className="admin-audit-retention"> 180 </p>
</main>
);
}
+15 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand. // Generated from openapi/openapi.json. Do not edit by hand.
import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOverviewResponse, AdminServicesResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js"; import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOperationAuditResponse, AdminOverviewResponse, AdminServicesResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, PrivateContentAccessAuditResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -120,6 +120,13 @@ export async function getAdminDiagnostics(options: ClientOptions = {}): Promise<
return response.json() as Promise<AdminDiagnosticsResponse>; return response.json() as Promise<AdminDiagnosticsResponse>;
} }
export async function getAdminOperationAudit(options: ClientOptions = {}): Promise<AdminOperationAuditResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/operations`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminOperationAuditResponse>;
}
export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> { export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
@@ -208,6 +215,13 @@ export async function getMyCredits(options: ClientOptions = {}): Promise<CreditB
return response.json() as Promise<CreditBalanceResponse>; return response.json() as Promise<CreditBalanceResponse>;
} }
export async function getPrivateContentAccessAudit(options: ClientOptions = {}): Promise<PrivateContentAccessAuditResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/private-content`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<PrivateContentAccessAuditResponse>;
}
export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> { export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} });
+40
View File
@@ -50,6 +50,11 @@ export type AccountSettingsResponse = {
}; };
}; };
export type AdminAuditQuery = {
"cursor"?: string;
"limit"?: number;
};
export type AdminAuthenticatedUser = { export type AdminAuthenticatedUser = {
"role": "super_admin"; "role": "super_admin";
"status": "active"; "status": "active";
@@ -112,6 +117,26 @@ export type AdminLoginSendRequest = {
"email": string; "email": string;
}; };
export type AdminOperationAuditItem = {
"actor_ref": string;
"actor_type": "system" | "super_admin";
"after_summary": string | null;
"before_summary": string | null;
"expires_at": string;
"log_id": string;
"occurred_at": string;
"operation_type": string;
"result": "succeeded" | "failed";
"target_ref": string;
"target_type": string;
};
export type AdminOperationAuditResponse = {
"generated_at": string;
"items": Array<AdminOperationAuditItem>;
"next_cursor": string | null;
};
export type AdminOverviewResponse = { export type AdminOverviewResponse = {
"asset_cleanup": { "asset_cleanup": {
"pending_jobs": number; "pending_jobs": number;
@@ -681,6 +706,21 @@ export type ModelRuntimeSseEvent = {
"runtime_availability_version": number; "runtime_availability_version": number;
}; };
export type PrivateContentAccessAuditItem = {
"actor_ref": string;
"content_type": "image" | "prompt";
"expires_at": string;
"log_id": string;
"occurred_at": string;
"target_ref": string;
};
export type PrivateContentAccessAuditResponse = {
"generated_at": string;
"items": Array<PrivateContentAccessAuditItem>;
"next_cursor": string | null;
};
export type PrivateContentGenerationParams = { export type PrivateContentGenerationParams = {
"generationId": string; "generationId": string;
}; };
+2 -1
View File
@@ -10,6 +10,7 @@ import { AdminModelsPage } from "./admin-models.js";
import { AdminAssetsPage } from "./admin-assets.js"; import { AdminAssetsPage } from "./admin-assets.js";
import { AdminGenerationsPage } from "./admin-generations.js"; import { AdminGenerationsPage } from "./admin-generations.js";
import { AdminServicesStoragePage } from "./admin-services-storage.js"; import { AdminServicesStoragePage } from "./admin-services-storage.js";
import { AdminAuditPage } from "./admin-audit.js";
import { CreditsPage } from "./credits-page.js"; import { CreditsPage } from "./credits-page.js";
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js"; import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
import { EditorPage } from "./editor-page.js"; import { EditorPage } from "./editor-page.js";
@@ -43,7 +44,7 @@ function renderAuthenticationEntry() {
const adminPages: Record<string, { content: ReactNode; title: string }> = { const adminPages: Record<string, { content: ReactNode; title: string }> = {
"/admin": { content: <AdminOverviewPage />, title: "运营总览" }, "/admin": { content: <AdminOverviewPage />, title: "运营总览" },
"/admin/assets": { content: <AdminAssetsPage />, title: "素材" }, "/admin/assets": { content: <AdminAssetsPage />, title: "素材" },
"/admin/audit": { content: <AdminPlaceholderPage title="审计" />, title: "审计" }, "/admin/audit": { content: <AdminAuditPage />, title: "审计" },
"/admin/generations": { content: <AdminGenerationsPage />, title: "生成记录" }, "/admin/generations": { content: <AdminGenerationsPage />, title: "生成记录" },
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" }, "/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
"/admin/models": { content: <AdminModelsPage />, title: "模型" }, "/admin/models": { content: <AdminModelsPage />, title: "模型" },
+26 -4
View File
@@ -212,10 +212,19 @@ export class ProjectPurgeCleanup {
transaction.immediate(); transaction.immediate();
completed += 1; completed += 1;
} catch { } catch {
this.database.prepare(` const transaction = this.database.transaction(() => {
UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed' this.database.prepare(`
WHERE cleanup_id = ? UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed'
`).run(row.cleanup_id); WHERE cleanup_id = ?
`).run(row.cleanup_id);
if (row.managed_file_id && this.tableExists("asset_cleanup_request_items")) {
const request = this.database.prepare(`
SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ? LIMIT 1
`).get(row.managed_file_id) as { request_id: string } | undefined;
if (request) this.insertAssetCleanupFailureAudit(request.request_id, this.clock());
}
});
transaction.immediate();
failed += 1; failed += 1;
} }
} }
@@ -296,6 +305,19 @@ export class ProjectPurgeCleanup {
); );
} }
private insertAssetCleanupFailureAudit(requestId: string, occurredAt: number) {
if (!this.tableExists("admin_operation_logs")) return;
this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'system', 'project_purge_worker', 'asset_cleanup_physical_failed', 'asset_cleanup', ?, 'failed', NULL, ?, ?, ?)
`).run(
randomUUID(), requestId, JSON.stringify({ failed_count: 1, status: "retry_pending" }), occurredAt,
occurredAt + auditRetentionMilliseconds,
);
}
private remeasureManagedCapacity() { private remeasureManagedCapacity() {
const state = this.database.prepare(` const state = this.database.prepare(`
SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1 SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1
+353
View File
@@ -260,6 +260,22 @@
], ],
"type": "object" "type": "object"
}, },
"AdminAuditQuery": {
"additionalProperties": false,
"properties": {
"cursor": {
"maxLength": 512,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
},
"limit": {
"maximum": 100,
"minimum": 1,
"type": "integer"
}
},
"type": "object"
},
"AdminAuthenticatedUser": { "AdminAuthenticatedUser": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -717,6 +733,143 @@
], ],
"type": "object" "type": "object"
}, },
"AdminOperationAuditItem": {
"additionalProperties": false,
"properties": {
"actor_ref": {
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
"type": "string"
},
"actor_type": {
"anyOf": [
{
"enum": [
"system"
],
"type": "string"
},
{
"enum": [
"super_admin"
],
"type": "string"
}
]
},
"after_summary": {
"anyOf": [
{
"maxLength": 2048,
"type": "string"
},
{
"type": "null"
}
]
},
"before_summary": {
"anyOf": [
{
"maxLength": 2048,
"type": "string"
},
{
"type": "null"
}
]
},
"expires_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
"type": "string"
},
"log_id": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
},
"occurred_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
"type": "string"
},
"operation_type": {
"maxLength": 160,
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
"type": "string"
},
"result": {
"anyOf": [
{
"enum": [
"succeeded"
],
"type": "string"
},
{
"enum": [
"failed"
],
"type": "string"
}
]
},
"target_ref": {
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
"type": "string"
},
"target_type": {
"maxLength": 160,
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
"type": "string"
}
},
"required": [
"actor_ref",
"actor_type",
"after_summary",
"before_summary",
"expires_at",
"log_id",
"occurred_at",
"operation_type",
"result",
"target_ref",
"target_type"
],
"type": "object"
},
"AdminOperationAuditResponse": {
"additionalProperties": false,
"properties": {
"generated_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
"type": "string"
},
"items": {
"items": {
"$ref": "#/components/schemas/AdminOperationAuditItem"
},
"maxItems": 100,
"type": "array"
},
"next_cursor": {
"anyOf": [
{
"maxLength": 512,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"generated_at",
"items",
"next_cursor"
],
"type": "object"
},
"AdminOverviewResponse": { "AdminOverviewResponse": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -4471,6 +4624,90 @@
], ],
"type": "object" "type": "object"
}, },
"PrivateContentAccessAuditItem": {
"additionalProperties": false,
"properties": {
"actor_ref": {
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
"type": "string"
},
"content_type": {
"anyOf": [
{
"enum": [
"image"
],
"type": "string"
},
{
"enum": [
"prompt"
],
"type": "string"
}
]
},
"expires_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
"type": "string"
},
"log_id": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
},
"occurred_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
"type": "string"
},
"target_ref": {
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
"type": "string"
}
},
"required": [
"actor_ref",
"content_type",
"expires_at",
"log_id",
"occurred_at",
"target_ref"
],
"type": "object"
},
"PrivateContentAccessAuditResponse": {
"additionalProperties": false,
"properties": {
"generated_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
"type": "string"
},
"items": {
"items": {
"$ref": "#/components/schemas/PrivateContentAccessAuditItem"
},
"maxItems": 100,
"type": "array"
},
"next_cursor": {
"anyOf": [
{
"maxLength": 512,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"generated_at",
"items",
"next_cursor"
],
"type": "object"
},
"PrivateContentGenerationParams": { "PrivateContentGenerationParams": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -6214,6 +6451,122 @@
] ]
} }
}, },
"/api/v1/admin/audit/operations": {
"get": {
"operationId": "getAdminOperationAudit",
"parameters": [
{
"in": "query",
"name": "cursor",
"required": false,
"schema": {
"maxLength": 512,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"maximum": 100,
"minimum": 1,
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminOperationAuditResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"503": {
"description": "Default Response"
}
},
"tags": [
"Admin Operations"
]
}
},
"/api/v1/admin/audit/private-content": {
"get": {
"operationId": "getPrivateContentAccessAudit",
"parameters": [
{
"in": "query",
"name": "cursor",
"required": false,
"schema": {
"maxLength": 512,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"maximum": 100,
"minimum": 1,
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PrivateContentAccessAuditResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"503": {
"description": "Default Response"
}
},
"tags": [
"Admin Operations"
]
}
},
"/api/v1/admin/diagnostics": { "/api/v1/admin/diagnostics": {
"get": { "get": {
"operationId": "getAdminDiagnostics", "operationId": "getAdminDiagnostics",
+3 -1
View File
@@ -14,7 +14,7 @@
"test:integration": "vitest run tests/integration", "test:integration": "vitest run tests/integration",
"test:api": "pnpm check:openapi && vitest run tests/api", "test:api": "pnpm check:openapi && vitest run tests/api",
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp5-05-admin-assets.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts", "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp5-05-admin-assets.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts tests/e2e/wp6-04-audit.spec.ts tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts",
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
@@ -99,6 +99,8 @@
"test:wp5-05:red": "node scripts/run-wp5-05-validation.mjs --phase red", "test:wp5-05:red": "node scripts/run-wp5-05-validation.mjs --phase red",
"test:wp6-01": "node scripts/run-wp6-01-validation.mjs --phase scaffold", "test:wp6-01": "node scripts/run-wp6-01-validation.mjs --phase scaffold",
"test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red", "test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red",
"test:wp6-04": "node scripts/run-wp6-04-validation.mjs --phase green",
"test:wp6-04:red": "node scripts/run-wp6-04-validation.mjs --phase red",
"test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts" "test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts"
}, },
"devDependencies": { "devDependencies": {
+61
View File
@@ -70,6 +70,67 @@ export type PrivateContentNoticeAckRequest = Static<typeof PrivateContentNoticeA
export type PrivateContentNoticeAckResponse = Static<typeof PrivateContentNoticeAckResponseSchema>; export type PrivateContentNoticeAckResponse = Static<typeof PrivateContentNoticeAckResponseSchema>;
export type PrivateContentPromptResponse = Static<typeof PrivateContentPromptResponseSchema>; export type PrivateContentPromptResponse = Static<typeof PrivateContentPromptResponseSchema>;
export const AdminAuditQuerySchema = Type.Object(
{
cursor: Type.Optional(Type.String({ maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" })),
limit: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })),
},
{ additionalProperties: false, $id: "AdminAuditQuery" },
);
export const AdminOperationAuditItemSchema = Type.Object(
{
actor_ref: Type.String({ pattern: safeReferencePattern }),
actor_type: Type.Union([Type.Literal("system"), Type.Literal("super_admin")]),
after_summary: Type.Union([Type.String({ maxLength: 2048 }), Type.Null()]),
before_summary: Type.Union([Type.String({ maxLength: 2048 }), Type.Null()]),
expires_at: Type.String({ pattern: isoTimestampPattern }),
log_id: Type.String({ pattern: uuidPattern }),
occurred_at: Type.String({ pattern: isoTimestampPattern }),
operation_type: Type.String({ maxLength: 160, pattern: safeReferencePattern }),
result: Type.Union([Type.Literal("succeeded"), Type.Literal("failed")]),
target_ref: Type.String({ pattern: safeReferencePattern }),
target_type: Type.String({ maxLength: 160, pattern: safeReferencePattern }),
},
{ additionalProperties: false, $id: "AdminOperationAuditItem" },
);
export const AdminOperationAuditResponseSchema = Type.Object(
{
generated_at: Type.String({ pattern: isoTimestampPattern }),
items: Type.Array(Type.Ref(AdminOperationAuditItemSchema), { maxItems: 100 }),
next_cursor: Type.Union([Type.String({ maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" }), Type.Null()]),
},
{ additionalProperties: false, $id: "AdminOperationAuditResponse" },
);
export const PrivateContentAccessAuditItemSchema = Type.Object(
{
actor_ref: Type.String({ pattern: safeReferencePattern }),
content_type: Type.Union([Type.Literal("image"), Type.Literal("prompt")]),
expires_at: Type.String({ pattern: isoTimestampPattern }),
log_id: Type.String({ pattern: uuidPattern }),
occurred_at: Type.String({ pattern: isoTimestampPattern }),
target_ref: Type.String({ pattern: safeReferencePattern }),
},
{ additionalProperties: false, $id: "PrivateContentAccessAuditItem" },
);
export const PrivateContentAccessAuditResponseSchema = Type.Object(
{
generated_at: Type.String({ pattern: isoTimestampPattern }),
items: Type.Array(Type.Ref(PrivateContentAccessAuditItemSchema), { maxItems: 100 }),
next_cursor: Type.Union([Type.String({ maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" }), Type.Null()]),
},
{ additionalProperties: false, $id: "PrivateContentAccessAuditResponse" },
);
export type AdminAuditQuery = Static<typeof AdminAuditQuerySchema>;
export type AdminOperationAuditItem = Static<typeof AdminOperationAuditItemSchema>;
export type AdminOperationAuditResponse = Static<typeof AdminOperationAuditResponseSchema>;
export type PrivateContentAccessAuditItem = Static<typeof PrivateContentAccessAuditItemSchema>;
export type PrivateContentAccessAuditResponse = Static<typeof PrivateContentAccessAuditResponseSchema>;
export const AdminOverviewResponseSchema = Type.Object( export const AdminOverviewResponseSchema = Type.Object(
{ {
generated_at: Type.String({ pattern: isoTimestampPattern }), generated_at: Type.String({ pattern: isoTimestampPattern }),
+102
View File
@@ -0,0 +1,102 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const phaseIndex = process.argv.indexOf("--phase");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (!new Set(["red", "green"]).has(phase)) throw new Error(`Unsupported phase: ${phase}`);
const runId = process.env.DADA_TDD_RUN_ID ?? `wp6-04-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const caseDirectory = resolve(runDirectory, "cases", "TDD-WP6-AUD-001-sensitive-operations");
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(caseDirectory, { recursive: true });
const environment = {
...process.env,
DADA_EVIDENCE_DIR_WP6_AUD: caseDirectory,
DADA_PLAYWRIGHT_OUTPUT_DIR: resolve(runDirectory, "playwright-output"),
};
const commands = phase === "red"
? [
["integration-red", ".\\node_modules\\.bin\\vitest.CMD run tests/integration/wp6-04-sensitive-audit.test.ts tests/integration/wp5-05-sticker-release.test.ts"],
["api-red", ".\\node_modules\\.bin\\vitest.CMD run tests/api/wp6-04-audit.test.ts"],
["e2e-red", ".\\node_modules\\.bin\\playwright.CMD test tests/e2e/wp6-04-audit.spec.ts --config playwright.config.ts"],
]
: [
["integration", "pnpm.cmd test:integration"],
["api", "pnpm.cmd test:api"],
["worker", "pnpm.cmd test:worker"],
["e2e", "pnpm.cmd test:e2e"],
["tdd-trace", "pnpm.cmd validate:tdd-trace"],
];
const commandResults = [];
for (const [name, command] of commands) {
const started_at = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
encoding: "utf8",
env: environment,
maxBuffer: 40 * 1024 * 1024,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
if (phase === "green" && (result.status ?? 1) !== 0) break;
}
function findFiles(directory, name) {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? findFiles(path, name) : entry.name === name ? [path] : [];
});
}
const trace = findFiles(environment.DADA_PLAYWRIGHT_OUTPUT_DIR, "trace.zip")
.find((path) => path.toLowerCase().includes("wp6-04-audit"));
if (trace) copyFileSync(trace, resolve(caseDirectory, "trace.zip"));
const redConfirmed = phase === "red" && commandResults.length === commands.length && commandResults.every((item) => item.exit_code !== 0);
if (phase === "red") {
writeFileSync(resolve(caseDirectory, "red-observation.json"), `${JSON.stringify({
expected_failure: "Audit list APIs and UI are absent, while invite, user-status, and sticker-release mutations are missing same-transaction AdminOperationLog coverage.",
observed_commands: commandResults,
red_reason: "TDD-WP6-AUD-001 first Red: required operations can be missing audit rows, the two log types have no separate admin read contract, and no admin audit page exists.",
status: redConfirmed ? "red_confirmed" : "failed",
}, null, 2)}\n`);
}
const expectedEvidence = phase === "red"
? ["red-observation.json"]
: ["operation-matrix.json", "db-diff.json", "redaction.json", "trace.zip"];
const missingEvidence = expectedEvidence.filter((file) => !existsSync(resolve(caseDirectory, file)));
const greenPassed = phase === "green" && commandResults.length === commands.length && commandResults.every((item) => item.exit_code === 0);
const status = phase === "red"
? redConfirmed && missingEvidence.length === 0 ? "red_confirmed" : "failed"
: greenPassed && missingEvidence.length === 0 ? "green" : "failed";
const result = {
acceptance_criteria: ["AC-50"],
automation: ["automated"],
commit: spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim(),
evidence_refs: expectedEvidence,
layer: ["DB", "API", "WRK", "E2E"],
manifest: {
path: "tasks.manifest.json",
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
},
missing_evidence: missingEvidence,
phase,
requirements: ["ADMIN-09"],
run_id: runId,
status,
task_id: "TASK-WP6-04",
test_id: "TDD-WP6-AUD-001-sensitive-operations",
work_package: "WP-6",
};
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: [{ missing_evidence: missingEvidence, status, test_id: result.test_id }], phase, run_id: runId, status }, null, 2)}\n`);
console.log(JSON.stringify({ phase, run_id: runId, status }, null, 2));
if (status === "failed") process.exit(1);
+123
View File
@@ -0,0 +1,123 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { auditRetentionMilliseconds, serializeAuditSummary } from "../../apps/api/src/audit-policy.js";
import { createApp } from "../../apps/api/src/app.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { wp604OperationMatrix } from "../fixtures/wp6-04-audit.js";
const roots: string[] = [];
const services: RegistrationService[] = [];
const now = Date.parse("2026-08-04T09:30:00.000Z");
const requestHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
function fixture() {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-04-api-"));
roots.push(root);
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0xa1),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0xa2),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0xa3),
});
services.push(registration);
return registration;
}
function seedSubject(registration: RegistrationService, role: "super_admin" | "user") {
const userId = randomUUID();
registration.database.prepare(`
INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit,
registration_id, created_at
) VALUES (?, ?, ?, 'active', ?, ?, ?)
`).run(userId, `${role}-${userId}@example.invalid`, role, role === "user" ? 1 : 0, randomUUID(), now);
if (role === "super_admin") {
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
}
return userId;
}
function seedAuditRows(registration: RegistrationService, adminId: string) {
const operationInsert = registration.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'super_admin', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
wp604OperationMatrix.forEach((entry, index) => {
const occurredAt = now - index * 1_000;
operationInsert.run(
randomUUID(), adminId, entry.operation_type, entry.target_type, randomUUID(),
entry.operation_type === "asset_cleanup_reference_denied" || entry.operation_type === "asset_cleanup_physical_failed" ? "failed" : "succeeded",
serializeAuditSummary({ status: "before" }), serializeAuditSummary({ count: index, status: "after" }),
occurredAt, occurredAt + auditRetentionMilliseconds,
);
});
registration.database.prepare(`
INSERT INTO private_content_access_logs (
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
) VALUES (?, ?, ?, ?, 'prompt', ?, ?)
`).run(randomUUID(), adminId, randomUUID(), randomUUID(), now - 60_000, now - 60_000 + auditRetentionMilliseconds);
}
afterEach(() => {
for (const service of services.splice(0)) service.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP6-AUD-001-sensitive-operations", () => {
it("keeps operation and private-content audit APIs separate, admin-only, redacted, and cursor-paged", async () => {
const registration = fixture();
const adminId = seedSubject(registration, "super_admin");
const userId = seedSubject(registration, "user");
seedAuditRows(registration, adminId);
const admin = registration.issueAuthenticatedSession(adminId, "admin");
const ordinary = registration.issueAuthenticatedSession(userId, "user");
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
for (const path of ["operations", "private-content"]) {
const denied = await app.inject({
headers: { ...requestHeaders, cookie: `dada_admin_session=${ordinary.sessionToken}` },
method: "GET",
url: `/api/v1/admin/audit/${path}?limit=2`,
});
expect(denied.statusCode).toBe(401);
}
const headers = { ...requestHeaders, cookie: `dada_admin_session=${admin.sessionToken}` };
const first = await app.inject({ headers, method: "GET", url: "/api/v1/admin/audit/operations?limit=2" });
expect(first.statusCode).toBe(200);
expect(first.json().items).toHaveLength(2);
expect(first.json().next_cursor).toEqual(expect.any(String));
const second = await app.inject({ headers, method: "GET", url: `/api/v1/admin/audit/operations?limit=2&cursor=${first.json().next_cursor}` });
expect(second.statusCode).toBe(200);
expect(second.json().items[0].log_id).not.toBe(first.json().items[0].log_id);
const privateAccess = await app.inject({ headers, method: "GET", url: "/api/v1/admin/audit/private-content?limit=20" });
expect(privateAccess.statusCode).toBe(200);
expect(privateAccess.json().items).toHaveLength(1);
expect(privateAccess.json().items[0]).toMatchObject({ content_type: "prompt" });
expect(JSON.stringify(first.json())).not.toMatch(/content_type|subject_ref|prompt|image|email|secret|path/i);
expect(JSON.stringify(privateAccess.json())).not.toMatch(/operation_type|before_summary|after_summary|email|secret|path/i);
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD;
if (evidenceRoot) {
mkdirSync(evidenceRoot, { recursive: true });
writeFileSync(resolve(evidenceRoot, "redaction.json"), `${JSON.stringify({
operation_fields: Object.keys(first.json().items[0]).sort(),
private_access_fields: Object.keys(privateAccess.json().items[0]).sort(),
sensitive_fields_present: false,
}, null, 2)}\n`);
}
await app.close();
});
});
+10 -1
View File
@@ -7,7 +7,16 @@ import { createServer, type ViteDevServer } from "vite";
let vite: ViteDevServer; let vite: ViteDevServer;
let webUrl: string; let webUrl: string;
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64"); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64");
const adminSession = { csrf_token: "csrf-wp5-05-admin-000000000000000000000000000000000" }; const adminSession = {
acknowledged_private_content_notice_version: null,
admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000001405" },
audience: "admin",
authenticated: true,
csrf_token: "csrf-wp5-05-admin-000000000000000000000000000000000",
current_private_content_notice_version: null,
expires_at: "2026-09-03T12:00:00.000Z",
notice_acknowledged: false,
};
const projectId = "00000000-0000-4000-8000-000000001405"; const projectId = "00000000-0000-4000-8000-000000001405";
const userSession = { const userSession = {
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
+51
View File
@@ -0,0 +1,51 @@
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
import { wp604AuditApiFixture, wp604PrivateAuditApiFixture } from "../fixtures/wp6-04-audit.js";
let vite: ViteDevServer;
let webUrl: string;
const adminSession = {
acknowledged_private_content_notice_version: null,
admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000000604" },
audience: "admin",
authenticated: true,
csrf_token: "csrf-wp6-04-admin-0000000000000000000000000000000000",
current_private_content_notice_version: null,
expires_at: "2026-09-04T09:30:00.000Z",
notice_acknowledged: false,
};
test.beforeAll(async () => {
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
test("TDD-WP6-AUD-001-sensitive-operations renders two immutable, redacted audit lists", async ({ page }) => {
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
await page.route("**/api/v1/admin/audit/operations**", (route) => route.fulfill({ body: JSON.stringify(wp604AuditApiFixture), contentType: "application/json", status: 200 }));
await page.route("**/api/v1/admin/audit/private-content**", (route) => route.fulfill({ body: JSON.stringify(wp604PrivateAuditApiFixture), contentType: "application/json", status: 200 }));
await page.goto(`${webUrl}/admin/audit`);
await expect(page.getByRole("heading", { level: 2, name: "审计" })).toBeVisible();
await expect(page.getByRole("tab", { name: "后台操作审计" })).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("cell", { name: "user_status_change" })).toBeVisible();
await expect(page.getByRole("button", { name: "下一页" })).toBeVisible();
await page.getByRole("tab", { name: "私有内容访问审计" }).click();
await expect(page.getByRole("cell", { name: "prompt" })).toBeVisible();
await expect(page.getByRole("cell", { name: "00000000-0000-4000-8000-000000000644" })).toBeVisible();
await expect(page.getByText(/private prompt body|api key|absolute path/i)).toHaveCount(0);
await expect(page.getByRole("button", { name: /删除|编辑|清空/ })).toHaveCount(0);
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD;
if (evidenceRoot) await page.screenshot({ fullPage: true, path: resolve(evidenceRoot, "admin-audit.png") });
});
+52
View File
@@ -0,0 +1,52 @@
export const wp604OperationMatrix = [
{ operation_type: "user_status_change", target_type: "user_account" },
{ operation_type: "credit_adjustment", target_type: "user_credit_account" },
{ operation_type: "invite_create", target_type: "invite" },
{ operation_type: "model_configuration_replace", target_type: "model_config_set" },
{ operation_type: "sticker_release_publish", target_type: "sticker_release" },
{ operation_type: "preview_grant_create", target_type: "preview_grant" },
{ operation_type: "service_hard_limit_update", target_type: "external_service_limit" },
{ operation_type: "gateway_balance_recovery", target_type: "gateway_balance_state" },
{ operation_type: "asset_cleanup_requested", target_type: "asset_cleanup_request" },
{ operation_type: "asset_cleanup_validated", target_type: "asset_cleanup_request" },
{ operation_type: "asset_cleanup_scheduled", target_type: "asset_cleanup_request" },
{ operation_type: "asset_cleanup_reference_denied", target_type: "asset_cleanup_request" },
{ operation_type: "asset_cleanup_physical_completed", target_type: "asset_cleanup" },
{ operation_type: "asset_cleanup_physical_failed", target_type: "asset_cleanup" },
{ operation_type: "secure_config_apply", target_type: "secure_config_revision" },
] as const;
export const wp604AuditApiFixture = {
generated_at: "2026-08-04T09:30:00.000Z",
items: [
{
actor_ref: "00000000-0000-4000-8000-000000000604",
actor_type: "super_admin",
after_summary: "{\"status\":\"suspended\"}",
before_summary: "{\"status\":\"active\"}",
expires_at: "2027-01-31T09:30:00.000Z",
log_id: "00000000-0000-4000-8000-000000000641",
occurred_at: "2026-08-04T09:30:00.000Z",
operation_type: "user_status_change",
result: "succeeded",
target_ref: "00000000-0000-4000-8000-000000000642",
target_type: "user_account",
},
],
next_cursor: "audit_cursor_page_2",
} as const;
export const wp604PrivateAuditApiFixture = {
generated_at: "2026-08-04T09:30:00.000Z",
items: [
{
actor_ref: "00000000-0000-4000-8000-000000000604",
content_type: "prompt",
expires_at: "2027-01-31T09:29:00.000Z",
log_id: "00000000-0000-4000-8000-000000000643",
occurred_at: "2026-08-04T09:29:00.000Z",
target_ref: "00000000-0000-4000-8000-000000000644",
},
],
next_cursor: null,
} as const;
@@ -1,15 +1,19 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import type BetterSqlite3 from "better-sqlite3";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { HARD_LIMIT_BYTES, ManagedStorage, StorageCapacityError, StorageUnavailableError } from "../../apps/api/src/managed-storage.js"; import { HARD_LIMIT_BYTES, ManagedStorage, StorageCapacityError, StorageUnavailableError } from "../../apps/api/src/managed-storage.js";
import { StickerReleaseService } from "../../apps/api/src/sticker-releases.js"; import { StickerReleaseService } from "../../apps/api/src/sticker-releases.js";
const now = Date.parse("2026-08-03T12:00:00.000Z"); const now = Date.parse("2026-08-03T12:00:00.000Z");
const require = createRequire(resolve("apps/api/package.json"));
const Database = require("better-sqlite3") as typeof BetterSqlite3;
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64"); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64");
const webp = Buffer.from("UklGRjoAAABXRUJQVlA4IC4AAADQAQCdASoGAAUAAUAmJaACdLoB+AADsAD+9IiH/pNnibPE2fJI/+Uq8Fjc3wAA", "base64"); const webp = Buffer.from("UklGRjoAAABXRUJQVlA4IC4AAADQAQCdASoGAAUAAUAmJaACdLoB+AADsAD+9IiH/pNnibPE2fJI/+Uq8Fjc3wAA", "base64");
const roots: string[] = []; const roots: string[] = [];
@@ -38,7 +42,7 @@ function fixture() {
const storage = new ManagedStorage({ dataRoot, databasePath }); const storage = new ManagedStorage({ dataRoot, databasePath });
const stickers = new StickerReleaseService({ clock: () => now, databasePath, storage }); const stickers = new StickerReleaseService({ clock: () => now, databasePath, storage });
closeables.push(stickers, storage); closeables.push(stickers, storage);
return { dataRoot, stickers, storage }; return { dataRoot, databasePath, stickers, storage };
} }
async function upload(stickers: StickerReleaseService, stableId: string, order: number, bytes = png, mimeType: "image/png" | "image/webp" = "image/png") { async function upload(stickers: StickerReleaseService, stableId: string, order: number, bytes = png, mimeType: "image/png" | "image/webp" = "image/png") {
@@ -82,6 +86,26 @@ describe("TDD-WP5-UPL-001 upload metering", () => {
expect(test.stickers.listPublic(published.release_version).items).toHaveLength(1); expect(test.stickers.listPublic(published.release_version).items).toHaveLength(1);
expect(test.stickers.readPublicAsset(published.release_version, "STK1408", "original")?.bytes).toEqual(png); expect(test.stickers.readPublicAsset(published.release_version, "STK1408", "original")?.bytes).toEqual(png);
expect(test.stickers.readPublicAsset(disabled.release_version, "STK1408", "original")).toBeUndefined(); expect(test.stickers.readPublicAsset(disabled.release_version, "STK1408", "original")).toBeUndefined();
const audit = new Database(test.databasePath, { readonly: true });
expect(audit.prepare(`
SELECT operation_type, target_ref, result FROM admin_operation_logs
WHERE operation_type IN ('sticker_release_publish', 'sticker_release_update')
ORDER BY occurred_at
`).all()).toEqual([
{ operation_type: "sticker_release_publish", result: "succeeded", target_ref: published.release_version },
{ operation_type: "sticker_release_update", result: "succeeded", target_ref: disabled.release_version },
]);
audit.close();
const auditFailure = new Database(test.databasePath);
auditFailure.exec(`
CREATE TRIGGER force_sticker_release_audit_failure BEFORE INSERT ON admin_operation_logs
WHEN NEW.operation_type = 'sticker_release_update'
BEGIN SELECT RAISE(ABORT, 'forced_audit_failure'); END;
`);
expect(() => test.stickers.update({ actorId: randomUUID(), enabled: true, stableId: "STK1408" }))
.toThrow("forced_audit_failure");
expect(test.stickers.adminView()).toMatchObject({ release_version: disabled.release_version, items: [{ enabled: false }] });
auditFailure.close();
evidence("fs-before.json", before); evidence("fs-before.json", before);
evidence("fs-after.json", { files: filesBelow(join(test.dataRoot, "managed-assets")), state: test.storage.getState() }); evidence("fs-after.json", { files: filesBelow(join(test.dataRoot, "managed-assets")), state: test.storage.getState() });
@@ -136,4 +136,45 @@ describe("TDD-WP5-CLN-001 sticker history cleanup", () => {
{ operation_type: "asset_cleanup_physical_completed", result: "succeeded" }, { operation_type: "asset_cleanup_physical_completed", result: "succeeded" },
]); ]);
}); });
it("records a redacted physical failure in the same transaction and leaves the request retryable", async () => {
const test = fixture();
const adminId = seedAdmin(test.database);
const files = await seedHistoricalPair(test);
const candidates = test.storage.listAssetCleanupCandidates();
const intent = test.storage.createAssetCleanupIntent({
actorId: adminId,
fileIds: [files.original.file_id, files.thumbnail.file_id],
idempotencyKey: `cleanup-${randomUUID()}-${randomUUID()}`,
snapshotVersion: candidates.candidate_snapshot_version,
});
test.storage.confirmAssetCleanupIntent({
actorId: adminId,
confirmationToken: intent.confirmation_token,
requestId: intent.request_id,
});
test.database.prepare(`
UPDATE file_cleanup_queue SET relative_path = '../outside-fixture'
WHERE managed_file_id = ?
`).run(files.original.file_id);
const worker = new ProjectPurgeCleanup({ dataRoot: test.dataRoot, databasePath: test.databasePath });
const result = worker.processFileCleanup();
worker.close();
expect(result).toEqual({ completed: 1, failed: 1 });
expect(test.database.prepare("SELECT status FROM asset_cleanup_requests WHERE request_id = ?").get(intent.request_id)).toEqual({ status: "queued" });
expect(test.database.prepare("SELECT status, last_error FROM file_cleanup_queue WHERE managed_file_id = ?").get(files.original.file_id))
.toEqual({ last_error: "physical_file_cleanup_failed", status: "failed" });
const failure = test.database.prepare(`
SELECT operation_type, result, before_summary, after_summary
FROM admin_operation_logs WHERE target_ref = ? AND operation_type = 'asset_cleanup_physical_failed'
`).get(intent.request_id) as { after_summary: string; before_summary: null; operation_type: string; result: string };
expect(failure).toEqual({
after_summary: JSON.stringify({ failed_count: 1, status: "retry_pending" }),
before_summary: null,
operation_type: "asset_cleanup_physical_failed",
result: "failed",
});
expect(JSON.stringify(failure)).not.toMatch(/outside-fixture|relative_path|absolute_path|image|prompt|secret/i);
});
}); });
@@ -0,0 +1,90 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { wp604OperationMatrix } from "../fixtures/wp6-04-audit.js";
const roots: string[] = [];
const services: RegistrationService[] = [];
const now = Date.parse("2026-08-04T09:30:00.000Z");
function fixture() {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-04-integration-"));
roots.push(root);
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0xb1),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0xb2),
inviteCodeGenerator: () => "fixture-invite-code",
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0xb3),
});
services.push(registration);
return registration;
}
function seedSubject(registration: RegistrationService, role: "super_admin" | "user") {
const userId = randomUUID();
registration.database.prepare(`
INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit,
registration_id, created_at
) VALUES (?, ?, ?, 'active', ?, ?, ?)
`).run(userId, `${role}-${userId}@example.invalid`, role, role === "user" ? 1 : 0, randomUUID(), now);
if (role === "super_admin") {
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
}
return userId;
}
afterEach(() => {
for (const service of services.splice(0)) service.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP6-AUD-001-sensitive-operations", () => {
it("writes invite and user status audits in the same transaction as the mutation", () => {
const registration = fixture();
const adminId = seedSubject(registration, "super_admin");
const userId = seedSubject(registration, "user");
const adminOperations = registration as RegistrationService & {
createAdminInvite(input: { actorId: string; expiresAt: number; maxUses: number }): { code: string; inviteId: string };
changeUserStatus(userId: string, status: "suspended" | "deleted", actorId: string): void;
};
const invite = adminOperations.createAdminInvite({ actorId: adminId, expiresAt: now + 86_400_000, maxUses: 1 });
adminOperations.changeUserStatus(userId, "suspended", adminId);
expect(registration.database.prepare(`
SELECT operation_type, target_ref FROM admin_operation_logs
WHERE operation_type IN ('invite_create', 'user_status_change') ORDER BY occurred_at
`).all()).toEqual([
{ operation_type: "invite_create", target_ref: invite.inviteId },
{ operation_type: "user_status_change", target_ref: userId },
]);
registration.database.exec(`
CREATE TRIGGER force_user_status_audit_failure BEFORE INSERT ON admin_operation_logs
WHEN NEW.operation_type = 'user_status_change'
BEGIN SELECT RAISE(ABORT, 'forced_audit_failure'); END;
`);
expect(() => adminOperations.changeUserStatus(userId, "deleted", adminId)).toThrow("forced_audit_failure");
expect(registration.database.prepare("SELECT status FROM users WHERE user_id = ?").get(userId)).toEqual({ status: "suspended" });
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD;
if (evidenceRoot) {
mkdirSync(evidenceRoot, { recursive: true });
writeFileSync(resolve(evidenceRoot, "operation-matrix.json"), `${JSON.stringify({ operations: wp604OperationMatrix }, null, 2)}\n`);
writeFileSync(resolve(evidenceRoot, "db-diff.json"), `${JSON.stringify({
audit_failure_rollback: { user_status: "suspended" },
tables: { admin_operation_logs: "append_only", private_content_access_logs: "append_only_separate" },
}, null, 2)}\n`);
}
});
});