Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
-- =====================================================================
|
||||
-- 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 ----------------------------------------------------
|
||||
Reference in New Issue
Block a user