T

Supabase App Rescue

Tallgrass Customer Portal — before / after hardening

Taking a Lovable-pattern Supabase app to production

Most "rescue my AI-built app" jobs on Supabase are a Vite/React frontend talking straight to Supabase from the browser (the Lovable/Bolt pattern) with Row-Level Security left off, permissive, or bypassed by a leaked key. I built the Tallgrass customer portal that way, reverted it to the common vibe-coded flaw classes, proved each one with a live exploit against a real local Supabase stack, then hardened it and re-ran the same exploits.

Tallgrass Precision Components is fictional · demo by Garrett Smith

13
findings, with exploits
13/13
exploits work on before
0/13
exploits work on after
8/8
functional tests pass on after
SeverityFindingsFixed in after
Critical44
High55
Medium33
Low11

Generated from tests/results.json on 2026-10-13 by tests/run.mjs, against a real local Supabase stack (npx supabase start).

Findings — before vs after

Critical

E-001 · Row-Level Security disabled on the rmas table (CVE-2025-48757 class)

Exploit E-001 — before EXPLOITED anon key (no login) read 2 RMA rows across 2 tenants, HTTP 200
after blocked anon key denied: HTTP 200 []
Before / after — Database: schemas, RLS policies, RPC
before/ — vulnerable
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
after/ — hardened
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
Critical

E-002 · RLS enabled but policy is using(true): any customer reads every tenant's orders

Exploit E-002 — before EXPLOITED Skyline customer (jmiller@skylineaero.example) read 6 orders across 2 tenants, including Meridian's
after blocked Skyline customer sees only their own tenant: ["11111111-1111-1111-1111-111111111111"]
Before / after — Database: schemas, RLS policies, RPC
before/ — vulnerable
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
after/ — hardened
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
Critical

E-003 · Client-writable tenant_id/role on the profile row: horizontal + vertical privilege escalation

Exploit E-003 — before EXPLOITED PATCH /profiles from the browser set role='staff' and tenant_id=Meridian for a Skyline customer, HTTP 200
after blocked profile update blocked or had no effect: HTTP 200 []
Before / after — Database: schemas, RLS policies, RPC
before/ — vulnerable
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
after/ — hardened
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
Critical

E-004 · service_role key shipped in the client bundle (VITE_ env var)

Exploit E-004 — before EXPLOITED service_role key found verbatim in 1/1 built JS chunk(s) served at http://127.0.0.1:3101
after blocked no service_role key fragment in 1 built JS chunk(s) at http://127.0.0.1:3102
Before / after — Browser Supabase client
before/ — vulnerable
import { createClient } from '@supabase/supabase-js'

const url = import.meta.env.VITE_SUPABASE_URL
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
const schema = import.meta.env.VITE_SUPABASE_SCHEMA

// F-004: the service_role key is read from a VITE_ env var here, so Vite
// bakes its literal value into the built JS for every visitor (see
// scripts/build-demo exploit E-004, which greps the built bundle for it).
// A real "vibe-coded" version of this mistake usually looks like someone
// pasting the service_role key into whichever .env var made a stubborn RLS
// error go away.
export const serviceKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY

export const supabase = createClient(url, anonKey, { db: { schema } })

// Exposed on window so the "attacker" exploit script can prove the key is
// reachable from arbitrary page JS, the same way a real attacker would pull
// it out of the shipped bundle with a regex.
if (typeof window !== 'undefined') window.__TALLGRASS_SERVICE_KEY__ = serviceKey
after/ — hardened
import { createClient } from '@supabase/supabase-js'

const url = import.meta.env.VITE_SUPABASE_URL
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
const schema = import.meta.env.VITE_SUPABASE_SCHEMA

// F-004 fix: only the anon key ever reaches the browser. There is no
// VITE_SUPABASE_SERVICE_ROLE_KEY anywhere in this app; the service key lives
// only as a Supabase Edge Function secret (see supabase/functions and
// supabase/functions/.env), used server-side by support-ticket-after.
export const supabase = createClient(url, anonKey, { db: { schema } })
High

E-005 · Storage bucket public / unreadable cross-tenant certificate PDFs

Exploit E-005 — before EXPLOITED bucket 'certs-before' public=true; unauthenticated object fetch -> HTTP 400
after blocked bucket 'certs-after' public=false; unauthenticated object fetch -> HTTP 400 (blocked)
Before / after — Database: schemas, RLS policies, RPC
before/ — vulnerable
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
after/ — hardened
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
High

E-006 · SECURITY DEFINER RPC with no auth check: any customer can change any order's status

Exploit E-006 — before EXPLOITED Skyline customer called admin_set_order_status() on a Meridian order (not staff, no membership) -> status now 'Cancelled'
after blocked RPC call result: HTTP 403 {"code":"42501","details":null,"hint":null,"message":"not authorized"}; order status unchanged
Before / after — Database: schemas, RLS policies, RPC
before/ — vulnerable
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
after/ — hardened
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
High

E-007 · Missing WITH CHECK on insert: a customer writes a cert row into another tenant

Exploit E-007 — before EXPLOITED Skyline customer inserted a certificate row attributed to Meridian, HTTP 201
after blocked insert blocked: HTTP 403 {"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"certs\""}
Before / after — Database: schemas, RLS policies, RPC
before/ — vulnerable
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
after/ — hardened
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
High

E-008 · Edge function: wildcard CORS + no auth check lets anyone write a ticket for any tenant

