nxtgauge-backend-rust/crates/db/migrations/20260627000000_tracecoin_security_hardening.up.sql.skip

145 lines
6 KiB
Text
Raw Normal View History

-- ──────────────────────────────────────────────────────────────────────────
-- Tracecoin security hardening
-- ──────────────────────────────────────────────────────────────────────────
-- This migration tightens the tracecoin_wallets + tracecoin_ledger schema
-- so that the wallet/ledger can be operated on safely under concurrent
-- load. Specifically:
--
-- 1. Prevent negative balances at the database layer (CHECK constraints).
-- 2. Prevent double-credits on payment re-verification (unique idempotency
-- key on (wallet_id, reference_id) when reference_id IS NOT NULL).
-- 3. Add a balance_after column so the ledger is a self-contained
-- append-only history with running totals (no need to SUM at read time).
-- 4. Add an actor_user_id column so we know who triggered each entry
-- (admin adjustments become auditable; system entries are NULL).
-- 5. Add metadata JSONB so callers can attach context without growing
-- the schema.
-- 6. Backfill balance_after for existing rows.
-- 7. Add a CHECK that amount != 0.
-- 8. Add a CHECK that type is one of the allowed values.
-- 9. Add an index on (user_id, created_at DESC) for fast history reads.
-- ──────────────────────────────────────────────────────────────────────────
-- 1. CHECK constraints
ALTER TABLE tracecoin_wallets
DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_nonneg;
ALTER TABLE tracecoin_wallets
ADD CONSTRAINT tracecoin_wallets_balance_nonneg
CHECK (balance >= 0);
ALTER TABLE tracecoin_wallets
DROP CONSTRAINT IF EXISTS tracecoin_wallets_reserved_nonneg;
ALTER TABLE tracecoin_wallets
ADD CONSTRAINT tracecoin_wallets_reserved_nonneg
CHECK (reserved >= 0);
ALTER TABLE tracecoin_wallets
DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_consistent;
ALTER TABLE tracecoin_wallets
ADD CONSTRAINT tracecoin_wallets_balance_consistent
CHECK (balance + reserved <= 2147483647);
-- 2. Idempotency: at most one ledger entry per (wallet, reference, type).
-- This lets a single payment credit exactly once, while still allowing
-- separate RESERVE / RELEASE / CONFIRM rows for a single lead_request.
CREATE UNIQUE INDEX IF NOT EXISTS uq_tracecoin_ledger_wallet_reference_type
ON tracecoin_ledger (wallet_id, reference_id, type)
WHERE reference_id IS NOT NULL;
-- 3. balance_after column
ALTER TABLE tracecoin_ledger
ADD COLUMN IF NOT EXISTS balance_after INTEGER;
-- 4. actor_user_id column
ALTER TABLE tracecoin_ledger
ADD COLUMN IF NOT EXISTS actor_user_id UUID REFERENCES users(id);
-- 5. metadata JSONB
ALTER TABLE tracecoin_ledger
ADD COLUMN IF NOT EXISTS metadata JSONB;
-- 7. amount != 0
ALTER TABLE tracecoin_ledger
DROP CONSTRAINT IF EXISTS tracecoin_ledger_amount_nonzero;
ALTER TABLE tracecoin_ledger
ADD CONSTRAINT tracecoin_ledger_amount_nonzero
CHECK (amount <> 0);
-- 8. type must be one of the allowed values
ALTER TABLE tracecoin_ledger
DROP CONSTRAINT IF EXISTS tracecoin_ledger_type_valid;
ALTER TABLE tracecoin_ledger
ADD CONSTRAINT tracecoin_ledger_type_valid
CHECK (type IN ('CREDIT', 'DEBIT', 'RESERVE', 'RELEASE', 'ADJUSTMENT'));
-- 9. (user_id, created_at) covering index for history reads.
-- We don't have user_id on the ledger; we have wallet_id, so we use that.
CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_created
ON tracecoin_ledger (wallet_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_type
ON tracecoin_ledger (type);
-- 6. Backfill balance_after for existing rows using a window function.
-- This is best-effort: if the data has gaps, balance_after is set to the
-- row's amount as a relative number; a re-compute can rebuild it later.
WITH ordered AS (
SELECT
id,
SUM(amount) OVER (
PARTITION BY wallet_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running
FROM tracecoin_ledger
)
UPDATE tracecoin_ledger tl
SET balance_after = ordered.running
FROM ordered
WHERE tl.id = ordered.id
AND tl.balance_after IS NULL;
-- Prevent further updates / deletes to ledger rows. The application code
-- must treat the ledger as append-only.
CREATE OR REPLACE FUNCTION tracecoin_ledger_immutable()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'tracecoin_ledger is append-only; updates and deletes are not allowed';
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_update ON tracecoin_ledger;
CREATE TRIGGER trg_tracecoin_ledger_no_update
BEFORE UPDATE ON tracecoin_ledger
FOR EACH ROW EXECUTE FUNCTION tracecoin_ledger_immutable();
DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_delete ON tracecoin_ledger;
CREATE TRIGGER trg_tracecoin_ledger_no_delete
BEFORE DELETE ON tracecoin_ledger
FOR EACH ROW EXECUTE FUNCTION tracecoin_ledger_immutable();
-- Helper function: get-or-create wallet, lock the row, return id and current
-- balance. This is the single point where concurrent access is serialized.
CREATE OR REPLACE FUNCTION lock_tracecoin_wallet(p_user_id UUID)
RETURNS TABLE(wallet_id UUID, balance INTEGER, reserved INTEGER)
LANGUAGE plpgsql
AS $$
DECLARE
v_id UUID;
BEGIN
-- Insert if missing. ON CONFLICT DO NOTHING preserves an existing row.
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
VALUES (p_user_id, 0, 0)
ON CONFLICT (user_id) DO NOTHING;
SELECT id INTO v_id FROM tracecoin_wallets WHERE user_id = p_user_id;
-- Lock the row for the duration of the transaction.
RETURN QUERY
SELECT tw.id, tw.balance, tw.reserved
FROM tracecoin_wallets tw
WHERE tw.id = v_id
FOR UPDATE;
END;
$$;