Add cash management schema and immediate variance alerts

This commit is contained in:
Krikorios
2026-05-06 10:51:55 +03:00
parent 1a3de58de6
commit 1896cbdd11
106 changed files with 16800 additions and 4604 deletions
+348
View File
@@ -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 ----------------------------------------------------