Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions inventory/sql/RUN.md
Original file line number Diff line number Diff line change
@@ -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'));`
319 changes: 319 additions & 0 deletions inventory/sql/phase1.sql
Original file line number Diff line number Diff line change
@@ -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.
-- =============================================================================
Loading
Loading