Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
-- =====================================================================
|
||||
-- Migration 0001 — Auth, organizational hierarchy, RLS foundations.
|
||||
--
|
||||
-- Implements roadmap Step 1 (identity & access) and Step 2 (org & master
|
||||
-- data). No money tables yet; those come in 0002+. Every table is created
|
||||
-- with RLS enabled and a deny-by-default posture; specific policies are
|
||||
-- added inline.
|
||||
--
|
||||
-- Threat-model rows addressed: 9, 18, 20, 24, 25.
|
||||
-- =====================================================================
|
||||
|
||||
-- Required extensions ---------------------------------------------------
|
||||
create extension if not exists "pgcrypto"; -- gen_random_uuid, digest
|
||||
create extension if not exists "citext"; -- case-insensitive text
|
||||
|
||||
-- Dedicated schema for app data (keeps `public` clean) ------------------
|
||||
create schema if not exists app;
|
||||
|
||||
-- Revoke broad defaults; we will grant explicitly per role.
|
||||
revoke all on schema app from public;
|
||||
grant usage on schema app to authenticated;
|
||||
|
||||
-- =====================================================================
|
||||
-- Roles
|
||||
-- =====================================================================
|
||||
-- We model business roles as an enum, separate from Postgres/Supabase
|
||||
-- roles. Supabase still uses `authenticated`/`anon`; the business role is
|
||||
-- read from `app.user_shop_assignments` per shop.
|
||||
do $$ begin
|
||||
create type app.business_role as enum ('owner', 'manager', 'cashier', 'auditor');
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- =====================================================================
|
||||
-- Shops, tills, users
|
||||
-- =====================================================================
|
||||
create table if not exists app.shops (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
address text,
|
||||
omt_agent_code text unique,
|
||||
alfa_dealer_code text unique,
|
||||
touch_dealer_code text unique,
|
||||
created_at timestamptz not null default now(),
|
||||
created_by uuid references auth.users(id)
|
||||
);
|
||||
|
||||
create table if not exists app.tills (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
shop_id uuid not null references app.shops(id) on delete restrict,
|
||||
name text not null,
|
||||
-- Pin a till to a hardware device. New devices must be registered by an
|
||||
-- owner; blocks vector #20 (second undeclared till on same machine).
|
||||
device_fingerprint text unique,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (shop_id, name)
|
||||
);
|
||||
|
||||
-- Profile mirror of auth.users so we can attach business attributes
|
||||
-- without granting clients access to the auth schema.
|
||||
create table if not exists app.user_profiles (
|
||||
user_id uuid primary key references auth.users(id) on delete cascade,
|
||||
full_name text not null,
|
||||
phone text,
|
||||
-- 6-digit PIN, salted+hashed. Never store plaintext.
|
||||
pin_hash text,
|
||||
pin_set_at timestamptz,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists app.user_shop_assignments (
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
shop_id uuid not null references app.shops(id) on delete cascade,
|
||||
role app.business_role not null,
|
||||
assigned_at timestamptz not null default now(),
|
||||
assigned_by uuid references auth.users(id),
|
||||
primary key (user_id, shop_id)
|
||||
);
|
||||
|
||||
create index if not exists idx_assignments_shop on app.user_shop_assignments(shop_id);
|
||||
create index if not exists idx_assignments_role on app.user_shop_assignments(shop_id, role);
|
||||
|
||||
-- =====================================================================
|
||||
-- Helper functions (SECURITY DEFINER) used by RLS policies.
|
||||
-- These run with the function owner's privileges, so they can read
|
||||
-- assignment rows even when the calling user cannot read the table.
|
||||
-- =====================================================================
|
||||
create or replace function app.current_user_id()
|
||||
returns uuid
|
||||
language sql
|
||||
stable
|
||||
as $$ select auth.uid() $$;
|
||||
|
||||
create or replace function app.has_role_in_shop(p_shop uuid, p_role app.business_role)
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
stable
|
||||
as $$
|
||||
select exists (
|
||||
select 1
|
||||
from app.user_shop_assignments a
|
||||
where a.user_id = auth.uid()
|
||||
and a.shop_id = p_shop
|
||||
and a.role = p_role
|
||||
);
|
||||
$$;
|
||||
|
||||
create or replace function app.has_any_role_in_shop(p_shop uuid, p_roles app.business_role[])
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
stable
|
||||
as $$
|
||||
select exists (
|
||||
select 1
|
||||
from app.user_shop_assignments a
|
||||
where a.user_id = auth.uid()
|
||||
and a.shop_id = p_shop
|
||||
and a.role = any(p_roles)
|
||||
);
|
||||
$$;
|
||||
|
||||
create or replace function app.is_owner_anywhere()
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
stable
|
||||
as $$
|
||||
select exists (
|
||||
select 1 from app.user_shop_assignments a
|
||||
where a.user_id = auth.uid() and a.role = 'owner'
|
||||
);
|
||||
$$;
|
||||
|
||||
revoke all on function app.has_role_in_shop(uuid, app.business_role) from public;
|
||||
revoke all on function app.has_any_role_in_shop(uuid, app.business_role[]) from public;
|
||||
revoke all on function app.is_owner_anywhere() from public;
|
||||
grant execute on function app.has_role_in_shop(uuid, app.business_role) to authenticated;
|
||||
grant execute on function app.has_any_role_in_shop(uuid, app.business_role[]) to authenticated;
|
||||
grant execute on function app.is_owner_anywhere() to authenticated;
|
||||
|
||||
-- =====================================================================
|
||||
-- PIN management. Plaintext PINs never leave the server.
|
||||
-- =====================================================================
|
||||
create or replace function app.set_my_pin(p_pin text)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
begin
|
||||
if p_pin !~ '^[0-9]{6}$' then
|
||||
raise exception 'PIN must be exactly 6 digits';
|
||||
end if;
|
||||
insert into app.user_profiles(user_id, full_name, pin_hash, pin_set_at)
|
||||
values (auth.uid(), coalesce((select full_name from app.user_profiles where user_id = auth.uid()), 'Unnamed'),
|
||||
crypt(p_pin, gen_salt('bf', 10)), now())
|
||||
on conflict (user_id) do update
|
||||
set pin_hash = crypt(p_pin, gen_salt('bf', 10)),
|
||||
pin_set_at = now();
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function app.verify_my_pin(p_pin text)
|
||||
returns boolean
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
declare h text;
|
||||
begin
|
||||
select pin_hash into h from app.user_profiles where user_id = auth.uid();
|
||||
if h is null then return false; end if;
|
||||
return h = crypt(p_pin, h);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function app.set_my_pin(text) from public;
|
||||
revoke all on function app.verify_my_pin(text) from public;
|
||||
grant execute on function app.set_my_pin(text) to authenticated;
|
||||
grant execute on function app.verify_my_pin(text) to authenticated;
|
||||
|
||||
-- =====================================================================
|
||||
-- Audit log of authentication / authorization events.
|
||||
-- Append-only: revoke UPDATE and DELETE; only INSERT via function.
|
||||
-- =====================================================================
|
||||
create table if not exists app.auth_events (
|
||||
id bigserial primary key,
|
||||
occurred_at timestamptz not null default now(),
|
||||
user_id uuid,
|
||||
event_type text not null, -- login, pin_ok, pin_fail, role_change, device_register, ...
|
||||
shop_id uuid,
|
||||
device_fingerprint text,
|
||||
metadata jsonb not null default '{}'::jsonb
|
||||
);
|
||||
|
||||
create index if not exists idx_auth_events_user on app.auth_events(user_id, occurred_at desc);
|
||||
create index if not exists idx_auth_events_shop on app.auth_events(shop_id, occurred_at desc);
|
||||
|
||||
create or replace function app.log_auth_event(
|
||||
p_event_type text,
|
||||
p_shop uuid,
|
||||
p_device text,
|
||||
p_metadata jsonb
|
||||
)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
begin
|
||||
insert into app.auth_events(user_id, event_type, shop_id, device_fingerprint, metadata)
|
||||
values (auth.uid(), p_event_type, p_shop, p_device, coalesce(p_metadata, '{}'::jsonb));
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function app.log_auth_event(text, uuid, text, jsonb) from public;
|
||||
grant execute on function app.log_auth_event(text, uuid, text, jsonb) to authenticated;
|
||||
|
||||
-- =====================================================================
|
||||
-- RLS — deny by default, then allow per role.
|
||||
-- =====================================================================
|
||||
alter table app.shops enable row level security;
|
||||
alter table app.tills enable row level security;
|
||||
alter table app.user_profiles enable row level security;
|
||||
alter table app.user_shop_assignments enable row level security;
|
||||
alter table app.auth_events enable row level security;
|
||||
|
||||
-- Force RLS even for table owners (defense in depth against insider edits,
|
||||
-- threat-model row #24).
|
||||
alter table app.shops force row level security;
|
||||
alter table app.tills force row level security;
|
||||
alter table app.user_profiles force row level security;
|
||||
alter table app.user_shop_assignments force row level security;
|
||||
alter table app.auth_events force row level security;
|
||||
|
||||
-- shops: owners and assigned users can see their shops.
|
||||
drop policy if exists shops_select on app.shops;
|
||||
create policy shops_select on app.shops
|
||||
for select to authenticated
|
||||
using (
|
||||
app.is_owner_anywhere()
|
||||
or exists (
|
||||
select 1 from app.user_shop_assignments a
|
||||
where a.shop_id = shops.id and a.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Only owners can create/modify shops, and never via direct UPDATE of
|
||||
-- security-relevant columns; we still permit it here but real changes
|
||||
-- should go through dedicated functions later.
|
||||
drop policy if exists shops_write_owner on app.shops;
|
||||
create policy shops_write_owner on app.shops
|
||||
for all to authenticated
|
||||
using (app.is_owner_anywhere())
|
||||
with check (app.is_owner_anywhere());
|
||||
|
||||
-- tills: visible to everyone assigned to the shop, writable only by owners.
|
||||
drop policy if exists tills_select on app.tills;
|
||||
create policy tills_select on app.tills
|
||||
for select to authenticated
|
||||
using (
|
||||
app.is_owner_anywhere()
|
||||
or exists (
|
||||
select 1 from app.user_shop_assignments a
|
||||
where a.shop_id = tills.shop_id and a.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists tills_write_owner on app.tills;
|
||||
create policy tills_write_owner on app.tills
|
||||
for all to authenticated
|
||||
using (app.has_role_in_shop(tills.shop_id, 'owner'))
|
||||
with check (app.has_role_in_shop(tills.shop_id, 'owner'));
|
||||
|
||||
-- user_profiles: a user can read/update their own profile (but PIN is
|
||||
-- changed only via the set_my_pin function). Owners can read all.
|
||||
drop policy if exists profiles_select_self_or_owner on app.user_profiles;
|
||||
create policy profiles_select_self_or_owner on app.user_profiles
|
||||
for select to authenticated
|
||||
using (user_id = auth.uid() or app.is_owner_anywhere());
|
||||
|
||||
drop policy if exists profiles_update_self on app.user_profiles;
|
||||
create policy profiles_update_self on app.user_profiles
|
||||
for update to authenticated
|
||||
using (user_id = auth.uid())
|
||||
with check (user_id = auth.uid());
|
||||
|
||||
-- user_shop_assignments: a user can see their own assignments; owners can
|
||||
-- see/manage all assignments in their own shops.
|
||||
drop policy if exists assignments_select on app.user_shop_assignments;
|
||||
create policy assignments_select on app.user_shop_assignments
|
||||
for select to authenticated
|
||||
using (
|
||||
user_id = auth.uid()
|
||||
or app.has_role_in_shop(shop_id, 'owner')
|
||||
);
|
||||
|
||||
drop policy if exists assignments_write_owner on app.user_shop_assignments;
|
||||
create policy assignments_write_owner on app.user_shop_assignments
|
||||
for all to authenticated
|
||||
using (app.has_role_in_shop(shop_id, 'owner'))
|
||||
with check (app.has_role_in_shop(shop_id, 'owner'));
|
||||
|
||||
-- auth_events: nobody writes directly; only the log_auth_event function.
|
||||
-- Reads: a user sees their own events; owners see all in their shops.
|
||||
revoke insert, update, delete on app.auth_events from authenticated;
|
||||
|
||||
drop policy if exists auth_events_select on app.auth_events;
|
||||
create policy auth_events_select on app.auth_events
|
||||
for select to authenticated
|
||||
using (
|
||||
user_id = auth.uid()
|
||||
or (shop_id is not null and app.has_role_in_shop(shop_id, 'owner'))
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Hard prohibitions — no DELETE on auth_events from anyone (including
|
||||
-- service role used by the app). Only DBA at psql can DELETE, and that
|
||||
-- itself should be audited at the infrastructure level.
|
||||
-- =====================================================================
|
||||
revoke delete on app.auth_events from authenticated;
|
||||
-- Note: in Supabase, the `service_role` bypasses RLS but still respects
|
||||
-- table grants. Revoke explicitly:
|
||||
do $$ begin
|
||||
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
||||
execute 'revoke delete on app.auth_events from service_role';
|
||||
execute 'revoke update on app.auth_events from service_role';
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- =====================================================================
|
||||
-- Grants for ordinary table access (RLS still applies).
|
||||
-- =====================================================================
|
||||
grant select on app.shops to authenticated;
|
||||
grant insert, update on app.shops to authenticated;
|
||||
grant select on app.tills to authenticated;
|
||||
grant insert, update on app.tills to authenticated;
|
||||
grant select, update on app.user_profiles to authenticated;
|
||||
grant select, insert, update, delete on app.user_shop_assignments to authenticated;
|
||||
grant select on app.auth_events to authenticated;
|
||||
|
||||
-- End migration 0001 ----------------------------------------------------
|
||||
Reference in New Issue
Block a user