Exploit E-008 — before EXPLOITED unauthenticated POST to /functions/v1/support-ticket-before created a ticket for Meridian (CORS: *)
after blocked blocked: HTTP 401 {"error":"Sign in required."} (CORS: *)
Before / after — Edge function: support-ticket
before/ — vulnerable
// support-ticket-before — intentionally flawed edge function.
// Flaws injected on purpose for this demo (see audit/FINDINGS.md):
//   F-008 wildcard CORS + no auth check at all (anyone can call this, logged
//         in or not, and it trusts whatever tenant_id the caller sends)
//   F-010 no rate limiting (can be hit as fast as the network allows)
//   F-011 verbose errors (returns the raw Postgres error, including detail/
//         hint/code, straight to the caller)
//
// It uses the service_role key SERVER-SIDE ONLY (as a Supabase secret, never
// shipped to the browser — that part is fine and matches real Supabase
// practice). The problem is everything downstream of "did we check who is
// calling".
import { createClient } from "jsr:@supabase/supabase-js@2";

const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;

Deno.serve(async (req) => {
  // F-008: reflects any origin, allows credentials, does not even look at
  // req.headers.get('authorization').
  const cors = {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Headers": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
  };
  if (req.method === "OPTIONS") return new Response("ok", { headers: cors });

  const body = await req.json().catch(() => ({}));
  const { tenant_id, email, message } = body;

  const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
    db: { schema: "app_before" },
  });

  // No verification that the caller is actually a member of tenant_id — the
  // client just says which company the ticket is "from".
  const { data, error } = await admin
    .from("support_tickets")
    .insert({ tenant_id, email, message })
    .select()
    .single();

  if (error) {
    // F-011: the raw Postgres error (message/detail/hint/code) goes straight
    // back to the browser.
    return new Response(JSON.stringify({ error }), {
      status: 500,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  return new Response(JSON.stringify({ ok: true, ticket: data }), {
    status: 200,
    headers: { ...cors, "Content-Type": "application/json" },
  });
});
after/ — hardened
// support-ticket-after — hardened edge function.
// Fixes for F-008, F-010, F-011:
//   - CORS locked to one configured origin, not "*".
//   - Requires a valid Supabase session (Authorization: Bearer <jwt>);
//     tenant_id is read from the CALLER's own profile row, never trusted
//     from the request body.
//   - A simple per-caller token-bucket rate limit (in-memory — fine for a
//     single local demo instance; a real deploy needs a shared store such as
//     Upstash/Redis, same caveat as the Next.js case study's limiter).
//   - Errors are logged server-side with a correlation id; the caller gets a
//     generic message only.
import { createClient } from "jsr:@supabase/supabase-js@2";

const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const ALLOWED_ORIGIN = Deno.env.get("ALLOWED_ORIGIN") ?? "http://127.0.0.1:3102";

// per-instance in-memory bucket: 5 requests / 60s per caller.
const buckets = new Map<string, { count: number; resetAt: number }>();
const LIMIT = 5;
const WINDOW_MS = 60_000;

function limited(key: string): boolean {
  const now = Date.now();
  const b = buckets.get(key);
  if (!b || now > b.resetAt) {
    buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
    return false;
  }
  b.count += 1;
  return b.count > LIMIT;
}

Deno.serve(async (req) => {
  const cors = {
    "Access-Control-Allow-Origin": ALLOWED_ORIGIN,
    "Access-Control-Allow-Headers": "authorization, content-type, apikey",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Vary": "Origin",
  };
  if (req.method === "OPTIONS") return new Response("ok", { headers: cors });

  const authHeader = req.headers.get("authorization") ?? "";
  const jwt = authHeader.replace(/^Bearer\s+/i, "");
  if (!jwt) {
    return new Response(JSON.stringify({ error: "Sign in required." }), {
      status: 401,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  // Verify the caller's own session (anon key + their JWT — never the
  // service key for this step).
  const asUser = createClient(SUPABASE_URL, Deno.env.get("SUPABASE_ANON_KEY")!, {
    global: { headers: { Authorization: `Bearer ${jwt}` } },
  });
  const { data: userData, error: userErr } = await asUser.auth.getUser(jwt);
  if (userErr || !userData?.user) {
    return new Response(JSON.stringify({ error: "Sign in required." }), {
      status: 401,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }
  const userId = userData.user.id;

  if (limited(userId)) {
    return new Response(JSON.stringify({ error: "Too many requests. Try again in a minute." }), {
      status: 429,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
    db: { schema: "app_after" },
  });

  // tenant_id comes from the caller's OWN profile row — never from the body.
  const { data: profile, error: profileErr } = await admin
    .from("profiles")
    .select("tenant_id, email")
    .eq("id", userId)
    .single();
  if (profileErr || !profile?.tenant_id) {
    const ref = crypto.randomUUID();
    console.error(`[support-ticket ${ref}]`, profileErr);
    return new Response(JSON.stringify({ error: "Could not submit the ticket.", ref }), {
      status: 400,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  const body = await req.json().catch(() => ({}));
  const message = typeof body.message === "string" ? body.message.slice(0, 2000) : "";
  if (!message.trim()) {
    return new Response(JSON.stringify({ error: "Message is required." }), {
      status: 400,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  const { data, error } = await admin
    .from("support_tickets")
    .insert({ tenant_id: profile.tenant_id, email: profile.email, message })
    .select()
    .single();

  if (error) {
    const ref = crypto.randomUUID();
    console.error(`[support-ticket ${ref}]`, error);
    return new Response(JSON.stringify({ error: "Could not submit the ticket.", ref }), {
      status: 500,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  return new Response(JSON.stringify({ ok: true, ticket: { id: data.id } }), {
    status: 200,
    headers: { ...cors, "Content-Type": "application/json" },
  });
});
High

E-009 · Open self-signup + world-readable invite code = join a real tenant with no verification

Exploit E-009 — before EXPLOITED invite_code 'MED-DEMO-4M9X' leaked via anon SELECT; self-signup with no email confirmation produced an instant working session inside Meridian's tenant
after blocked invite_code not readable via anon key (HTTP 200, []) — no code to use
Before / after — Database: schemas, RLS policies, RPC
before/ — vulnerable
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
after/ — hardened
-- Tallgrass Precision Components — customer portal
-- Two schemas in the SAME local Supabase/Postgres instance so both the
-- vulnerable and hardened versions can be queried live, side by side,
-- through the same anon key, without restarting the stack:
--   app_before  — the flawed build (every flaw documented in audit/FINDINGS.md)
--   app_after   — the hardened build
-- Both schemas are exposed via PostgREST (see supabase/config.toml api.schemas).
-- auth.users is shared between them (same demo accounts, same passwords) so the
-- functional tests are apples to apples.

create extension if not exists pgcrypto;

create schema if not exists app_before;
create schema if not exists app_after;
grant usage on schema app_before to anon, authenticated;
grant usage on schema app_after  to anon, authenticated;

-- ===========================================================================
-- Shared demo accounts (auth.users) — used by both schemas.
-- Local-only passwords, obviously fake, documented in README.
-- ===========================================================================

insert into auth.users
  (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at,
   raw_app_meta_data, raw_user_meta_data, created_at, updated_at, confirmation_token,
   recovery_token, email_change_token_new, email_change)
values
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a001', 'authenticated', 'authenticated',
   'jmiller@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a002', 'authenticated', 'authenticated',
   'dchen@skylineaero.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a003', 'authenticated', 'authenticated',
   'rpatel@meridianmed.example', crypt('Demo-Customer-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', ''),
  ('00000000-0000-0000-0000-000000000000', 'a0000000-0000-0000-0000-00000000a004', 'authenticated', 'authenticated',
   'staff@tallgrasspc.example', crypt('Demo-Staff-2026!', gen_salt('bf')), now(),
   '{"provider":"email","providers":["email"]}', '{}', now(), now(), '', '', '', '')
on conflict (id) do nothing;

insert into auth.identities
  (id, provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
select gen_random_uuid(), u.id::text, u.id, jsonb_build_object('sub', u.id::text, 'email', u.email), 'email', now(), now(), now()
from auth.users u
where u.id in ('a0000000-0000-0000-0000-00000000a001','a0000000-0000-0000-0000-00000000a002',
               'a0000000-0000-0000-0000-00000000a003','a0000000-0000-0000-0000-00000000a004')
on conflict do nothing;

-- ===========================================================================
-- app_before — tables (flaws injected on purpose; see audit/FINDINGS.md)
-- ===========================================================================

create table app_before.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

-- F-003: tenant_id and role live directly on the user-updatable profile row,
-- and (see policies below) the update policy lets a user change BOTH.
create table app_before.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_before.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_before.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_before.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

-- F-001: RLS never enabled on this table at all. Readable/writable by anyone
-- holding the anon key, straight from the browser via PostgREST.
create table app_before.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_before.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_before.orders (tenant_id);
create index on app_before.certs  (tenant_id);
create index on app_before.rmas   (tenant_id);

-- F-006: SECURITY DEFINER function with NO auth/role check inside. Any
-- authenticated caller (the grant below even allows it) can move any order
-- to any status for any tenant.
create or replace function app_before.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security definer
set search_path = app_before, public
as $$
begin
  update app_before.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_before.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: role is fixed to 'customer' at signup (that part is fine);
-- the real hole is that the profiles UPDATE policy below lets the user change
-- tenant_id and role afterwards.
create or replace function app_before.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_before, public as $$
declare
  t uuid;
begin
  select id into t from app_before.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_before.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_before on auth.users;
create trigger on_auth_user_created_before
  after insert on auth.users
  for each row execute function app_before.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_before.tenants  enable row level security;
alter table app_before.profiles enable row level security;
alter table app_before.orders   enable row level security;
alter table app_before.certs    enable row level security;
-- app_before.rmas: RLS left OFF on purpose (F-001).

-- F-009 (part of the anon-reconnaissance finding): RLS is "on" for tenants but
-- the policy is `using (true)` AND granted to anon, so anyone with just the
-- published anon key — no login required — can list every company name.
create policy before_tenants_select_anyone on app_before.tenants
  for select to anon, authenticated using (true);

-- F-003: a user may select their own profile AND update it — including the
-- tenant_id and role columns, which PostgREST lets through because the policy
-- has no column restriction and no WITH CHECK pinning them to their old values.
create policy before_profiles_select on app_before.profiles
  for select to authenticated using (true);
create policy before_profiles_update_self on app_before.profiles
  for update to authenticated using (id = auth.uid());

-- F-002: RLS is "on" (looks safe in a quick audit) but the SELECT policy is
-- `using (true)` — every authenticated customer can read every tenant's
-- orders, not just their own.
create policy before_orders_select_all on app_before.orders
  for select to authenticated using (true);
create policy before_orders_write_all on app_before.orders
  for insert to authenticated with check (true);
create policy before_orders_update_all on app_before.orders
  for update to authenticated using (true);

create policy before_certs_select_all on app_before.certs
  for select to authenticated using (true);
create policy before_certs_write_all on app_before.certs
  for insert to authenticated with check (true);

-- app_before.rmas has no policies at all — moot, because RLS is disabled on
-- the table, so PostgREST does not even consult policies.

-- ===========================================================================
-- app_after — hardened tables
-- ===========================================================================

create table app_after.tenants (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  invite_code text not null unique,
  created_at  timestamptz not null default now()
);

create table app_after.profiles (
  id         uuid primary key references auth.users(id) on delete cascade,
  email      text,
  tenant_id  uuid references app_after.tenants(id),
  role       text not null default 'customer' check (role in ('customer','staff')),
  created_at timestamptz not null default now()
);

create table app_after.orders (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text not null,
  part_number  text not null,
  qty          integer not null check (qty > 0),
  due_date     date not null,
  status       text not null default 'Open'
               check (status in ('Open','In Production','Inspection','Shipped','Delivered','Cancelled')),
  created_at   timestamptz not null default now()
);

create table app_after.certs (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  title        text not null,
  file_path    text not null,
  created_at   timestamptz not null default now()
);

create table app_after.rmas (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references app_after.tenants(id) on delete cascade,
  order_number text,
  reason       text not null,
  status       text not null default 'Requested'
               check (status in ('Requested','Approved','Received','Resolved','Rejected')),
  created_by   uuid references auth.users(id),
  created_at   timestamptz not null default now()
);

create index on app_after.orders (tenant_id);
create index on app_after.certs  (tenant_id);
create index on app_after.rmas   (tenant_id);

-- helper functions read the caller's OWN row only, security definer so the
-- policies that call them don't recurse into profiles' own RLS.
create or replace function app_after.current_tenant_id()
returns uuid language sql stable security definer set search_path = app_after, public as $$
  select tenant_id from app_after.profiles where id = auth.uid()
$$;

create or replace function app_after.is_staff()
returns boolean language sql stable security definer set search_path = app_after, public as $$
  select coalesce((select role = 'staff' from app_after.profiles where id = auth.uid()), false)
$$;

-- F-006 fix: SECURITY INVOKER (runs as the caller, so RLS on orders still
-- applies) PLUS an explicit staff check — belt and suspenders. A customer
-- calling this gets a permission error, not a silent no-op.
create or replace function app_after.admin_set_order_status(p_order_id uuid, p_status text)
returns void
language plpgsql
security invoker
set search_path = app_after, public
as $$
begin
  if not app_after.is_staff() then
    raise exception 'not authorized' using errcode = '42501';
  end if;
  update app_after.orders set status = p_status where id = p_order_id;
end;
$$;
grant execute on function app_after.admin_set_order_status(uuid, text) to authenticated;

-- new-user trigger: tenant comes from the invite code at signup, same as
-- before — the fix is that nothing downstream lets the user change it later.
create or replace function app_after.handle_new_user()
returns trigger language plpgsql security definer set search_path = app_after, public as $$
declare
  t uuid;
begin
  select id into t from app_after.tenants
   where invite_code = new.raw_user_meta_data->>'invite_code';
  insert into app_after.profiles (id, email, tenant_id, role)
  values (new.id, new.email, t, 'customer')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created_after on auth.users;
create trigger on_auth_user_created_after
  after insert on auth.users
  for each row execute function app_after.handle_new_user();

-- ------------------------------------------------------------------- RLS

alter table app_after.tenants  enable row level security;
alter table app_after.profiles enable row level security;
alter table app_after.orders   enable row level security;
alter table app_after.certs    enable row level security;
alter table app_after.rmas     enable row level security;

-- F-009 fix: tenants are no longer readable by anon, and an authenticated
-- user only sees their own company (staff see all).
create policy after_tenants_select on app_after.tenants
  for select to authenticated
  using (id = app_after.current_tenant_id() or app_after.is_staff());

-- F-003 fix: a user may read their own profile. There is NO update policy at
-- all for authenticated users — tenant_id and role can only change through
-- the SECURITY DEFINER trigger (signup) or a staff-only RPC, never a direct
-- PostgREST PATCH from the browser.
create policy after_profiles_select on app_after.profiles
  for select to authenticated
  using (id = auth.uid() or app_after.is_staff());
create policy after_profiles_staff_update on app_after.profiles
  for update to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-002 fix: real tenant scoping, not `using (true)`.
create policy after_orders_select on app_after.orders
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_orders_staff_write on app_after.orders
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

create policy after_certs_select on app_after.certs
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_certs_staff_write on app_after.certs
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- F-007 fix: rmas is readable per-tenant, and a customer may INSERT only a
-- row for their OWN tenant, as themselves, starting in 'Requested' — the
-- WITH CHECK that app_before never had.
create policy after_rmas_select on app_after.rmas
  for select to authenticated
  using (tenant_id = app_after.current_tenant_id() or app_after.is_staff());
create policy after_rmas_customer_insert on app_after.rmas
  for insert to authenticated
  with check (
    tenant_id = app_after.current_tenant_id()
    and status = 'Requested'
    and created_by = auth.uid()
  );
create policy after_rmas_staff_write on app_after.rmas
  for all to authenticated
  using (app_after.is_staff()) with check (app_after.is_staff());

-- ===========================================================================
-- Storage — certs buckets
-- ===========================================================================

-- F-005: public bucket, no storage policies needed to read — any PDF path is
-- fetchable by anyone who can guess or list it, across every tenant.
insert into storage.buckets (id, name, public)
values ('certs-before', 'certs-before', true)
on conflict (id) do nothing;

-- F-005 fix: private bucket, explicit per-tenant policies (same shape as the
-- DB RLS: first path segment is the tenant id).
insert into storage.buckets (id, name, public)
values ('certs-after', 'certs-after', false)
on conflict (id) do nothing;

create policy after_certs_files_read on storage.objects for select to authenticated
  using (
    bucket_id = 'certs-after'
    and (
      app_after.is_staff()
      or (storage.foldername(name))[1] = app_after.current_tenant_id()::text
    )
  );
create policy after_certs_files_staff_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_update on storage.objects for update to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());
create policy after_certs_files_staff_delete on storage.objects for delete to authenticated
  using (bucket_id = 'certs-after' and app_after.is_staff());

-- ===========================================================================
-- Seed data — fictional. Same tenants/orders/etc. in both schemas so
-- functional tests are directly comparable.
-- ===========================================================================

insert into app_before.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');
insert into app_after.tenants (id, name, invite_code) values
  ('11111111-1111-1111-1111-111111111111', 'Skyline Aerospace (demo)', 'AERO-DEMO-7K2Q'),
  ('22222222-2222-2222-2222-222222222222', 'Meridian Medical Devices (demo)', 'MED-DEMO-4M9X');

-- profiles: 2 customers at Skyline, 1 at Meridian, 1 staff — same ids in both schemas
insert into app_before.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;
insert into app_after.profiles (id, email, tenant_id, role) values
  ('a0000000-0000-0000-0000-00000000a001', 'jmiller@skylineaero.example', '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a002', 'dchen@skylineaero.example',   '11111111-1111-1111-1111-111111111111', 'customer'),
  ('a0000000-0000-0000-0000-00000000a003', 'rpatel@meridianmed.example',  '22222222-2222-2222-2222-222222222222', 'customer'),
  ('a0000000-0000-0000-0000-00000000a004', 'staff@tallgrasspc.example',   null, 'staff')
on conflict (id) do nothing;

insert into app_before.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');
insert into app_after.orders (tenant_id, order_number, part_number, qty, due_date, status) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10041', 'TPC-AL-7075-BRKT-220', 150, '2026-10-30', 'In Production'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10042', 'TPC-TI-6AL4V-FTG-118',  40, '2026-11-14', 'Open'),
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'TPC-AL-6061-HSG-305',  300, '2026-09-28', 'Shipped'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5520',  'TPC-SS-316L-CANN-045', 500, '2026-10-22', 'Inspection'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5527',  'TPC-SS-316L-HDL-012',  250, '2026-11-05', 'In Production'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'TPC-PEEK-SPC-077',    1000, '2026-09-15', 'Delivered');

insert into app_before.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');
insert into app_after.certs (tenant_id, order_number, title, file_path) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Certificate of Conformance – PO-10037', '11111111-1111-1111-1111-111111111111/coc-po-10037.pdf'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Material Cert & CoC – MM-5491',          '22222222-2222-2222-2222-222222222222/coc-mm-5491.pdf');

insert into app_before.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');
insert into app_after.rmas (tenant_id, order_number, reason, status, created_by) values
  ('11111111-1111-1111-1111-111111111111', 'PO-10037', 'Three housings have a burr on the bore edge.', 'Approved',  'a0000000-0000-0000-0000-00000000a001'),
  ('22222222-2222-2222-2222-222222222222', 'MM-5491',  'Spacer dimension out of tolerance on 12 pcs.', 'Requested', 'a0000000-0000-0000-0000-00000000a003');

-- grants: anon/authenticated need table-level privileges; row visibility is
-- then enforced (or, in app_before's case, NOT enforced) by RLS above.
grant select, insert, update, delete on all tables in schema app_before to anon, authenticated;
grant select, insert, update, delete on all tables in schema app_after  to anon, authenticated;
grant usage on all sequences in schema app_before to anon, authenticated;
grant usage on all sequences in schema app_after  to anon, authenticated;
Medium

E-010 · No rate limiting on the edge function (spam / credential-adjacent abuse)

Exploit E-010 — before EXPLOITED 10 rapid calls -> 10 succeeded, 0 throttled (429)
after blocked 10 rapid calls -> 5 succeeded, 5 throttled (429)
Before / after — Edge function: support-ticket
before/ — vulnerable
// support-ticket-before — intentionally flawed edge function.
// Flaws injected on purpose for this demo (see audit/FINDINGS.md):
//   F-008 wildcard CORS + no auth check at all (anyone can call this, logged
//         in or not, and it trusts whatever tenant_id the caller sends)
//   F-010 no rate limiting (can be hit as fast as the network allows)
//   F-011 verbose errors (returns the raw Postgres error, including detail/
//         hint/code, straight to the caller)
//
// It uses the service_role key SERVER-SIDE ONLY (as a Supabase secret, never
// shipped to the browser — that part is fine and matches real Supabase
// practice). The problem is everything downstream of "did we check who is
// calling".
import { createClient } from "jsr:@supabase/supabase-js@2";

const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;

Deno.serve(async (req) => {
  // F-008: reflects any origin, allows credentials, does not even look at
  // req.headers.get('authorization').
  const cors = {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Headers": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
  };
  if (req.method === "OPTIONS") return new Response("ok", { headers: cors });

  const body = await req.json().catch(() => ({}));
  const { tenant_id, email, message } = body;

  const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
    db: { schema: "app_before" },
  });

  // No verification that the caller is actually a member of tenant_id — the
  // client just says which company the ticket is "from".
  const { data, error } = await admin
    .from("support_tickets")
    .insert({ tenant_id, email, message })
    .select()
    .single();

  if (error) {
    // F-011: the raw Postgres error (message/detail/hint/code) goes straight
    // back to the browser.
    return new Response(JSON.stringify({ error }), {
      status: 500,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  return new Response(JSON.stringify({ ok: true, ticket: data }), {
    status: 200,
    headers: { ...cors, "Content-Type": "application/json" },
  });
});
after/ — hardened
// support-ticket-after — hardened edge function.
// Fixes for F-008, F-010, F-011:
//   - CORS locked to one configured origin, not "*".
//   - Requires a valid Supabase session (Authorization: Bearer <jwt>);
//     tenant_id is read from the CALLER's own profile row, never trusted
//     from the request body.
//   - A simple per-caller token-bucket rate limit (in-memory — fine for a
//     single local demo instance; a real deploy needs a shared store such as
//     Upstash/Redis, same caveat as the Next.js case study's limiter).
//   - Errors are logged server-side with a correlation id; the caller gets a
//     generic message only.
import { createClient } from "jsr:@supabase/supabase-js@2";

const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const ALLOWED_ORIGIN = Deno.env.get("ALLOWED_ORIGIN") ?? "http://127.0.0.1:3102";

// per-instance in-memory bucket: 5 requests / 60s per caller.
const buckets = new Map<string, { count: number; resetAt: number }>();
const LIMIT = 5;
const WINDOW_MS = 60_000;

function limited(key: string): boolean {
  const now = Date.now();
  const b = buckets.get(key);
  if (!b || now > b.resetAt) {
    buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
    return false;
  }
  b.count += 1;
  return b.count > LIMIT;
}

Deno.serve(async (req) => {
  const cors = {
    "Access-Control-Allow-Origin": ALLOWED_ORIGIN,
    "Access-Control-Allow-Headers": "authorization, content-type, apikey",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Vary": "Origin",
  };
  if (req.method === "OPTIONS") return new Response("ok", { headers: cors });

  const authHeader = req.headers.get("authorization") ?? "";
  const jwt = authHeader.replace(/^Bearer\s+/i, "");
  if (!jwt) {
    return new Response(JSON.stringify({ error: "Sign in required." }), {
      status: 401,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  // Verify the caller's own session (anon key + their JWT — never the
  // service key for this step).
  const asUser = createClient(SUPABASE_URL, Deno.env.get("SUPABASE_ANON_KEY")!, {
    global: { headers: { Authorization: `Bearer ${jwt}` } },
  });
  const { data: userData, error: userErr } = await asUser.auth.getUser(jwt);
  if (userErr || !userData?.user) {
    return new Response(JSON.stringify({ error: "Sign in required." }), {
      status: 401,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }
  const userId = userData.user.id;

  if (limited(userId)) {
    return new Response(JSON.stringify({ error: "Too many requests. Try again in a minute." }), {
      status: 429,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
    db: { schema: "app_after" },
  });

  // tenant_id comes from the caller's OWN profile row — never from the body.
  const { data: profile, error: profileErr } = await admin
    .from("profiles")
    .select("tenant_id, email")
    .eq("id", userId)
    .single();
  if (profileErr || !profile?.tenant_id) {
    const ref = crypto.randomUUID();
    console.error(`[support-ticket ${ref}]`, profileErr);
    return new Response(JSON.stringify({ error: "Could not submit the ticket.", ref }), {
      status: 400,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  const body = await req.json().catch(() => ({}));
  const message = typeof body.message === "string" ? body.message.slice(0, 2000) : "";
  if (!message.trim()) {
    return new Response(JSON.stringify({ error: "Message is required." }), {
      status: 400,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  const { data, error } = await admin
    .from("support_tickets")
    .insert({ tenant_id: profile.tenant_id, email: profile.email, message })
    .select()
    .single();

  if (error) {
    const ref = crypto.randomUUID();
    console.error(`[support-ticket ${ref}]`, error);
    return new Response(JSON.stringify({ error: "Could not submit the ticket.", ref }), {
      status: 500,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  return new Response(JSON.stringify({ ok: true, ticket: { id: data.id } }), {
    status: 200,
    headers: { ...cors, "Content-Type": "application/json" },
  });
});
Medium

E-011 · Edge function returns raw Postgres errors (stack/SQL-shaped detail) to the caller

Exploit E-011 — before EXPLOITED raw Postgres error returned to the browser: {"code":"23503","details":"Key (tenant_id)=(99999999-9999-9999-9999-999999999999) is not present in table \"tenants\".","hint":null,"message":"insert or update on table \"support_t
after blocked generic response only: {"error":"Too many requests. Try again in a minute."}
Before / after — Edge function: support-ticket
before/ — vulnerable
// support-ticket-before — intentionally flawed edge function.
// Flaws injected on purpose for this demo (see audit/FINDINGS.md):
//   F-008 wildcard CORS + no auth check at all (anyone can call this, logged
//         in or not, and it trusts whatever tenant_id the caller sends)
//   F-010 no rate limiting (can be hit as fast as the network allows)
//   F-011 verbose errors (returns the raw Postgres error, including detail/
//         hint/code, straight to the caller)
//
// It uses the service_role key SERVER-SIDE ONLY (as a Supabase secret, never
// shipped to the browser — that part is fine and matches real Supabase
// practice). The problem is everything downstream of "did we check who is
// calling".
import { createClient } from "jsr:@supabase/supabase-js@2";

const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;

Deno.serve(async (req) => {
  // F-008: reflects any origin, allows credentials, does not even look at
  // req.headers.get('authorization').
  const cors = {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Headers": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
  };
  if (req.method === "OPTIONS") return new Response("ok", { headers: cors });

  const body = await req.json().catch(() => ({}));
  const { tenant_id, email, message } = body;

  const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
    db: { schema: "app_before" },
  });

  // No verification that the caller is actually a member of tenant_id — the
  // client just says which company the ticket is "from".
  const { data, error } = await admin
    .from("support_tickets")
    .insert({ tenant_id, email, message })
    .select()
    .single();

  if (error) {
    // F-011: the raw Postgres error (message/detail/hint/code) goes straight
    // back to the browser.
    return new Response(JSON.stringify({ error }), {
      status: 500,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  return new Response(JSON.stringify({ ok: true, ticket: data }), {
    status: 200,
    headers: { ...cors, "Content-Type": "application/json" },
  });
});
after/ — hardened
// support-ticket-after — hardened edge function.
// Fixes for F-008, F-010, F-011:
//   - CORS locked to one configured origin, not "*".
//   - Requires a valid Supabase session (Authorization: Bearer <jwt>);
//     tenant_id is read from the CALLER's own profile row, never trusted
//     from the request body.
//   - A simple per-caller token-bucket rate limit (in-memory — fine for a
//     single local demo instance; a real deploy needs a shared store such as
//     Upstash/Redis, same caveat as the Next.js case study's limiter).
//   - Errors are logged server-side with a correlation id; the caller gets a
//     generic message only.
import { createClient } from "jsr:@supabase/supabase-js@2";

const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const ALLOWED_ORIGIN = Deno.env.get("ALLOWED_ORIGIN") ?? "http://127.0.0.1:3102";

// per-instance in-memory bucket: 5 requests / 60s per caller.
const buckets = new Map<string, { count: number; resetAt: number }>();
const LIMIT = 5;
const WINDOW_MS = 60_000;

function limited(key: string): boolean {
  const now = Date.now();
  const b = buckets.get(key);
  if (!b || now > b.resetAt) {
    buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
    return false;
  }
  b.count += 1;
  return b.count > LIMIT;
}

Deno.serve(async (req) => {
  const cors = {
    "Access-Control-Allow-Origin": ALLOWED_ORIGIN,
    "Access-Control-Allow-Headers": "authorization, content-type, apikey",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Vary": "Origin",
  };
  if (req.method === "OPTIONS") return new Response("ok", { headers: cors });

  const authHeader = req.headers.get("authorization") ?? "";
  const jwt = authHeader.replace(/^Bearer\s+/i, "");
  if (!jwt) {
    return new Response(JSON.stringify({ error: "Sign in required." }), {
      status: 401,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  // Verify the caller's own session (anon key + their JWT — never the
  // service key for this step).
  const asUser = createClient(SUPABASE_URL, Deno.env.get("SUPABASE_ANON_KEY")!, {
    global: { headers: { Authorization: `Bearer ${jwt}` } },
  });
  const { data: userData, error: userErr } = await asUser.auth.getUser(jwt);
  if (userErr || !userData?.user) {
    return new Response(JSON.stringify({ error: "Sign in required." }), {
      status: 401,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }
  const userId = userData.user.id;

  if (limited(userId)) {
    return new Response(JSON.stringify({ error: "Too many requests. Try again in a minute." }), {
      status: 429,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
    db: { schema: "app_after" },
  });

  // tenant_id comes from the caller's OWN profile row — never from the body.
  const { data: profile, error: profileErr } = await admin
    .from("profiles")
    .select("tenant_id, email")
    .eq("id", userId)
    .single();
  if (profileErr || !profile?.tenant_id) {
    const ref = crypto.randomUUID();
    console.error(`[support-ticket ${ref}]`, profileErr);
    return new Response(JSON.stringify({ error: "Could not submit the ticket.", ref }), {
      status: 400,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  const body = await req.json().catch(() => ({}));
  const message = typeof body.message === "string" ? body.message.slice(0, 2000) : "";
  if (!message.trim()) {
    return new Response(JSON.stringify({ error: "Message is required." }), {
      status: 400,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  const { data, error } = await admin
    .from("support_tickets")
    .insert({ tenant_id: profile.tenant_id, email: profile.email, message })
    .select()
    .single();

  if (error) {
    const ref = crypto.randomUUID();
    console.error(`[support-ticket ${ref}]`, error);
    return new Response(JSON.stringify({ error: "Could not submit the ticket.", ref }), {
      status: 500,
      headers: { ...cors, "Content-Type": "application/json" },
    });
  }

  return new Response(JSON.stringify({ ok: true, ticket: { id: data.id } }), {
    status: 200,
    headers: { ...cors, "Content-Type": "application/json" },
  });
});
Medium

E-012 · Missing security headers (no CSP / X-Frame-Options / nosniff)

Exploit E-012 — before EXPLOITED no CSP, X-Frame-Options, or X-Content-Type-Options on the served app
after blocked CSP: present; X-Frame-Options: DENY; X-Content-Type-Options: nosniff
Before / after — Vite preview config (headers)
before/ — vulnerable
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: { port: 5173 },
  preview: { port: 3101 },
})
after/ — hardened
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// F-012 fix: security headers on every response. `before/` sets none of
// these (see its vite.config.js) — this plugin only exists in `after/`.
function securityHeaders() {
  return {
    name: 'tallgrass-security-headers',
    configurePreviewServer(server) {
      server.middlewares.use((_req, res, next) => {
        res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://127.0.0.1:54321 ws://127.0.0.1:54321; frame-ancestors 'none'");
        res.setHeader('X-Content-Type-Options', 'nosniff');
        res.setHeader('X-Frame-Options', 'DENY');
        res.setHeader('Referrer-Policy', 'no-referrer');
        res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
        next();
      });
    },
  };
}

export default defineConfig({
  plugins: [react(), securityHeaders()],
  server: { port: 5174 },
  preview: { port: 3102 },
})
Low

E-013 · N+1 queries on the orders dashboard (one certs query per order)

Exploit E-013 — before EXPLOITED 6 orders loaded -> 6 certs queries
after blocked 6 orders loaded -> 1 certs query
Before / after — Orders dashboard query
before/ — vulnerable
import { useEffect, useState } from 'react'
import { supabase } from '../supabase'

export default function Orders({ isStaff }) {
  const [rows, setRows] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState('')

  useEffect(() => {
    supabase
      .from('orders')
      .select('id, order_number, part_number, qty, due_date, status, tenants(name)')
      .order('due_date', { ascending: true })
      .then(async ({ data, error }) => {
        if (error) { setError(error.message); setLoading(false); return }
        // F-013: one certs query PER ORDER instead of one batched query — the
        // exploit measures this by counting /rest/v1/certs requests over the
        // wire while this page loads (tests/exploits/exploits.mjs, E-013).
        const withCerts = await Promise.all(
          (data || []).map(async (o) => {
            const { count } = await supabase
              .from('certs')
              .select('id', { count: 'exact', head: true })
              .eq('order_number', o.order_number)
            return { ...o, certCount: count ?? 0 }
          })
        )
        setRows(withCerts)
        setLoading(false)
      })
  }, [])

  return (
    <section>
      <h2>Orders</h2>
      {error && <p className="error">{error}</p>}
      {loading ? (
        <p>Loading…</p>
      ) : rows.length === 0 ? (
        <p className="muted">No orders yet.</p>
      ) : (
        <table>
          <thead>
            <tr>
              {isStaff && <th>Company</th>}
              <th>Order #</th>
              <th>Part #</th>
              <th>Qty</th>
              <th>Due date</th>
              <th>Status</th>
              <th>Certs</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((o) => (
              <tr key={o.id}>
                {isStaff && <td>{o.tenants?.name}</td>}
                <td>{o.order_number}</td>
                <td>{o.part_number}</td>
                <td>{o.qty}</td>
                <td>{o.due_date}</td>
                <td><span className={'badge s-' + o.status.replace(/\s+/g, '').toLowerCase()}>{o.status}</span></td>
                <td>{o.certCount}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </section>
  )
}
after/ — hardened
import { useEffect, useState } from 'react'
import { supabase } from '../supabase'

export default function Orders({ isStaff }) {
  const [rows, setRows] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState('')

  useEffect(() => {
    supabase
      .from('orders')
      .select('id, order_number, part_number, qty, due_date, status, tenants(name)')
      .order('due_date', { ascending: true })
      .then(async ({ data, error }) => {
        if (error) { setError(error.message); setLoading(false); return }
        // F-013 fix: ONE batched query for all orders' cert counts, grouped
        // client-side, instead of one query per order.
        const orderNumbers = (data || []).map((o) => o.order_number)
        const { data: certRows } = await supabase
          .from('certs')
          .select('order_number')
          .in('order_number', orderNumbers.length ? orderNumbers : ['__none__'])
        const counts = {}
        for (const c of certRows || []) counts[c.order_number] = (counts[c.order_number] || 0) + 1
        setRows((data || []).map((o) => ({ ...o, certCount: counts[o.order_number] || 0 })))
        setLoading(false)
      })
  }, [])

  return (
    <section>
      <h2>Orders</h2>
      {error && <p className="error">{error}</p>}
      {loading ? (
        <p>Loading…</p>
      ) : rows.length === 0 ? (
        <p className="muted">No orders yet.</p>
      ) : (
        <table>
          <thead>
            <tr>
              {isStaff && <th>Company</th>}
              <th>Order #</th>
              <th>Part #</th>
              <th>Qty</th>
              <th>Due date</th>
              <th>Status</th>
              <th>Certs</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((o) => (
              <tr key={o.id}>
                {isStaff && <td>{o.tenants?.name}</td>}
                <td>{o.order_number}</td>
                <td>{o.part_number}</td>
                <td>{o.qty}</td>
                <td>{o.due_date}</td>
                <td><span className={'badge s-' + o.status.replace(/\s+/g, '').toLowerCase()}>{o.status}</span></td>
                <td>{o.certCount}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </section>
  )
}

Functional tests (hardened app still works)

IDTestResultEvidence
FN-1Valid customer login succeedsPASSgot access_token
FN-2Wrong password is rejectedPASSlogin rejected
FN-3Customer sees only their own company's ordersPASS3 orders, tenants: 1
FN-4Staff sees all tenants' ordersPASS6 orders across 2 tenants
FN-5Customer can open a new RMA for their own orderPASSinsert HTTP 201
FN-6Customer cannot see another tenant's RMAs (filtered, not an error)PASS5 RMAs visible, all own-tenant: true
FN-7Customer can submit a support ticket (edge function, authenticated)PASSHTTP 200 {"ok":true,"ticket":{"id":"47d8040c-7125-4835-9ced-17c0e16d20d5"}}
FN-8Orders dashboard query (with cert counts) returns data with no errorsPASS6 orders, 2 certs, no errors

RLS lint — app_before vs app_after

RLS lint — app_before vs app_after

_Lists every table in both schemas, whether Row-Level Security is enabled, and how many policies exist. A table with RLS off, or RLS on with zero policies but grants to anon/authenticated, is a finding. Generated by scripts/rls-lint.mjs against the local stack._

SchemaTableRLS enabledPoliciesVerdict
app_beforecertstrue2OK
app_beforeorderstrue3OK
app_beforeprofilestrue2OK
app_beforermasfalse0NO RLS — every row readable/writable via the anon key
app_beforesupport_ticketstrue0RLS on, 0 policies (deny-by-default — safe)
app_beforetenantstrue1OK
app_aftercertstrue2OK
app_afterorderstrue2OK
app_afterprofilestrue2OK
app_afterrmastrue3OK
app_aftersupport_ticketstrue0RLS on, 0 policies (deny-by-default — safe)
app_aftertenantstrue1OK

Summary: app_before has 1 table(s) with RLS disabled. app_after has 0.

Limitation, stated plainly: this lint only checks whether RLS is *on* and whether at least one policy exists — it cannot tell a real per-tenant policy from a using (true) policy that grants every row to every caller. app_before.orders and app_before.tenants both show "OK" here despite F-002 and F-009 — the policies exist, they are just permissive. That gap is exactly why this audit also runs a live exploit per finding (tests/exploits/exploits.mjs) instead of trusting a lint pass alone.

Real renders