Files
OMT-SM/supabase/migrations/0011_hardening.sql
T

223 lines
9.0 KiB
PL/PgSQL

-- =====================================================================
-- Migration 0011 — Hardening & ops (roadmap Step 12).
--
-- 1. pg_cron schedules: alert detector + chain verifier.
-- 2. Daily off-site hash anchor (writes the day's last row_hash per shop
-- to app.daily_anchors; an external job copies these to S3/Glacier).
-- 3. HMAC + PIN secret rotation procedures with audit.
-- 4. DDL lockdown advisory (event trigger blocking DDL by anyone other
-- than the migration role).
-- 5. NTP / clock-skew guard at INSERT time on app.transactions.
--
-- Threat-model rows: 4, 7, 17, 22, 23, 25.
-- =====================================================================
-- ---------------------------------------------------------------------
-- 1. pg_cron schedules. Supabase ships pg_cron in the `extensions`
-- schema. Each task runs as the table owner thanks to SECURITY DEFINER.
-- ---------------------------------------------------------------------
create extension if not exists pg_cron;
-- Run alert detectors every 5 minutes.
do $$ begin
perform cron.schedule('app_alert_detectors_5m',
'*/5 * * * *',
$cmd$ select app.run_alert_detectors(); $cmd$);
exception when others then null; -- already scheduled
end $$;
-- Verify the hash chain hourly per shop. We don't bail loudly here;
-- run_alert_detectors() raises a chain_break alert if verify_chain fails.
do $$ begin
perform cron.schedule('app_chain_verify_hourly',
'7 * * * *',
$cmd$ select 1 from (
select s.id, (select bool_and(ok) from app.verify_chain(s.id))
from app.shops s
) v; $cmd$);
exception when others then null;
end $$;
-- ---------------------------------------------------------------------
-- 2. Daily off-site anchor.
-- The most important fraud control after recon: every night, copy
-- the last row_hash per shop into a row that is written ONCE,
-- timestamped, and exported to immutable storage. If anyone tampers
-- with history, today's anchor will not chain back to yesterday's.
-- ---------------------------------------------------------------------
create table if not exists app.daily_anchors (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
anchor_date date not null,
last_txn_id uuid,
last_ref_no bigint,
last_row_hash bytea,
txn_count_to_date bigint not null,
computed_at timestamptz not null default now(),
unique (shop_id, anchor_date)
);
alter table app.daily_anchors enable row level security;
alter table app.daily_anchors force row level security;
revoke insert, update, delete on app.daily_anchors from authenticated;
create or replace function app._daily_anchors_guard()
returns trigger language plpgsql as $$
begin
raise exception 'daily_anchors are append-only';
end;
$$;
drop trigger if exists trg_daily_anchors_guard on app.daily_anchors;
create trigger trg_daily_anchors_guard before update or delete on app.daily_anchors
for each row execute function app._daily_anchors_guard();
drop policy if exists daily_anchors_select on app.daily_anchors;
create policy daily_anchors_select on app.daily_anchors
for select to authenticated
using (app.has_any_role_in_shop(shop_id,
array['owner','auditor']::app.business_role[]));
grant select on app.daily_anchors to authenticated;
create or replace function app.write_daily_anchors()
returns int
language plpgsql
security definer
set search_path = app, public
as $$
declare n int := 0; r record;
begin
for r in
with last_row as (
select distinct on (shop_id)
shop_id, id, reference_no, row_hash, occurred_at
from app.transactions
where (occurred_at at time zone 'UTC')::date
= (now() at time zone 'UTC')::date - 1
order by shop_id, reference_no desc
)
select lr.shop_id, lr.id, lr.reference_no, lr.row_hash,
(now() at time zone 'UTC')::date - 1 as anchor_date,
(select count(*) from app.transactions t
where t.shop_id = lr.shop_id
and t.reference_no <= lr.reference_no) as cnt
from last_row lr
loop
insert into app.daily_anchors(shop_id, anchor_date, last_txn_id,
last_ref_no, last_row_hash, txn_count_to_date)
values (r.shop_id, r.anchor_date, r.id,
r.reference_no, r.row_hash, r.cnt)
on conflict (shop_id, anchor_date) do nothing;
n := n + 1;
end loop;
perform app.log_auth_event('daily_anchor_written', null, null,
jsonb_build_object('rows', n));
return n;
end;
$$;
revoke all on function app.write_daily_anchors() from public;
grant execute on function app.write_daily_anchors() to authenticated;
-- 02:15 Beirut time = 23:15 UTC the previous day; at that hour the till
-- is closed and the day's last txn already exists.
do $$ begin
perform cron.schedule('app_daily_anchor',
'15 23 * * *',
$cmd$ select app.write_daily_anchors(); $cmd$);
exception when others then null;
end $$;
-- ---------------------------------------------------------------------
-- 3. Secret rotation with audit. The HMAC key was created in 0007;
-- rotating it invalidates all printed receipts but new ones become
-- forgery-resistant. PIN rotation is per-user.
-- ---------------------------------------------------------------------
create table if not exists app.secret_rotations (
id uuid primary key default gen_random_uuid(),
secret_name text not null,
rotated_by uuid not null references auth.users(id) default auth.uid(),
rotated_at timestamptz not null default now(),
reason text not null check (length(btrim(reason)) >= 5)
);
alter table app.secret_rotations enable row level security;
alter table app.secret_rotations force row level security;
revoke insert, update, delete on app.secret_rotations from authenticated;
drop policy if exists secret_rotations_select on app.secret_rotations;
create policy secret_rotations_select on app.secret_rotations
for select to authenticated
using (app.is_owner_anywhere());
grant select on app.secret_rotations to authenticated;
create or replace function app._secret_rotations_guard()
returns trigger language plpgsql as $$
begin
raise exception 'secret_rotations are append-only';
end;
$$;
drop trigger if exists trg_secret_rotations_guard on app.secret_rotations;
create trigger trg_secret_rotations_guard before update or delete on app.secret_rotations
for each row execute function app._secret_rotations_guard();
create or replace function app.log_secret_rotation(p_name text, p_reason text)
returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare rid uuid;
begin
if not app.is_owner_anywhere() then
raise exception 'owner role required';
end if;
insert into app.secret_rotations(secret_name, reason)
values (p_name, p_reason) returning id into rid;
return rid;
end;
$$;
revoke all on function app.log_secret_rotation(text, text) from public;
grant execute on function app.log_secret_rotation(text, text) to authenticated;
-- ---------------------------------------------------------------------
-- 4. DDL lockdown advisory.
-- Anyone with `authenticated` should not be able to issue DDL anyway,
-- but Supabase ships a `service_role` key. This event trigger raises
-- if DDL is attempted from anything other than the migration owner.
-- ---------------------------------------------------------------------
create or replace function app._ddl_lock()
returns event_trigger
language plpgsql
as $$
begin
-- Allow the role that owns the schema (typically `postgres` running
-- supabase migrations) and the cron worker. Block everyone else.
if current_user not in ('postgres', 'supabase_admin') then
raise exception 'DDL is locked: caller % may not modify schema', current_user;
end if;
end;
$$;
drop event trigger if exists app_ddl_lock;
create event trigger app_ddl_lock
on ddl_command_start
execute function app._ddl_lock();
-- ---------------------------------------------------------------------
-- 5. Clock-skew guard. A till with a manipulated clock can backdate or
-- pre-date transactions to hide them from a shift. Reject inserts
-- whose `occurred_at` is more than 5 minutes off server `now()`.
-- ---------------------------------------------------------------------
create or replace function app._txn_clock_guard()
returns trigger language plpgsql as $$
begin
if abs(extract(epoch from (new.occurred_at - now()))) > 300 then
raise exception
'clock skew rejected: occurred_at=% server now()=%', new.occurred_at, now();
end if;
return new;
end;
$$;
drop trigger if exists trg_txn_clock_guard on app.transactions;
-- Fires before the existing txn_before_insert (alphabetical 'a' < 't').
create trigger trg_a_txn_clock_guard before insert on app.transactions
for each row execute function app._txn_clock_guard();
-- End migration 0011 ----------------------------------------------------