Files
civitai__civitai/apps/moderator/abuse-detection/schema.sql
T
Zachary Lowden ed0b0b04e3 docs(retool): hold the moderator id table privately, genericise internal refs (#4476)
This repository is public. raw/README.md already documented a sanitisation rule for
the Retool exports, but that pass matched on the SHAPE of a value, so content that
looks like ordinary prose went straight through.

Staff identity. moderator-id-mapping.md paired moderation team members' real names
with their Civitai user ids and usernames, and user-lookup-v2.json gated features on
current_user.fullName === a real name, so the authorization model itself was written
in names. The table now lives in the private infra repo; the public doc keeps the
coverage figures and the backfill method and points there. Export literals became
__MODERATOR_A__..__MODERATOR_C__, underscore-delimited because bare MODERATOR_A is a
strict prefix of the live MODERATOR_APP_URL env var.

One staff member had all three legs of a pseudonym-to-identity linkage present in
this repo -- pseudonym, userId in the retained moderator id set, and real name in a
planning doc. That one was reconstructible from repo content alone; the display name
is dropped. Other staff mentions complete no linkage and stand.

End-user identifiers. bulk-ban carried four real banned users' IP addresses and five
real account ids. Now RFC5737 addresses and <accountId>.

Internal service names. Comments, .env.examples and test fixtures named real
in-cluster services and two node hostnames. Every namespace token is now clear.
Deliberately unchanged: five executed fallbacks, verify-runner as an application
identifier, stub-oidc as a local e2e test double, and cnpg-database in immutable
Prisma migrations.

Four adversarial audit rounds. The redactions verified clean every round; the prose
describing them needed four public corrections, including one that misattributed
which moderator was exposed and one that misdiagnosed why a survivor survived -- it
was a term-list defect, not the line wrap I claimed, so the guard I added protected
against the wrong mechanism. Corrections are on the PR.

These files were already committed and pushed, so this narrows further exposure
rather than undoing it. Nothing here is a credential. The history decision -- leave
it, treat the content as disclosed -- is recorded on the tracking ticket.

Verified: 168 tests across four suites (run with --project; without it vitest
silently runs three files and still reports success), 11/11 raw exports parse, byte
deltas match the replacements arithmetically.
2026-08-28 19:06:11 -05:00

102 lines
6.2 KiB
SQL

-- Abuse-detection reports from automated detectors.
--
-- Lives in the `internal_tools` instance (MODERATOR_DATABASE_URL), NOT the legacy Retool database
-- (RETOOL_DATABASE_URL) that `getModeratorDb()` reads. Those are different instances; the Retool one
-- is what the migration is moving away from, so nothing new should land there.
--
-- 🔴 APPLIED BY HAND, in both environments. Repo convention: no `prisma migrate deploy`, no auto-run
-- on deploy, and this file is not wired to any runner (same as apps/moderator/xguard-lab/schema*.sql).
-- Applying it is a deliberate act by a human, and the app degrades rather than crashes when the
-- tables are absent — see `abuse-detection.service.ts`.
--
-- psql "$MODERATOR_DATABASE_URL" -f apps/moderator/abuse-detection/schema.sql
--
-- 🔴 AS THE APPLICATION ROLE (`internal_tools`), which is what that URL connects as. Running this
-- as `postgres` — the natural `kubectl exec … psql -U postgres` shortcut — creates postgres-owned
-- tables the app cannot read, and the page then reports a permission error it cannot distinguish
-- from an outage without the 42501 branch it now carries. If you already did that, either re-run as
-- the app role or:
-- GRANT SELECT, INSERT, UPDATE, DELETE ON abuse_detection_run, abuse_detection_finding TO internal_tools;
-- GRANT USAGE, SELECT ON SEQUENCE abuse_detection_run_id_seq, abuse_detection_finding_id_seq TO internal_tools;
--
-- 🔴 `MODERATOR_DATABASE_URL` and `RETOOL_DATABASE_URL` currently resolve to the SAME instance
-- (the same `internal_tools` database, measured 2026-08-21, post-Retool-cutover). Either works
-- today; this names the one whose purpose is new moderator data.
--
-- Idempotent: safe to re-run.
-- 🔴 REQUIRED. Without it psql continues past a failed statement, and the one environment the DROP
-- below targets — one that ran an earlier version and may hold duplicate (detector, started_at)
-- rows — is exactly where the CREATE UNIQUE INDEX fails. It would then drop the old index anyway,
-- leaving that deployment with NO unique index (every write 42P10s) and no per-detector index either.
\set ON_ERROR_STOP on
CREATE TABLE IF NOT EXISTS abuse_detection_run (
id bigserial PRIMARY KEY,
-- Opaque producer key (`reaction-abuse`, `review-bomb`, …). The UI supplies the display name.
detector text NOT NULL,
-- The PRODUCER's clock, not receipt time. A run that finishes at 11:20 and reports at 11:47 after
-- a retry must not read as an 11:47 run — the whole point of the board is "how current is this".
started_at timestamptz NOT NULL,
finished_at timestamptz NOT NULL,
summary text,
-- Per-detector counters. jsonb rather than columns because each detector counts different things,
-- and a fixed column set would make adding a counter a schema migration in two repos.
counters jsonb NOT NULL DEFAULT '{}'::jsonb,
received_at timestamptz NOT NULL DEFAULT now()
);
-- 🔴 IDEMPOTENCY KEY, not just an index. The producers retry: a POST that commits but whose response
-- is lost to a timeout is sent again, and without this the board grows a duplicate run each time —
-- two rows claiming to be the same run, which is worse than none because a reader cannot tell which
-- is current. The service upserts on this pair. It doubles as the (detector, started_at) index the
-- board's per-detector listing needs, so there is no separate one.
--
-- ⚠️ On an existing deployment, de-duplicate before adding it:
-- DELETE FROM abuse_detection_run a USING abuse_detection_run b
-- WHERE a.detector = b.detector AND a.started_at = b.started_at AND a.id < b.id;
-- Keeps the HIGHEST id, i.e. the most recently inserted duplicate. The runtime upsert reaches the
-- same CONTENT a different way — it keeps the original row's id and overwrites its columns — so the
-- two agree on which report survives, not on which row identity does.
CREATE UNIQUE INDEX IF NOT EXISTS abuse_detection_run_detector_started_key
ON abuse_detection_run (detector, started_at);
-- Superseded by the unique index above: same columns in the same order, and btree scans backward,
-- so it serves `WHERE detector = $1 ORDER BY started_at DESC` identically. (A shared LEADING column
-- would NOT be sufficient grounds — the full column list matching is.) Dropped rather than left
-- behind: an environment that ran an earlier copy of this file still carries it, and a redundant
-- index is pure write cost on every insert.
DROP INDEX IF EXISTS abuse_detection_run_detector_started_idx;
CREATE INDEX IF NOT EXISTS abuse_detection_run_started_idx
ON abuse_detection_run (started_at DESC);
CREATE TABLE IF NOT EXISTS abuse_detection_finding (
id bigserial PRIMARY KEY,
run_id bigint NOT NULL REFERENCES abuse_detection_run (id) ON DELETE CASCADE,
-- The account the finding is ABOUT. Not an actor, and deliberately not FK'd — this database does
-- not hold the main app's User table, and a dangling id is a real state (deleted account) rather
-- than corruption.
user_id integer NOT NULL,
-- The producer's own 0..1 confidence. NOT comparable across detectors; the UI must not rank on it
-- across rows from different producers.
confidence real NOT NULL,
reason text NOT NULL,
-- 🔴 The column this table exists for. `false` is the common case: detected, scored, and
-- deliberately NOT acted on. No pre-existing surface can represent that, and it is precisely what
-- a human review queue needs to see.
actioned boolean NOT NULL,
-- What was done, when something was (`exclude`, `unexclude`, …). NULL when `actioned` is false.
action text,
created_at timestamptz NOT NULL DEFAULT now(),
-- An action name without an action, or an action that names nothing, is incoherent either way.
CONSTRAINT abuse_detection_finding_action_matches_actioned
CHECK ((actioned AND action IS NOT NULL) OR (NOT actioned AND action IS NULL))
);
CREATE INDEX IF NOT EXISTS abuse_detection_finding_run_idx
ON abuse_detection_finding (run_id);
-- "What has any detector said about this account?" — the per-user lookup the moderator app joins on.
CREATE INDEX IF NOT EXISTS abuse_detection_finding_user_idx
ON abuse_detection_finding (user_id, created_at DESC);