Skip to content

Commit d79b09c

Browse files
Newmusicyy111claude
andcommitted
fix(migrations): close RLS column-scope gap and B2B reapply dead-end
019: Postgres RLS is row-level only, so the admin update policies let an admin session change any column on orders/products/commissions/payouts, not just the status/price/inventory fields the comment documents. Added column-scoping triggers that reject updates touching columns outside the documented admin-editable set. 020: user_id is unique and the update policy required status='pending', so a rejected user had no way to resubmit (can't insert a second row, can't update the rejected row). Widened the policy to allow editing a rejected row back to 'pending' (with-check still pins the result to 'pending' only, so a user can never self-approve), and added a trigger to clear stale reviewer fields on resubmission. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 643b060 commit d79b09c

2 files changed

Lines changed: 93 additions & 3 deletions

File tree

migrations/019_admin_rls_policies.sql

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,65 @@ create policy "Admins can read audit logs"
9898
on public.admin_audit_logs
9999
for select
100100
using (exists (select 1 from public.users where users.id = auth.uid() and users.tier = 'admin'));
101+
102+
-- Column-scoping guards -----------------------------------------------------
103+
-- RLS policies above are row-level only: Postgres RLS cannot restrict which
104+
-- *columns* an update touches, so the "admins cannot modify user tier or
105+
-- delete orders (only status updates)" comment above is not actually
106+
-- enforced by the `for update` policies alone — an admin session could
107+
-- update any column on a row it can see (e.g. orders.total, orders.user_id).
108+
-- These triggers close that gap by rejecting updates that touch a column
109+
-- outside the documented admin-editable set. They only apply when the admin
110+
-- RLS predicate matches, so non-admin updates (already blocked by RLS, or
111+
-- performed by the service role) are unaffected.
112+
create or replace function public.enforce_admin_column_scope()
113+
returns trigger
114+
language plpgsql
115+
as $$
116+
declare
117+
is_admin boolean;
118+
allowed_cols text[] := tg_argv;
119+
col text;
120+
begin
121+
is_admin := exists (select 1 from public.users where users.id = auth.uid() and users.tier = 'admin');
122+
if not is_admin then
123+
return new;
124+
end if;
125+
126+
foreach col in array (
127+
select key from jsonb_each(to_jsonb(new)) t(key, value)
128+
where to_jsonb(new) -> key is distinct from to_jsonb(old) -> key
129+
)
130+
loop
131+
if not (col = any (allowed_cols)) then
132+
raise exception 'Admin update to column "%" on % is not permitted (allowed: %)', col, tg_table_name, allowed_cols;
133+
end if;
134+
end loop;
135+
136+
return new;
137+
end;
138+
$$;
139+
140+
drop trigger if exists admin_column_scope_orders on public.orders;
141+
create trigger admin_column_scope_orders
142+
before update on public.orders
143+
for each row
144+
execute function public.enforce_admin_column_scope('status', 'updated_at');
145+
146+
drop trigger if exists admin_column_scope_products on public.products;
147+
create trigger admin_column_scope_products
148+
before update on public.products
149+
for each row
150+
execute function public.enforce_admin_column_scope('price', 'original_price', 'in_stock', 'inventory', 'updated_at');
151+
152+
drop trigger if exists admin_column_scope_commissions on public.commissions;
153+
create trigger admin_column_scope_commissions
154+
before update on public.commissions
155+
for each row
156+
execute function public.enforce_admin_column_scope('status', 'paid_at', 'updated_at');
157+
158+
drop trigger if exists admin_column_scope_commission_payouts on public.commission_payouts;
159+
create trigger admin_column_scope_commission_payouts
160+
before update on public.commission_payouts
161+
for each row
162+
execute function public.enforce_admin_column_scope('status', 'payout_date', 'updated_at');

migrations/020_b2b_upgrade_requests.sql

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,15 @@ create policy "Users can insert own upgrade requests"
5454
for insert
5555
with check (auth.uid() = user_id);
5656

57-
-- Users can update their own pending requests
58-
create policy "Users can update own pending requests"
57+
-- Users can update their own pending OR rejected requests. The unique constraint
58+
-- on user_id means a rejected user has no way to submit a *new* row, so the
59+
-- reapply path is editing the existing one back to 'pending' -- the with-check
60+
-- pins the resulting status to 'pending' so a user can never self-approve or
61+
-- resubmit into any other state.
62+
create policy "Users can update own pending or rejected requests"
5963
on public.b2b_upgrade_requests
6064
for update
61-
using (auth.uid() = user_id and status = 'pending')
65+
using (auth.uid() = user_id and status in ('pending', 'rejected'))
6266
with check (auth.uid() = user_id and status = 'pending');
6367

6468
-- Admin can update any request (reviewed_by, status, reviewed_at, rejection_reason)
@@ -67,3 +71,27 @@ create policy "Admin can update all requests"
6771
for update
6872
using (exists (select 1 from public.users where users.id = auth.uid() and users.tier = 'admin'))
6973
with check (exists (select 1 from public.users where users.id = auth.uid() and users.tier = 'admin'));
74+
75+
-- Clear stale reviewer fields whenever a non-admin resubmission flips a
76+
-- rejected request back to 'pending' -- otherwise the old rejection_reason/
77+
-- reviewed_by/reviewed_at would linger and misrepresent the new submission
78+
-- as already reviewed.
79+
create or replace function public.reset_b2b_review_fields_on_resubmit()
80+
returns trigger
81+
language plpgsql
82+
as $$
83+
begin
84+
if old.status = 'rejected' and new.status = 'pending' then
85+
new.reviewed_by := null;
86+
new.reviewed_at := null;
87+
new.rejection_reason := null;
88+
end if;
89+
return new;
90+
end;
91+
$$;
92+
93+
drop trigger if exists b2b_reset_review_fields_on_resubmit on public.b2b_upgrade_requests;
94+
create trigger b2b_reset_review_fields_on_resubmit
95+
before update on public.b2b_upgrade_requests
96+
for each row
97+
execute function public.reset_b2b_review_fields_on_resubmit();

0 commit comments

Comments
 (0)