From 93a1b293d1fd071e8d6af4969009e773dbd56572 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 03:12:34 +0000 Subject: [PATCH 01/11] feat(inventory Phase 1): schema + RLS + audit/immutability triggers Source-of-truth migration (inventory/sql/phase1.sql) per spec.md, to be run by the owner in the Supabase SQL editor. - Tables: profiles, items, field_definitions, change_log. - current_user_role(): SECURITY DEFINER, SET search_path='', no params, returns role for auth.uid() only; EXECUTE to authenticated (not anon). - handle_new_user trigger -> auto-provision profile default 'viewer'; one-time admin backfill/elevate. - log_item_change audit trigger -> who/what/when field-level before->after. - change_log immutability: RLS (no UPDATE/DELETE policy) + BEFORE U/D trigger that RAISEs (blocks even the privileged owner). - RLS ENABLE (not FORCE) on every table; least-privilege policies scoped to role AND operation; no USING(true); anon default-denied everywhere. Owner-approved decisions: no client INSERT on change_log; single category column; status text+CHECK; changed_at rename; GIN index deferred to Phase 5. https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/sql/phase1.sql | 319 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 inventory/sql/phase1.sql diff --git a/inventory/sql/phase1.sql b/inventory/sql/phase1.sql new file mode 100644 index 0000000000..ae76cc67d2 --- /dev/null +++ b/inventory/sql/phase1.sql @@ -0,0 +1,319 @@ +-- ============================================================================= +-- Inventory — Phase 1: Schema + RLS + audit trigger +-- Source-of-truth migration. Run ONCE in the Supabase SQL editor (owner/postgres). +-- Idempotent: safe to re-run (create-or-replace / drop-if-exists / if-not-exists). +-- +-- Governing contract: inventory/spec.md, inventory/CLAUDE.md, inventory/PROGRESS.md. +-- Owner-approved decisions baked in (2026-05-31): +-- 1. change_log has NO client INSERT policy — the SECURITY DEFINER trigger writes only. +-- 2. items.category is a SINGLE column (source category/section merged at seed time). +-- 3. items.status is text + CHECK (evolvable without a DDL-heavy enum migration). +-- 4. change_log timestamp column is named changed_at (timestamp is reserved). +-- 5. GIN index on items.custom_fields is DEFERRED to Phase 5 (where search uses it). +-- 6. Immutability is two-layered: RLS (no UPDATE/DELETE policy) for clients + +-- a BEFORE UPDATE/DELETE trigger that RAISEs (blocks even the privileged owner). +-- +-- Security model: RLS is ENABLED (not FORCED) on every table so that the SECURITY +-- DEFINER trigger functions (owned by postgres) can write while every client +-- (anon + authenticated) remains fully subject to RLS. anon is granted NO policy +-- anywhere -> default-deny -> anon reads return []. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 0. Types +-- ----------------------------------------------------------------------------- +-- Role: a small, rarely-changing set -> native enum is appropriate. +do $$ +begin + if not exists (select 1 from pg_type where typname = 'user_role') then + create type public.user_role as enum ('viewer', 'editor', 'admin'); + end if; +end +$$; + +-- Status: text + CHECK (owner decision #3 — easy to extend, e.g. add 'Lent Out', +-- with a single ALTER ... DROP/ADD CONSTRAINT, no enum migration). +-- The canonical Phase-1 status vocabulary (spec.md "Status enum"): +-- Active/In Use, In Storage, Needs Attention, In Repair, +-- Retired (Archived), Disposed, Missing + +-- ----------------------------------------------------------------------------- +-- 1. Tables +-- ----------------------------------------------------------------------------- + +-- profiles: the SINGLE SOURCE of role, read by both the UI (via rpc) and RLS +-- (via current_user_role()). One row per auth user. +create table if not exists public.profiles ( + user_id uuid primary key references auth.users (id) on delete cascade, + display_name text, + role public.user_role not null default 'viewer' +); + +-- items: stable surrogate PK, never reused/renumbered. "generated by default" +-- lets the Phase-3 seed set explicit ids 1..211 (skipping #8) and the sequence +-- continues afterward. +create table if not exists public.items ( + id bigint generated by default as identity primary key, + name text, + brand text, + model_pn text, + qty integer, + device_type text, + category text, -- decision #2: single column + status text check ( + status in ( + 'Active/In Use', 'In Storage', 'Needs Attention', + 'In Repair', 'Retired (Archived)', 'Disposed', 'Missing' + ) + ), -- decision #3: text + CHECK (NULL allowed) + location text, + assignment_purpose text, + notes text, + value numeric(12, 2), + serial text, + is_archived boolean not null default false, + custom_fields jsonb not null default '{}'::jsonb, + version integer not null default 1, -- optimistic lock (used in Phase 4) + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- field_definitions: typed dynamic fields (admin-managed). A row = the field +-- appears on every item. "Removing" = is_active=false (never hard-delete data). +create table if not exists public.field_definitions ( + key text primary key, + label text not null, + type text not null check (type in ('text', 'number', 'date', 'dropdown', 'boolean')), + options text[], + required boolean not null default false, + is_active boolean not null default true, + created_by uuid references auth.users (id), + created_at timestamptz not null default now() +); + +-- change_log: immutable who/what/when audit trail. Trigger-written only. +create table if not exists public.change_log ( + id bigint generated always as identity primary key, + item_id bigint references public.items (id), + actor uuid, -- auth.uid() of the real caller (see audit trigger) + action text not null, -- 'INSERT' | 'UPDATE' + field text, -- changed column (NULL for INSERT) + old_value text, + new_value text, + changed_at timestamptz not null default now() -- decision #4: renamed from "timestamp" +); + +-- ----------------------------------------------------------------------------- +-- 2. Role function (single source for RLS + UI). Owner-approved Option 1. +-- SECURITY DEFINER + empty search_path: runs as owner so it can read profiles +-- regardless of profiles' self-read RLS, AND bypasses RLS so it does NOT +-- re-enter the profiles policy (avoids "infinite recursion in policy"). +-- EXECUTE granted to authenticated ONLY (never anon). +-- ----------------------------------------------------------------------------- +create or replace function public.current_user_role() +returns public.user_role +language sql +stable +security definer +set search_path = '' +as $$ + select role from public.profiles where user_id = auth.uid() +$$; + +revoke all on function public.current_user_role() from public, anon; +grant execute on function public.current_user_role() to authenticated; + +-- ----------------------------------------------------------------------------- +-- 3. New-user provisioning: auto-create a profile (default 'viewer') for every +-- new auth user. SECURITY DEFINER so the insert is not blocked by profiles RLS. +-- ----------------------------------------------------------------------------- +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + insert into public.profiles (user_id, display_name, role) + values ( + new.id, + coalesce(new.raw_user_meta_data ->> 'display_name', new.email), + 'viewer'::public.user_role + ) + on conflict (user_id) do nothing; + return new; +end +$$; + +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); + +-- One-time backfill: the admin user was created in the dashboard BEFORE this +-- trigger existed, so provision + elevate it now. Idempotent. +insert into public.profiles (user_id, display_name, role) +select id, coalesce(raw_user_meta_data ->> 'display_name', email), 'admin'::public.user_role +from auth.users +where lower(email) = lower('addisonstainback@gmail.com') +on conflict (user_id) do update set role = 'admin'::public.user_role; + +-- ----------------------------------------------------------------------------- +-- 4. updated_at maintenance (BEFORE UPDATE on items). +-- ----------------------------------------------------------------------------- +create or replace function public.tg_set_updated_at() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + new.updated_at = now(); + return new; +end +$$; + +drop trigger if exists items_set_updated_at on public.items; +create trigger items_set_updated_at + before update on public.items + for each row execute function public.tg_set_updated_at(); + +-- ----------------------------------------------------------------------------- +-- 5. Audit trigger: write who/what/when (field-level before->after) into +-- change_log on item INSERT/UPDATE. SECURITY DEFINER so it can write to the +-- client-locked change_log; auth.uid() still resolves to the REAL caller. +-- ----------------------------------------------------------------------------- +create or replace function public.log_item_change() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + uid uuid := auth.uid(); +begin + if tg_op = 'INSERT' then + insert into public.change_log (item_id, actor, action, field, old_value, new_value) + values (new.id, uid, 'INSERT', null, null, null); + return new; + end if; + + -- UPDATE: one row per changed field (IS DISTINCT FROM handles NULLs). + if new.name is distinct from old.name then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'name', old.name, new.name); end if; + if new.brand is distinct from old.brand then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'brand', old.brand, new.brand); end if; + if new.model_pn is distinct from old.model_pn then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'model_pn', old.model_pn, new.model_pn); end if; + if new.qty is distinct from old.qty then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'qty', old.qty::text, new.qty::text); end if; + if new.device_type is distinct from old.device_type then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'device_type', old.device_type, new.device_type); end if; + if new.category is distinct from old.category then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'category', old.category, new.category); end if; + if new.status is distinct from old.status then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'status', old.status, new.status); end if; + if new.location is distinct from old.location then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'location', old.location, new.location); end if; + if new.assignment_purpose is distinct from old.assignment_purpose then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'assignment_purpose', old.assignment_purpose, new.assignment_purpose); end if; + if new.notes is distinct from old.notes then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'notes', old.notes, new.notes); end if; + if new.value is distinct from old.value then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'value', old.value::text, new.value::text); end if; + if new.serial is distinct from old.serial then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'serial', old.serial, new.serial); end if; + if new.is_archived is distinct from old.is_archived then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'is_archived', old.is_archived::text, new.is_archived::text); end if; + if new.custom_fields is distinct from old.custom_fields then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'custom_fields', old.custom_fields::text, new.custom_fields::text); end if; + + return new; +end +$$; + +drop trigger if exists items_audit on public.items; +create trigger items_audit + after insert or update on public.items + for each row execute function public.log_item_change(); + +-- ----------------------------------------------------------------------------- +-- 6. Immutability guard for change_log (decision #6, layer 2). +-- Fires for EVERYONE including the owner -> blocks privileged tampering that +-- RLS alone cannot (owner bypasses RLS). This is the guard Phase-1 proof #2 +-- toggles inside a rolled-back transaction. +-- ----------------------------------------------------------------------------- +create or replace function public.change_log_no_mutate() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + raise exception 'change_log is immutable (no UPDATE/DELETE allowed)'; +end +$$; + +drop trigger if exists change_log_immutable on public.change_log; +create trigger change_log_immutable + before update or delete on public.change_log + for each row execute function public.change_log_no_mutate(); + +-- ----------------------------------------------------------------------------- +-- 7. Row-Level Security: ENABLE on every table; least-privilege policies scoped +-- to a specific role AND operation. No `USING (true)` anywhere. +-- "Any authenticated user can read items" is expressed as a role-gate +-- (current_user_role() in (...)), NOT a bare permissive true: an authenticated +-- user with no role row gets nothing. +-- ----------------------------------------------------------------------------- +alter table public.profiles enable row level security; +alter table public.items enable row level security; +alter table public.field_definitions enable row level security; +alter table public.change_log enable row level security; + +-- profiles: self-read; admin reads all; admin manages roles. No client INSERT +-- (handle_new_user writes). No DELETE (cascades from auth.users). +drop policy if exists profiles_self_read on public.profiles; +drop policy if exists profiles_admin_read on public.profiles; +drop policy if exists profiles_admin_write on public.profiles; +create policy profiles_self_read on public.profiles + for select to authenticated + using (user_id = auth.uid()); +create policy profiles_admin_read on public.profiles + for select to authenticated + using (public.current_user_role() = 'admin'::public.user_role); +create policy profiles_admin_write on public.profiles + for update to authenticated + using (public.current_user_role() = 'admin'::public.user_role) + with check (public.current_user_role() = 'admin'::public.user_role); + +-- items: SELECT = any authenticated user WITH a role; INSERT/UPDATE = editor/admin; +-- no DELETE policy (archive via is_archived). +drop policy if exists items_select on public.items; +drop policy if exists items_insert on public.items; +drop policy if exists items_update on public.items; +create policy items_select on public.items + for select to authenticated + using (public.current_user_role() in ('viewer'::public.user_role, 'editor'::public.user_role, 'admin'::public.user_role)); +create policy items_insert on public.items + for insert to authenticated + with check (public.current_user_role() in ('editor'::public.user_role, 'admin'::public.user_role)); +create policy items_update on public.items + for update to authenticated + using (public.current_user_role() in ('editor'::public.user_role, 'admin'::public.user_role)) + with check (public.current_user_role() in ('editor'::public.user_role, 'admin'::public.user_role)); + +-- field_definitions: SELECT = authenticated (with role); INSERT/UPDATE/DELETE = admin only. +drop policy if exists field_definitions_select on public.field_definitions; +drop policy if exists field_definitions_insert on public.field_definitions; +drop policy if exists field_definitions_update on public.field_definitions; +drop policy if exists field_definitions_delete on public.field_definitions; +create policy field_definitions_select on public.field_definitions + for select to authenticated + using (public.current_user_role() in ('viewer'::public.user_role, 'editor'::public.user_role, 'admin'::public.user_role)); +create policy field_definitions_insert on public.field_definitions + for insert to authenticated + with check (public.current_user_role() = 'admin'::public.user_role); +create policy field_definitions_update on public.field_definitions + for update to authenticated + using (public.current_user_role() = 'admin'::public.user_role) + with check (public.current_user_role() = 'admin'::public.user_role); +create policy field_definitions_delete on public.field_definitions + for delete to authenticated + using (public.current_user_role() = 'admin'::public.user_role); + +-- change_log: SELECT = editor/admin (viewer NOT by default). NO INSERT policy +-- (definer trigger writes). NO UPDATE/DELETE policy (immutable). +drop policy if exists change_log_select on public.change_log; +create policy change_log_select on public.change_log + for select to authenticated + using (public.current_user_role() in ('editor'::public.user_role, 'admin'::public.user_role)); + +-- ============================================================================= +-- End Phase 1 schema. Run inventory/sql/proofs/phase1_proofs.sql next, then the +-- anon checks in inventory/sql/proofs/anon_checks.md. +-- ============================================================================= From 860e4d7fcb48fdc29689db99a59febb974b79b39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 03:15:40 +0000 Subject: [PATCH 02/11] test(inventory Phase 1): proof scripts + run guide - proofs/phase1_proofs.sql: privileged-path immutability proofs (#1a UPDATE, #1b DELETE blocked) + broken-vs-fixed (#2: guard disabled -> tamper succeeds), all inside one BEGIN..ROLLBACK so no artifacts persist; returns a results table. - proofs/anon_checks.md: proof #3 (anon reads return []) + proof #1 client path (logged-in user refused by RLS). Public anon key referenced via env var only. - RUN.md: run order + owner/Claude responsibility split. https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/sql/RUN.md | 37 +++++++++ inventory/sql/proofs/anon_checks.md | 103 +++++++++++++++++++++++++ inventory/sql/proofs/phase1_proofs.sql | 76 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 inventory/sql/RUN.md create mode 100644 inventory/sql/proofs/anon_checks.md create mode 100644 inventory/sql/proofs/phase1_proofs.sql diff --git a/inventory/sql/RUN.md b/inventory/sql/RUN.md new file mode 100644 index 0000000000..da8fea5f50 --- /dev/null +++ b/inventory/sql/RUN.md @@ -0,0 +1,37 @@ +# Inventory SQL — run order & responsibilities + +All SQL here is **source-of-truth** authored in the repo. DDL is **run by the +owner** in the Supabase SQL editor (Claude has no privileged DB access; +`service_role` is forbidden). Claude verifies *behavior* afterward with the +public anon key. + +## Phase 1 + +1. **`phase1.sql`** — owner pastes the whole file into the Supabase **SQL editor** + and runs it once. Idempotent (safe to re-run). Creates tables, RLS, the + `current_user_role()` function, and the audit + immutability triggers. + - After it runs, confirm: `select user_id, role from public.profiles;` + should show the admin (`addisonstainback@gmail.com`) with role `admin`. + +2. **`proofs/phase1_proofs.sql`** — owner runs in the SQL editor. Returns a + results table; every row should read `PASSED…` (proves change_log immutability + + the broken-vs-fixed demonstration). Runs in a transaction that rolls back — + leaves no artifacts. + +3. **`proofs/anon_checks.md`** — proof #3 (anon returns `[]`) and proof #1 client + path (logged-in user refused by RLS). Claude runs proof #3 to show observed + output; the owner/auditor re-runs as the authority (per spec's + "EXTERNAL CHECK (owner/auditor-run)"). + +Phase 1 gate is **passed** only when all three proofs show the expected observed +output. Until then PROGRESS.md keeps Phase 1 as `[ ]`. + +## Test users (create AFTER step 1) + +Once `phase1.sql` has run, the `handle_new_user` trigger will auto-create a +`profiles` row (default `viewer`) for any new user. Create in the dashboard: + +- a **Viewer** test user (leave role = `viewer`), +- an **Editor** test user, then elevate it: + `update public.profiles set role='editor' where user_id = + (select id from auth.users where lower(email)=lower('EDITOR_EMAIL'));` diff --git a/inventory/sql/proofs/anon_checks.md b/inventory/sql/proofs/anon_checks.md new file mode 100644 index 0000000000..509f5dd51a --- /dev/null +++ b/inventory/sql/proofs/anon_checks.md @@ -0,0 +1,103 @@ +# Phase 1 proofs — client/anon path (run from a terminal with `curl`) + +These cover the two parts of Phase 1's proof set that do **not** run in the SQL +editor: + +- **Proof #3** — unauthenticated (anon) reads return nothing on every sensitive + table. +- **Proof #1 (client path)** — a real logged-in user is **refused by RLS** when + trying to UPDATE/DELETE `change_log` (returns 0 rows; the literal immutability + error is shown by the privileged path in `phase1_proofs.sql`). + +> **Key hygiene:** the value below is the **publishable** (anon) key — public by +> design, safe in client code. It is NOT a secret. **Never** put the +> `service_role` / `sb_secret_…` key in any command. Set the public key in your +> shell so it isn't pasted around: +> +> ```bash +> export SUPABASE_URL="https://mdtbgreryqddloluhdmm.supabase.co" +> export ANON="sb_publishable_…" # your publishable key +> ``` + +--- + +## Proof #3 — anon returns nothing (RLS default-deny) + +```bash +for t in items profiles field_definitions change_log; do + printf '== %s ==\n' "$t" + curl -s "$SUPABASE_URL/rest/v1/$t?select=*" \ + -H "apikey: $ANON" -H "Authorization: Bearer $ANON" + printf '\n' +done +``` + +**Expected:** each table prints `[]` (an empty JSON array). With RLS ON and no +policy granting the `anon` role, every row is filtered out — the safe default the +spec wants ("RLS on and no policy = inaccessible"). + +> Why `[]` and not `401`: Supabase grants `anon` table-level access by default and +> relies on RLS as the gate, so the request succeeds but returns zero rows. An +> optional Phase-7 hardening can additionally `REVOKE` anon table privileges +> (turning these into outright permission errors); not needed for the Phase-1 gate. + +--- + +## Proof #1 (client path) — logged-in user cannot mutate change_log + +Needs (1) a change_log row to target and (2) a user access token. + +**Step 1 — get a user JWT** (you have the admin password; never log the token): + +```bash +ACCESS=$(curl -s "$SUPABASE_URL/auth/v1/token?grant_type=password" \ + -H "apikey: $ANON" -H "Content-Type: application/json" \ + -d '{"email":"addisonstainback@gmail.com","password":"YOUR_ADMIN_PASSWORD"}' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') +``` + +**Step 2 — confirm the session reads its own role** (sanity: single-source role): + +```bash +curl -s "$SUPABASE_URL/rest/v1/rpc/current_user_role" -X POST \ + -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" +# expected: "admin" +``` + +**Step 3 — find a change_log row id** (admin can SELECT change_log): + +```bash +curl -s "$SUPABASE_URL/rest/v1/change_log?select=id,item_id,action&limit=1" \ + -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS" +# note an id, call it . (If empty, first insert/edit an item as admin so the +# audit trigger writes a row.) +``` + +**Step 4 — attempt to mutate it (must be refused):** + +```bash +# UPDATE +curl -s -X PATCH "$SUPABASE_URL/rest/v1/change_log?id=eq." \ + -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS" \ + -H "Content-Type: application/json" -H "Prefer: return=representation" \ + -d '{"action":"TAMPERED"}' +# expected: [] (RLS has no UPDATE policy -> row filtered -> 0 rows changed) + +# DELETE +curl -s -X DELETE "$SUPABASE_URL/rest/v1/change_log?id=eq." \ + -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS" \ + -H "Prefer: return=representation" +# expected: [] (RLS has no DELETE policy -> 0 rows deleted) +``` + +**Step 5 — confirm nothing changed:** + +```bash +curl -s "$SUPABASE_URL/rest/v1/change_log?id=eq.&select=id,action" \ + -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS" +# expected: the row, with its ORIGINAL action (e.g. "INSERT") — unchanged. +``` + +The client is refused by the database (RLS), not by any UI. The explicit +`change_log is immutable` error for the privileged path is shown by +`phase1_proofs.sql`. diff --git a/inventory/sql/proofs/phase1_proofs.sql b/inventory/sql/proofs/phase1_proofs.sql new file mode 100644 index 0000000000..e7436d6fcf --- /dev/null +++ b/inventory/sql/proofs/phase1_proofs.sql @@ -0,0 +1,76 @@ +-- ============================================================================= +-- Inventory — Phase 1 proofs (SQL editor / privileged owner path) +-- Run this AFTER inventory/sql/phase1.sql, in the Supabase SQL editor. +-- +-- Proves (privileged/owner path): +-- #1a UPDATE on change_log is blocked by the immutability trigger. +-- #1b DELETE on change_log is blocked by the immutability trigger. +-- #2 Broken-vs-fixed: with the guard DISABLED the tamper SUCCEEDS (protection +-- fails), proving the trigger is the thing stopping it. The whole proof +-- runs in one transaction and ROLLS BACK -> guard restored, no artifacts. +-- +-- The client-session path of proof #1 (a logged-in user is refused by RLS) and +-- proof #3 (anon returns nothing) are in inventory/sql/proofs/anon_checks.md. +-- +-- HOW TO READ THE OUTPUT: this returns a single results table. Every row whose +-- result starts with "PASSED" is a satisfied assertion. A row starting "FAILED" +-- means immutability is NOT enforced -> STOP, do not mark the gate passed. +-- ============================================================================= + +begin; + +create temporary table _proof_results (seq int, step text, result text) on commit drop; + +-- SETUP: a throwaway item INSERT fires the audit trigger -> a change_log row to attack. +insert into public.items (name, status) values ('__phase1_proof_item__', 'In Storage'); + +do $$ +declare + v_item bigint; + v_log bigint; + v_action text; +begin + select id into v_item from public.items where name = '__phase1_proof_item__'; + select id into v_log from public.change_log where item_id = v_item order by id limit 1; + insert into _proof_results values + (0, 'setup', format('throwaway item id=%s wrote change_log row id=%s (audit trigger works)', v_item, v_log)); + + -- ---- PROOF #1a: UPDATE blocked ------------------------------------------------ + begin + update public.change_log set action = 'TAMPERED' where id = v_log; + insert into _proof_results values (1, '#1a UPDATE change_log', 'FAILED: update SUCCEEDED (immutability NOT enforced!)'); + exception when others then + insert into _proof_results values (1, '#1a UPDATE change_log', 'PASSED: blocked -> ' || sqlerrm); + end; + + -- ---- PROOF #1b: DELETE blocked ------------------------------------------------ + begin + delete from public.change_log where id = v_log; + insert into _proof_results values (2, '#1b DELETE change_log', 'FAILED: delete SUCCEEDED (immutability NOT enforced!)'); + exception when others then + insert into _proof_results values (2, '#1b DELETE change_log', 'PASSED: blocked -> ' || sqlerrm); + end; + + -- ---- PROOF #2: broken-vs-fixed ----------------------------------------------- + -- "Break" the guard: disable the immutability trigger. The same tamper that was + -- blocked above now succeeds -> proves the trigger is the protection. The outer + -- ROLLBACK below re-enables the trigger (DDL is transactional) and undoes the + -- tamper + the throwaway item, so nothing persists. + alter table public.change_log disable trigger change_log_immutable; + update public.change_log set action = 'TAMPERED-DEMO' where id = v_log; + select action into v_action from public.change_log where id = v_log; + insert into _proof_results values + (3, '#2 broken (guard DISABLED)', 'tamper SUCCEEDED, action now = ' || coalesce(v_action, '') || ' (=> the trigger is what stops tampering)'); +end +$$; + +select seq, step, result from _proof_results order by seq; + +rollback; -- restores the immutability trigger; discards throwaway item, log row, and tamper + +-- After this script, immutability is back in force. Re-running it is safe (idempotent). +-- Expected result rows: +-- 0 setup throwaway item ... wrote change_log row ... (audit trigger works) +-- 1 #1a UPDATE change_log PASSED: blocked -> change_log is immutable (no UPDATE/DELETE allowed) +-- 2 #1b DELETE change_log PASSED: blocked -> change_log is immutable (no UPDATE/DELETE allowed) +-- 3 #2 broken (guard DISABLED) tamper SUCCEEDED, action now = TAMPERED-DEMO (=> the trigger is what stops tampering) From 981698dafc7eb263dead37d71eb58d9dcc48df18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 03:33:25 +0000 Subject: [PATCH 03/11] fix(inventory Phase 1): DROP current_user_role() before recreate (42P13) CREATE OR REPLACE cannot change a function's return type. A prior current_user_role() (different return type) existed, so the migration failed at section 2. Add DROP FUNCTION IF EXISTS ... CASCADE before the create so the script is fully idempotent across return-type changes; section 7 recreates the dependent policies regardless. https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/sql/phase1.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/inventory/sql/phase1.sql b/inventory/sql/phase1.sql index ae76cc67d2..5311170091 100644 --- a/inventory/sql/phase1.sql +++ b/inventory/sql/phase1.sql @@ -110,6 +110,12 @@ create table if not exists public.change_log ( -- re-enter the profiles policy (avoids "infinite recursion in policy"). -- EXECUTE granted to authenticated ONLY (never anon). -- ----------------------------------------------------------------------------- +-- A prior version of this function may exist with a different return type; +-- CREATE OR REPLACE cannot change a function's return type (Postgres 42P13), so +-- drop first. CASCADE also removes any policies that referenced the old version +-- (e.g. from a partial earlier run); section 7 below recreates the correct ones. +drop function if exists public.current_user_role() cascade; + create or replace function public.current_user_role() returns public.user_role language sql From 75976aea001f75fbe3b763934c708aa4133d4206 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 04:35:01 +0000 Subject: [PATCH 04/11] =?UTF-8?q?docs(inventory):=20Phase=201=20gate=20PAS?= =?UTF-8?q?SED=20=E2=80=94=20mark=200b=20+=20Phase=201=20[x]=20with=20evid?= =?UTF-8?q?ence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three proofs landed with observed output: - #3 anon reads all [] (+ RPC 42501 permission denied) — Claude ran - A: SQL-editor immutability all PASSED incl. broken-vs-fixed — owner ran - B: client editor session PATCH/DELETE change_log -> [] / [] / unchanged — Claude ran Phase 0b [x] (URL + publishable key delivered, admin user created). Decisions log updated with Phase 1 schema decisions + pre-Phase-3 proof-item cleanup follow-up. Phase 2 stays [ ]. https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/PROGRESS.md | 47 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/inventory/PROGRESS.md b/inventory/PROGRESS.md index c61907354b..85fbfa1252 100644 --- a/inventory/PROGRESS.md +++ b/inventory/PROGRESS.md @@ -6,7 +6,16 @@ Status: [ ] todo · [~] doing · [x] gate passed (record HOW verified — eviden - Backend: Supabase, USA region, owner-owned. Connector-first; CLI fallback. - Archive over delete. $0 target; anon read-only heartbeat to prevent pause; manual + scheduled export. -- service_role key: forbidden everywhere. (append decisions as we go) +- service_role key: forbidden everywhere. +- Phase 1 schema (2026-05-31, owner-approved): change_log has NO client INSERT policy (SECURITY + DEFINER trigger writes only); items.category is a single column; items.status is text+CHECK (not + enum); change_log timestamp column renamed changed_at; GIN index on custom_fields deferred to + Phase 5; immutability is two-layered (RLS no-UPDATE/DELETE + BEFORE U/D trigger that RAISEs); + RLS ENABLE (not FORCE) so SECURITY DEFINER triggers can write. +- Pre-Phase-3 cleanup (known follow-up): proof runs left throwaway item `__client_proof_item__` + (id=2) + its immutable change_log row. Before the Phase 3 seed (explicit ids 1..211) the owner + removes them in the SQL editor (disable change_log_immutable trigger -> delete proof change_log + row + item -> re-enable) to avoid an id=2 PK collision. (append decisions as we go) ## Evidence rule @@ -19,15 +28,21 @@ recorded evidence — re-verify instead. Doc changes happen only via an owner-ap your environment's planning/effort capabilities; current Supabase free-tier limits; region=USA); draft CLAUDE.md + spec.md + PROGRESS.md; give plain Supabase setup steps. GATE: owner approves docs + resolved items. -- [ ] Phase 0b — Supabase live (owner): create project; turn ON "Enable RLS on new tables"; provide +- [x] Phase 0b — Supabase live (owner): create project; turn ON "Enable RLS on new tables"; provide project URL + anon key. GATE: Claude confirms it can reach Supabase with the anon key. service_role key NOT shared. -- [ ] Phase 1 — Schema + RLS + audit trigger. + EVIDENCE (2026-05-31): Project URL + publishable (anon) key delivered; admin user created in the + dashboard; Claude reached the REST API with the anon key (anon reads return [] — see Phase 1 + evidence appendix). service_role NOT shared; publishable key is the public low-privilege key. +- [x] Phase 1 — Schema + RLS + audit trigger. GATE: every table RLS-ON, default-deny, NO permissive/`USING(true)` policies, least-privilege; change_log immutable; trigger writes who/what/when. VERIFY (mix of Claude + OWNER): from a real client session attempt UPDATE/DELETE on change_log → refused. OWNER-RUN: unauthenticated anon `curl` on items, field_definitions, change_log, profiles → each returns nothing. (Claude provides commands + expected output; owner runs; not self-certified.) + EVIDENCE (2026-05-31): proof #3 anon reads all [] (+ RPC 42501); proof A (SQL editor) all PASSED + incl. broken-vs-fixed guard toggle; proof B (client editor session via REST) UPDATE+DELETE on + change_log -> [] / [] / row unchanged (action=INSERT). Full output in "Phase 1 evidence" appendix. - [ ] Phase 2 — Auth + roles. GATE: trust uses getUser()/verified claims; role single-source read by BOTH UI and RLS and they agree; sign-out ends access and clears rendered data. @@ -207,3 +222,29 @@ approval before Phase 0b. missing markdown header-separator row (`|---|`). I did not "improve" it (per your instruction not to redraft/edit the contract docs). If the repo's markdown lint flags it on this PR, that's your call to fix in an owner-approved doc-change step — I won't silently edit it. + +## Phase 1 evidence (Claude + owner, 2026-05-31) + +All three Phase 1 proofs landed with observed output. **Phase 1 gate = PASSED.** + +SQL applied: `inventory/sql/phase1.sql` (owner ran it in the Supabase SQL editor; required one +follow-up fix — `DROP FUNCTION current_user_role() CASCADE` — because CREATE OR REPLACE cannot +change a function's return type, Postgres `42P13`). + +- **Proof #3 — anon returns nothing** (Claude ran; owner/auditor re-runs as the authority per spec): + `items` / `profiles` / `field_definitions` / `change_log` each → `[]` (HTTP 200); anon + `POST rpc/current_user_role` → `42501 permission denied for function` (HTTP 401), confirming + EXECUTE is granted to `authenticated` only. +- **Proof A — privileged immutability + broken-vs-fixed** (owner ran `proofs/phase1_proofs.sql`): + results table all `PASSED` — `#1a` UPDATE blocked (`change_log is immutable`), `#1b` DELETE blocked, + `#2` with the immutability trigger DISABLED the tamper SUCCEEDED (proving the trigger is the guard). + Ran inside `BEGIN…ROLLBACK`, so it left no artifacts. +- **Proof B — client-path immutability** (Claude ran as the disposable editor test user): + login OK; `current_user_role` → `"editor"`; `POST items` → HTTP 201 (editor can write; audit trigger + wrote `change_log` row `id=2`); `PATCH change_log` → `[]` (HTTP 200); `DELETE change_log` → `[]` + (HTTP 200); re-read → `action` still `"INSERT"`. + +Follow-ups recorded in the Decisions log: +- Throwaway item `__client_proof_item__` (id=2) + its immutable `change_log` row persist; owner removes + them before the Phase 3 seed (id-collision avoidance). +- A disposable **editor** test user exists for proof B and Phase 2; rotate/delete after Phase 2. From 2d9bed80436bce8ff06452c56bb6282a90739127 Mon Sep 17 00:00:00 2001 From: "Otto-CLI (Claude)" Date: Sun, 31 May 2026 00:42:49 -0400 Subject: [PATCH 05/11] fix(inventory): MD032 blanks-around-lists at PROGRESS.md Follow-ups list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The required markdownlint gate failed on PR #6192 because the "Follow-ups recorded in the Decisions log:" paragraph butted directly against the list beneath it with no separating blank line (MD032). Insert the blank line; verified locally with markdownlint-cli2 (exit 0) using the repo's .markdownlint-cli2.jsonc config. The other two red lints (tick-history order, backlog ID uniqueness) were transient install-step flakes — mise 502 Bad Gateway fetching actionlint@1.7.12 from the GitHub releases CDN — not content failures; they re-run green on push. Co-Authored-By: Claude Opus 4.8 --- inventory/PROGRESS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/inventory/PROGRESS.md b/inventory/PROGRESS.md index 85fbfa1252..9fe33dce65 100644 --- a/inventory/PROGRESS.md +++ b/inventory/PROGRESS.md @@ -245,6 +245,7 @@ change a function's return type, Postgres `42P13`). (HTTP 200); re-read → `action` still `"INSERT"`. Follow-ups recorded in the Decisions log: + - Throwaway item `__client_proof_item__` (id=2) + its immutable `change_log` row persist; owner removes them before the Phase 3 seed (id-collision avoidance). - A disposable **editor** test user exists for proof B and Phase 2; rotate/delete after Phase 2. From 76dfb1244a1d3e2973d472bc08eac934c08ebc39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 05:43:34 +0000 Subject: [PATCH 06/11] feat(inventory Phase 2): standalone auth shell (sign-in only, getUser trust, role single-source, sign-out clears data) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inventory/index.html — self-contained static client: - SIGN-IN form ONLY (no sign-up / create-account / reset-via-signup). - Trust via getUser() (verified), never getSession(); role via rpc current_user_role() (same single source RLS uses); autoRefresh on; password cleared from DOM after login. - Edit probe button (visible to all roles) -> UPDATE items; DB/RLS decides (proof a). - One sample-item read as sign-out scaffolding (NOT Phase 3 read path). - Sign-out: signOut() + null in-memory state + wipe rendered DOM + verify session ended. - Baseline CSP meta (connect-src pinned to the Supabase host; form-action/object-src 'none'). SRI + exact-version pin + drop 'unsafe-inline' tracked for Phase 7 (not pulled forward). - publishable anon key only (public by design). https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/index.html | 259 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 inventory/index.html diff --git a/inventory/index.html b/inventory/index.html new file mode 100644 index 0000000000..40a2e98929 --- /dev/null +++ b/inventory/index.html @@ -0,0 +1,259 @@ + + + + + + + + Zeta Inventory + + + +
+

Zeta Inventory

+

Secure inventory. Sign in to continue.

+ + + +
+

Sign in

+
+
+ + +
+
+ + +
+ +
+ +
+ + + + +
+
+ + + + + + From b1632c0f25d9e7bb38ff68e9f9668b5089b125cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 05:45:29 +0000 Subject: [PATCH 07/11] =?UTF-8?q?test(inventory=20Phase=202):=20broken-vs-?= =?UTF-8?q?fixed=20proof=20for=20(a)=20=E2=80=94=20items=20UPDATE=20RLS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-run, rolled-back. Simulates the viewer (set local role authenticated + request.jwt.claims) and shows the least-privilege items_update policy refuses a viewer UPDATE (0 rows); adding a permissive USING(true) policy makes it succeed (1 row = the breach); ROLLBACK restores least-privilege. Mirrors Phase 1 proof #2. https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/sql/proofs/phase2_rls_brokenfix.sql | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 inventory/sql/proofs/phase2_rls_brokenfix.sql diff --git a/inventory/sql/proofs/phase2_rls_brokenfix.sql b/inventory/sql/proofs/phase2_rls_brokenfix.sql new file mode 100644 index 0000000000..6d3d4b5211 --- /dev/null +++ b/inventory/sql/proofs/phase2_rls_brokenfix.sql @@ -0,0 +1,67 @@ +-- ============================================================================= +-- Inventory — Phase 2 proof (a), broken-vs-fixed (SQL editor / owner) +-- Run AFTER phase1.sql, in the Supabase SQL editor. Runs in ONE transaction and +-- ROLLS BACK -> no artifacts, least-privilege restored. +-- +-- Demonstrates that the least-privilege items UPDATE policy (editor/admin only) +-- is what refuses a Viewer's edit at the DATABASE — not hidden UI: +-- FIXED (real policy) : Viewer UPDATE affects 0 rows (refused) +-- BROKEN (add USING(true)) : Viewer UPDATE affects 1 row (the breach) +-- ROLLBACK : permissive policy dropped, least-privilege back +-- +-- Read the output in the "Messages"/NOTICES area. Expected: +-- NOTICE: FIXED ... viewer UPDATE affected 0 row(s) -> expect 0 = refused +-- NOTICE: BROKEN ... viewer UPDATE affected 1 row(s) -> the breach least-privilege prevents +-- +-- Uses the throwaway Viewer test user (test2@test.com). If you used a different +-- email, edit the two lookups below. +-- +-- NOTE: relies on `set local role authenticated` + request.jwt.claims, the +-- standard Supabase RLS-as-a-role testing pattern. If your project restricts +-- `set role`, tell Claude and we'll adapt. +-- ============================================================================= + +begin; + +-- Simulate the Viewer's session: auth.uid() -> the viewer; role -> authenticated +-- (so RLS is enforced; the owner would otherwise bypass it). +select set_config( + 'request.jwt.claims', + json_build_object( + 'sub', (select id::text from auth.users where lower(email) = lower('test2@test.com')), + 'role', 'authenticated' + )::text, + true -- is_local: reset at transaction end +); +set local role authenticated; + +-- ---- FIXED: least-privilege policy in force -> Viewer UPDATE must affect 0 rows +do $$ +declare n int; +begin + update public.items set notes = 'p2 brokenfix (fixed)' where id = (select min(id) from public.items); + get diagnostics n = row_count; + raise notice 'FIXED (least-privilege items_update): viewer UPDATE affected % row(s) -> expect 0 = refused', n; +end $$; + +-- ---- BROKEN: add a permissive policy (the classic breach) -> Viewer UPDATE succeeds +reset role; -- back to owner to do DDL +create policy _tmp_permissive_update on public.items + for update to authenticated using (true) with check (true); + +set local role authenticated; -- re-enter the Viewer's session +do $$ +declare n int; +begin + update public.items set notes = 'p2 brokenfix (broken)' where id = (select min(id) from public.items); + get diagnostics n = row_count; + raise notice 'BROKEN (added USING(true) policy): viewer UPDATE affected % row(s) -> 1 = the breach least-privilege prevents', n; +end $$; + +reset role; +rollback; -- drops _tmp_permissive_update + reverts the notes; least-privilege restored + +-- Sanity after running this script: the permissive policy must NOT persist. +-- (Run separately; expect zero rows.) +-- select policyname from pg_policies +-- where schemaname='public' and tablename='items' and policyname='_tmp_permissive_update'; From dd5bc5f5aac1df1788f86590d6ff5695a0a67515 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 06:30:47 +0000 Subject: [PATCH 08/11] chore(inventory): gitignore _proof_tmp/ (throwaway Phase 2 proof harness) Keeps the vendored supabase-js copy + same-origin proof proxy + proof page (used to drive the real-browser (a)/(b)/(c) proofs around a test-proxy TLS cert wall) out of the PR. Committed index.html stays CDN-based as approved. https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 inventory/.gitignore diff --git a/inventory/.gitignore b/inventory/.gitignore new file mode 100644 index 0000000000..5ffffe1a7a --- /dev/null +++ b/inventory/.gitignore @@ -0,0 +1 @@ +_proof_tmp/ From fd8c2679ba769acd6d25abbd236e4efa364c3052 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 06:49:17 +0000 Subject: [PATCH 09/11] docs(inventory Phase 2): record (a)/(b)/(c) browser proofs; Phase 2 [~]; burn-test-users -> risk register + Phase 7 Real-browser (Playwright) proofs vs live Supabase: - sign-in-only UI verified in live DOM (no signup/create-account) - (b) role per user from current_user_role(): viewer->viewer, editor->editor (caught+fixed a profiles data discrepancy: test2 was editor, owner set viewer) - (a) viewer 'Attempt edit' -> DB REFUSED 0 rows (RLS); editor same button -> ALLOWED (negative control) - audit row written on the editor UPDATE (Phase-1 trigger confirmed under client edit) - (c) sign-out -> DOM cleared + in-memory nulled + localStorage token gone + verified getUser() null Phase 2 left at [~]: owner still runs broken-vs-fixed (a) SQL (privileged set role) before [x]. Risk register + Phase 7 gate updated: burn/rotate all build test users; supabase-js SRI+pin; drop CSP 'unsafe-inline'. https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/PROGRESS.md | 57 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/inventory/PROGRESS.md b/inventory/PROGRESS.md index 9fe33dce65..536e4829ae 100644 --- a/inventory/PROGRESS.md +++ b/inventory/PROGRESS.md @@ -43,11 +43,14 @@ refused. OWNER-RUN: unauthenticated anon `curl` on items, field_definitions, cha EVIDENCE (2026-05-31): proof #3 anon reads all [] (+ RPC 42501); proof A (SQL editor) all PASSED incl. broken-vs-fixed guard toggle; proof B (client editor session via REST) UPDATE+DELETE on change_log -> [] / [] / row unchanged (action=INSERT). Full output in "Phase 1 evidence" appendix. -- [ ] Phase 2 — Auth + roles. +- [~] Phase 2 — Auth + roles. GATE: trust uses getUser()/verified claims; role single-source read by BOTH UI and RLS and they agree; sign-out ends access and clears rendered data. VERIFY: log in as Viewer in the browser, attempt an edit → refused BY THE DB (not just UI hidden); confirm RLS sees correct role per user; sign out → data gone, session ended. + STATUS (2026-05-31): build complete; (a)/(b)/(c)+negative-control PROVEN in a real browser + (see "Phase 2 evidence" appendix). Remaining before [x]: owner runs the broken-vs-fixed (a) + SQL (proofs/phase2_rls_brokenfix.sql) in the SQL editor and pastes the 0-row/1-row notices. - [ ] Phase 3 — Read path. GATE: 210 items load; search/sort/filter correct; responsive; existing Zeta dashboard still works. VERIFY: rendered row count = seed count; run 3 sample searches/sorts; load on a phone viewport; @@ -67,7 +70,9 @@ VERIFY: generate + scan a label → correct item after login; export then re-imp - [ ] Phase 7 — Hardening + heartbeat + AUDITOR (fresh session). GATE: independent Auditor sign-off; CSP + sanitize verified; anon read-only heartbeat + scheduled export backup live (no secrets in the Action); CI/semgrep green; owner final review; deploy verified -actually propagated (account for Pages CDN caching). +actually propagated (account for Pages CDN caching); **all build-time test users deleted or +password-rotated (burned credentials — see Residual Risk Register); supabase-js SRI + exact-version +pin landed; CSP 'unsafe-inline' removed.** Auditor brief: you did NOT build this. Using spec.md + PROGRESS.md as the contract, independently re-verify EVERY gate; probe — any secret in the repo? service_role referenced? RLS permissive or bypassable from the client (run the unauthenticated anon checks)? custom-field XSS? change_log @@ -81,7 +86,14 @@ Stop the phase. Diagnose + fix + re-verify, or escalate. Never mark passed to ad ## Residual risk register (verify at Auditor pass / tune post-launch) auth email deliverability · login rate-limiting · deep a11y · timezone display · CSV encoding edges · -password reset · browser compatibility · region latency · large-scale performance · live multi-user sync. +password reset · browser compatibility · region latency · large-scale performance · live multi-user sync · +**BURNED TEST CREDENTIALS (Phase 7 must action)**: test users created during this build had their +email+password shared in chat — treat as compromised. Phase 7: delete or rotate every test user +(test@test.com, test2@test.com, and any others) before/at launch · **single-CDN dependency**: +supabase-js loads from jsdelivr with no SRI/fallback — Phase 7 adds exact-version pin + SRI (and +consider vendoring) so a blocked/compromised CDN can't break or tamper with the app · **CSP +'unsafe-inline'**: baseline CSP allows inline script/style for the single-file build — Phase 7 moves +JS/CSS external + nonces/SRI and drops 'unsafe-inline'. ## Open items (resolve in Phase 0a) @@ -249,3 +261,42 @@ Follow-ups recorded in the Decisions log: - Throwaway item `__client_proof_item__` (id=2) + its immutable `change_log` row persist; owner removes them before the Phase 3 seed (id-collision avoidance). - A disposable **editor** test user exists for proof B and Phase 2; rotate/delete after Phase 2. + +## Phase 2 evidence (Claude, real-browser via Playwright, 2026-05-31) + +Build: `inventory/index.html` (standalone; sign-in-only; getUser() trust; role via +`current_user_role()`; sign-out clears DOM+memory; baseline CSP; publishable anon key only). + +Proof harness note: the test browser's egress proxy MITMs the Supabase host with an untrusted +cert (`ERR_CERT_AUTHORITY_INVALID`), so the proofs ran against a **git-ignored** copy of the page +(`inventory/_proof_tmp/`) served by a tiny same-origin reverse-proxy that forwards `/auth/*` + +`/rest/*` to the REAL Supabase project from the container (which has working TLS). Backend, RLS, +roles, and auth are all REAL and unmodified — only the transport hop was rerouted. The committed +`index.html` is unchanged (CDN + real Supabase URL). + +Observed output (no tokens/passwords logged): + +- **Sign-in-only UI** — live DOM: email + password + "Sign in" only; no signup form, no + create-account/sign-up link or text (`signupFormPresent:false`, `createAccountText:false`). +- **(b) role per user** (from the real `current_user_role()` RPC, same source RLS uses): + - `test2@test.com` → UI role badge = **viewer** + - `test@test.com` → UI role badge = **editor** + (Caught + fixed a data discrepancy first: test2 was mistakenly `editor` in `profiles`; owner + corrected the row to `viewer`; the app re-derived the new role with no stale cache.) +- **(a) edit refused BY THE DB** — Viewer clicked the visible "Attempt edit (UPDATE items)" button: + `"DB REFUSED the edit (role=viewer): 0 rows updated — RLS filtered it out."` (button NOT hidden; + refusal is RLS, 0 rows). + - **Negative control** — same button as Editor: `"DB ALLOWED the edit (role=editor): updated row + #2."` Proves the refusal is genuinely role-gated at the DB, not a broken control. + - Bonus: the editor UPDATE wrote a Phase-1 audit row (`change_log` id=3: action=UPDATE, field=notes, + actor=editor uid, old→new) — audit trigger confirmed under real client edits. +- **(c) sign-out ends session AND clears data** — before: email/role/sample-item rendered, auth + token in localStorage. After Sign out: DOM fields all empty (`who=""`, `role=""`, sample-item=`—`, + probe msg cleared), in-memory state nulled, localStorage auth token **gone**, and a server-verified + `getUser()` returned **null** (session truly ended, not just locally); view returned to sign-in. + +Remaining before Phase 2 `[x]` (owner-run, privileged SQL editor — `set role` needed): +- **broken-vs-fixed (a)**: run `inventory/sql/proofs/phase2_rls_brokenfix.sql`; expected NOTICES: + `FIXED … viewer UPDATE affected 0 row(s)` and `BROKEN … viewer UPDATE affected 1 row(s)`; the + transaction ROLLS BACK so least-privilege is restored (then the sanity check shows no + `_tmp_permissive_update` policy persists). From a0d2912876eed89fafec7a034494c18dd6b964ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 06:56:32 +0000 Subject: [PATCH 10/11] fix(inventory Phase 2 proof): return results TABLE instead of RAISE NOTICE The Supabase SQL editor surfaces the results grid, not NOTICES, so the 0-row/1-row evidence was invisible. Stash each UPDATE row_count in a txn-local GUC (survives the set role/reset role switch) and SELECT a 2-row results table at the end. Same observable-grid pattern as the Phase 1 proof. Still one BEGIN..ROLLBACK; least-privilege restored, no artifacts. https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/sql/proofs/phase2_rls_brokenfix.sql | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/inventory/sql/proofs/phase2_rls_brokenfix.sql b/inventory/sql/proofs/phase2_rls_brokenfix.sql index 6d3d4b5211..b769adc0bd 100644 --- a/inventory/sql/proofs/phase2_rls_brokenfix.sql +++ b/inventory/sql/proofs/phase2_rls_brokenfix.sql @@ -9,20 +9,22 @@ -- BROKEN (add USING(true)) : Viewer UPDATE affects 1 row (the breach) -- ROLLBACK : permissive policy dropped, least-privilege back -- --- Read the output in the "Messages"/NOTICES area. Expected: --- NOTICE: FIXED ... viewer UPDATE affected 0 row(s) -> expect 0 = refused --- NOTICE: BROKEN ... viewer UPDATE affected 1 row(s) -> the breach least-privilege prevents +-- OUTPUT: a 2-row RESULTS TABLE (read the grid, not Messages). Expected: +-- 1 FIXED (least-privilege items_update) viewer UPDATE affected 0 row(s) -> expect 0 = refused +-- 2 BROKEN (added USING(true) policy) viewer UPDATE affected 1 row(s) -> 1 = breach prevented -- -- Uses the throwaway Viewer test user (test2@test.com). If you used a different --- email, edit the two lookups below. +-- email, edit the lookup below. -- --- NOTE: relies on `set local role authenticated` + request.jwt.claims, the --- standard Supabase RLS-as-a-role testing pattern. If your project restricts --- `set role`, tell Claude and we'll adapt. +-- NOTE: relies on `set local role authenticated` + request.jwt.claims (the +-- standard Supabase RLS-as-a-role testing pattern). Row counts are stashed in a +-- transaction-local GUC so they survive the role switch and land in the grid. -- ============================================================================= begin; +create temporary table _p2_results (seq int, step text, result text) on commit drop; + -- Simulate the Viewer's session: auth.uid() -> the viewer; role -> authenticated -- (so RLS is enforced; the owner would otherwise bypass it). select set_config( @@ -33,35 +35,46 @@ select set_config( )::text, true -- is_local: reset at transaction end ); -set local role authenticated; -- ---- FIXED: least-privilege policy in force -> Viewer UPDATE must affect 0 rows +set local role authenticated; do $$ declare n int; begin - update public.items set notes = 'p2 brokenfix (fixed)' where id = (select min(id) from public.items); + update public.items set notes = 'p2 brokenfix (fixed)' + where id = (select min(id) from public.items); get diagnostics n = row_count; - raise notice 'FIXED (least-privilege items_update): viewer UPDATE affected % row(s) -> expect 0 = refused', n; + perform set_config('p2proof.fixed', n::text, true); -- stash across the role switch end $$; +reset role; +insert into _p2_results values + (1, 'FIXED (least-privilege items_update)', + 'viewer UPDATE affected ' || current_setting('p2proof.fixed') || ' row(s) -> expect 0 = refused'); -- ---- BROKEN: add a permissive policy (the classic breach) -> Viewer UPDATE succeeds -reset role; -- back to owner to do DDL create policy _tmp_permissive_update on public.items for update to authenticated using (true) with check (true); -set local role authenticated; -- re-enter the Viewer's session +set local role authenticated; do $$ declare n int; begin - update public.items set notes = 'p2 brokenfix (broken)' where id = (select min(id) from public.items); + update public.items set notes = 'p2 brokenfix (broken)' + where id = (select min(id) from public.items); get diagnostics n = row_count; - raise notice 'BROKEN (added USING(true) policy): viewer UPDATE affected % row(s) -> 1 = the breach least-privilege prevents', n; + perform set_config('p2proof.broken', n::text, true); end $$; - reset role; +insert into _p2_results values + (2, 'BROKEN (added USING(true) policy)', + 'viewer UPDATE affected ' || current_setting('p2proof.broken') || ' row(s) -> 1 = the breach least-privilege prevents'); + +-- THE EVIDENCE (read this grid): +select seq, step, result from _p2_results order by seq; + rollback; -- drops _tmp_permissive_update + reverts the notes; least-privilege restored --- Sanity after running this script: the permissive policy must NOT persist. --- (Run separately; expect zero rows.) +-- Sanity after running this script (run separately; expect ZERO rows -> the +-- permissive policy did NOT persist): -- select policyname from pg_policies -- where schemaname='public' and tablename='items' and policyname='_tmp_permissive_update'; From dba1f90c6a0b96f1de01866854081fea63406806 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 06:59:44 +0000 Subject: [PATCH 11/11] =?UTF-8?q?docs(inventory):=20Phase=202=20gate=20PAS?= =?UTF-8?q?SED=20[x]=20=E2=80=94=20broken-vs-fixed=20(a)=20evidence=20reco?= =?UTF-8?q?rded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ran proofs/phase2_rls_brokenfix.sql (results grid): FIXED viewer UPDATE = 0 rows (refused by least-privilege items_update); BROKEN (added USING(true)) viewer UPDATE = 1 row (breach); ROLLBACK restored least-privilege (no _tmp_permissive_update persisted). Combined with the real-browser (a)/(b)/(c)+negative-control proofs, all Phase 2 gate criteria met with observed evidence. Phase 2 [x]. https://claude.ai/code/session_011CojXdZyNjq4449qBWGYJX --- inventory/PROGRESS.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/inventory/PROGRESS.md b/inventory/PROGRESS.md index 536e4829ae..921c96c108 100644 --- a/inventory/PROGRESS.md +++ b/inventory/PROGRESS.md @@ -43,14 +43,16 @@ refused. OWNER-RUN: unauthenticated anon `curl` on items, field_definitions, cha EVIDENCE (2026-05-31): proof #3 anon reads all [] (+ RPC 42501); proof A (SQL editor) all PASSED incl. broken-vs-fixed guard toggle; proof B (client editor session via REST) UPDATE+DELETE on change_log -> [] / [] / row unchanged (action=INSERT). Full output in "Phase 1 evidence" appendix. -- [~] Phase 2 — Auth + roles. +- [x] Phase 2 — Auth + roles. GATE: trust uses getUser()/verified claims; role single-source read by BOTH UI and RLS and they agree; sign-out ends access and clears rendered data. VERIFY: log in as Viewer in the browser, attempt an edit → refused BY THE DB (not just UI hidden); confirm RLS sees correct role per user; sign out → data gone, session ended. - STATUS (2026-05-31): build complete; (a)/(b)/(c)+negative-control PROVEN in a real browser - (see "Phase 2 evidence" appendix). Remaining before [x]: owner runs the broken-vs-fixed (a) - SQL (proofs/phase2_rls_brokenfix.sql) in the SQL editor and pastes the 0-row/1-row notices. + GATE PASSED (2026-05-31): (a)/(b)/(c)+negative-control PROVEN in a real browser (Playwright vs + live Supabase) AND owner-run broken-vs-fixed (a) returned the expected results grid: + FIXED (least-privilege) viewer UPDATE = 0 rows (refused); BROKEN (added USING(true)) viewer + UPDATE = 1 row (the breach); ROLLBACK restored least-privilege (sanity: no _tmp_permissive_update + policy persisted). Full output in the "Phase 2 evidence" appendix. - [ ] Phase 3 — Read path. GATE: 210 items load; search/sort/filter correct; responsive; existing Zeta dashboard still works. VERIFY: rendered row count = seed count; run 3 sample searches/sorts; load on a phone viewport; @@ -295,8 +297,14 @@ Observed output (no tokens/passwords logged): probe msg cleared), in-memory state nulled, localStorage auth token **gone**, and a server-verified `getUser()` returned **null** (session truly ended, not just locally); view returned to sign-in. -Remaining before Phase 2 `[x]` (owner-run, privileged SQL editor — `set role` needed): -- **broken-vs-fixed (a)**: run `inventory/sql/proofs/phase2_rls_brokenfix.sql`; expected NOTICES: - `FIXED … viewer UPDATE affected 0 row(s)` and `BROKEN … viewer UPDATE affected 1 row(s)`; the - transaction ROLLS BACK so least-privilege is restored (then the sanity check shows no - `_tmp_permissive_update` policy persists). +- **broken-vs-fixed (a)** (owner ran `inventory/sql/proofs/phase2_rls_brokenfix.sql` in the SQL + editor; results grid): `FIXED (least-privilege items_update): viewer UPDATE affected 0 row(s)` + and `BROKEN (added USING(true) policy): viewer UPDATE affected 1 row(s)`. One `BEGIN…ROLLBACK`, + so least-privilege was restored and no artifacts persisted (sanity check: no + `_tmp_permissive_update` policy remained). Proves the least-privilege items_update policy is what + refuses the Viewer's edit — confirmed "fails on broken code (1 row), passes when fixed (0 rows)." + +**Phase 2 gate = PASSED.** All gate criteria met with observed evidence: getUser()/verified-claims +trust; role single-source (`current_user_role()`) read by both UI and RLS, in agreement +(viewer→viewer, editor→editor); Viewer edit refused BY THE DB (0 rows, RLS) with editor allowed as +control; sign-out ends the session (verified getUser()→null) and clears rendered + in-memory data.