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/ diff --git a/inventory/PROGRESS.md b/inventory/PROGRESS.md index c61907354b..921c96c108 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,20 +28,31 @@ 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.) -- [ ] Phase 2 — Auth + roles. + 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. +- [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. + 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; @@ -52,7 +72,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 @@ -66,7 +88,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) @@ -207,3 +236,75 @@ 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. + +## 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. + +- **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. 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

+
+
+ + +
+
+ + +
+ +
+ +
+ + + + +
+
+ + + + + + 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/phase1.sql b/inventory/sql/phase1.sql new file mode 100644 index 0000000000..5311170091 --- /dev/null +++ b/inventory/sql/phase1.sql @@ -0,0 +1,325 @@ +-- ============================================================================= +-- 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). +-- ----------------------------------------------------------------------------- +-- 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 +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. +-- ============================================================================= 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) diff --git a/inventory/sql/proofs/phase2_rls_brokenfix.sql b/inventory/sql/proofs/phase2_rls_brokenfix.sql new file mode 100644 index 0000000000..b769adc0bd --- /dev/null +++ b/inventory/sql/proofs/phase2_rls_brokenfix.sql @@ -0,0 +1,80 @@ +-- ============================================================================= +-- 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 +-- +-- 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 lookup below. +-- +-- 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( + '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 +); + +-- ---- 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); + get diagnostics n = row_count; + 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 +create policy _tmp_permissive_update on public.items + for update to authenticated using (true) with check (true); + +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); + get diagnostics n = row_count; + 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 (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';