-- ===================================================================== -- 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 ----------------------------------------------------