Without an audit log of moderation actions, you cannot answer basic questions: Who approved the listing that turned out to be a scam? Was a rejection justified? When did a moderator act? This absence blocks compliance with ISO 27001:2022 A.8.15 (Logging) and makes CWE-778 (Insufficient Logging) applicable. Beyond compliance, an unlogged moderation system offers zero accountability — moderators can approve or suppress listings without any record.
High because missing moderation logs eliminate accountability, prevent abuse investigations, and fail ISO 27001:2022 A.8.15 logging requirements.
Create an append-only moderation_logs table and write a row on every approve, reject, or request-changes action. Never update or delete log rows:
CREATE TABLE moderation_logs (
id SERIAL PRIMARY KEY,
listing_id INT NOT NULL REFERENCES listings(id),
moderator_id VARCHAR NOT NULL,
action VARCHAR(50) NOT NULL CHECK (action IN ('approve', 'reject', 'request-changes')),
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- No UPDATE or DELETE privileges granted on this table
In app/api/admin/listings/[id]/moderate/route.ts, insert the log row in the same transaction as the status update so they stay in sync. Surface the log in the admin UI alongside the listing so moderators see the full history before acting.
ID: directory-submissions-moderation.moderation.moderation-actions-logged
Severity: high
What to look for: Examine the admin moderation interface. Check that moderators can: (1) approve a listing, (2) reject with a reason, (3) request changes. Look for an audit log or activity log that records who took action, when, and what reason was provided. Check the database schema for a moderation log table or audit trail.
Pass criteria: Enumerate all relevant code paths. The moderation dashboard allows approve/reject/request-changes actions. All actions are logged with timestamp, moderator ID, and reason. The logs are immutable (not edited after the fact). with at least 1 verified instance. Quote the actual validation rules or configuration used.
Fail criteria: Moderators can only approve/reject, not request changes, or actions are not logged, or logs can be edited after creation.
Skip (N/A) when: The project has no moderation feature or uses a third-party service.
Detail on fail: "Moderators can only approve or reject. There's no way to request changes (e.g., 'Please add operating hours')." or "No audit log of moderation actions — can't track who approved or rejected listings."
Remediation: Implement moderation logging:
-- Database schema
CREATE TABLE moderation_logs (
id SERIAL PRIMARY KEY,
listing_id INT NOT NULL REFERENCES listings(id),
moderator_id VARCHAR NOT NULL,
action VARCHAR(50) NOT NULL CHECK (action IN ('approve', 'reject', 'request-changes')),
reason TEXT,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(id) -- Prevent tampering
);
CREATE INDEX idx_moderation_logs_listing ON moderation_logs(listing_id);
// app/api/admin/listings/[id]/moderate/route.ts
export async function POST(req: Request) {
const { listingId } = params
const { action, reason } = await req.json()
const moderatorId = req.auth.userId // From session/auth
if (!['approve', 'reject', 'request-changes'].includes(action)) {
return Response.json({ error: 'Invalid action' }, { status: 400 })
}
// Update listing status
const statusMap = {
'approve': 'approved',
'reject': 'rejected',
'request-changes': 'pending-changes'
}
await db.listings.update({
where: { id: listingId },
data: { status: statusMap[action] }
})
// Log the action
await db.moderationLogs.create({
data: {
listing_id: listingId,
moderator_id: moderatorId,
action,
reason
}
})
// Notify submitter
const listing = await db.listings.findUnique({ where: { id: listingId } })
await sendEmail({
to: listing.contact_email,
subject: `Your listing has been ${action === 'request-changes' ? 'flagged for review' : action}`,
html: `<p>Your submission status: ${action}</p><p>${reason}</p>`
})
return Response.json({ success: true })
}