@nextblock-cms/db 0.21.3 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -47
- package/package.json +1 -1
- package/supabase/functions/nextblock-migration-ingest/index.ts +8 -8
- package/supabase/migrations/02000_catchup_gen1.sql +5880 -5880
- package/supabase/migrations/02001_baseline_schema.sql +2734 -2734
- package/supabase/migrations/02002_baseline_constraints_and_indexes.sql +1262 -1262
- package/supabase/migrations/02003_baseline_security_and_grants.sql +1577 -1577
- package/supabase/migrations/02004_baseline_seed.sql +565 -565
- package/supabase/migrations/02005_home_live_demo_promo_cadence_15min.sql +53 -53
- package/supabase/migrations/02006_home_marketing_contrast.sql +56 -56
- package/supabase/migrations/02007_home_pillar_caption_contrast.sql +44 -44
- package/supabase/migrations/02008_home_fr_parity.sql +89 -89
- package/supabase/migrations/02009_fr_cortex_posts.sql +67 -67
- package/supabase/migrations/02010_shop_hero_redesign.sql +72 -72
- package/supabase/migrations/02011_drop_backdrop_blur_on_pages.sql +26 -26
- package/supabase/migrations/02012_drop_variant_backdrop_blur.sql +17 -17
- package/supabase/migrations/02013_advisor_rls_and_trigger_grants.sql +102 -102
- package/supabase/migrations/02014_package_activations_staff_read.sql +18 -18
- package/supabase/migrations/02018_install_paths_and_seed_copy_accuracy.sql +1820 -1820
- package/supabase/migrations/02019_open_handed_licensing_and_mcp_clients.sql +448 -448
- package/supabase/migrations/02020_commerce_product_accuracy.sql +93 -93
- package/supabase/migrations/02021_profiles_staff_read_and_role_guard.sql +362 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
-- 02021: profiles are readable by their owner and staff only, `role` is admin-only, and the
|
|
2
|
+
-- revision tables are staff-only.
|
|
3
|
+
--
|
|
4
|
+
-- Reported 2026-10-17 and reproduced 2026-10-19 on a local replay of this folder. The baseline
|
|
5
|
+
-- shipped three holes:
|
|
6
|
+
--
|
|
7
|
+
-- * profiles_read_policy was FOR SELECT USING (true) with no TO clause, so it applied to every
|
|
8
|
+
-- role. The anon key that ships in the site bundle could list every account's full_name,
|
|
9
|
+
-- phone, website, avatar_url and role (no e-mail: that stays in auth.users).
|
|
10
|
+
-- * profiles_update_policy let any signed-in account update its own row with no column
|
|
11
|
+
-- restriction, and nothing guarded `role`. One PATCH from a self-registered session set
|
|
12
|
+
-- role = 'ADMIN'; get_current_user_role(), proxy.ts and app/cms/layout.tsx all trust that
|
|
13
|
+
-- column, so the account became a full CMS admin.
|
|
14
|
+
-- * page_revisions, post_revisions and product_revisions were FOR SELECT TO authenticated
|
|
15
|
+
-- USING (true): any customer account could read every historical version of every page, post
|
|
16
|
+
-- and product, published or not.
|
|
17
|
+
--
|
|
18
|
+
-- The fix:
|
|
19
|
+
--
|
|
20
|
+
-- 1. The role lookup moves to private.current_user_role(), a SECURITY DEFINER function in a
|
|
21
|
+
-- schema PostgREST does not expose. Running as the table owner makes it independent of the
|
|
22
|
+
-- profiles policies below, which now call it themselves (as SECURITY INVOKER it would only
|
|
23
|
+
-- work because the OR in the new SELECT policy short-circuits on the caller's own row and a
|
|
24
|
+
-- function with a SET clause is never inlined). public.get_current_user_role() and
|
|
25
|
+
-- public.is_admin() keep their names and signatures as SECURITY INVOKER wrappers, so the
|
|
26
|
+
-- 150 policies that call them are untouched, and the Supabase advisor lints
|
|
27
|
+
-- 0028/0029 (SECURITY DEFINER function executable by anon/authenticated in an exposed
|
|
28
|
+
-- schema) stay quiet: policies run with the querying role's rights, so anon, authenticated
|
|
29
|
+
-- and service_role get USAGE on private and EXECUTE on the helper, but /rest/v1/rpc cannot
|
|
30
|
+
-- reach it. Both helpers pin search_path.
|
|
31
|
+
-- 2. profiles SELECT is TO authenticated: own row, or every row for ADMIN and WRITER (the users
|
|
32
|
+
-- screen, author pickers, revision history, dashboard counts). anon reads nothing. The
|
|
33
|
+
-- UPDATE policy keeps "own row or ADMIN", and its WITH CHECK pins `role` to its current
|
|
34
|
+
-- value unless the caller is ADMIN.
|
|
35
|
+
-- 3. A BEFORE UPDATE trigger rejects a role change made by a request that carries a user
|
|
36
|
+
-- session (auth.uid() is set) unless that user is an ADMIN. It keys off the session, not
|
|
37
|
+
-- the SQL role, so a future SECURITY DEFINER helper called from a USER session cannot
|
|
38
|
+
-- slip past it. service_role and postgres carry no session: the users screen, /setup, the
|
|
39
|
+
-- MCP route, the sandbox reset and migrations all write through them.
|
|
40
|
+
-- 4. The three revision tables read for ADMIN and WRITER only. Their writers were already
|
|
41
|
+
-- staff-only; no USER-role or anonymous code path reads them.
|
|
42
|
+
-- 5. The public site still shows who wrote an approved review, comment or staff reply.
|
|
43
|
+
-- ProductReviewsSection, PostCommentsSection and StaffReplies embed the name and avatar
|
|
44
|
+
-- from the browser client, i.e. as anon, so that read moves off profiles onto author(): a
|
|
45
|
+
-- PostgREST computed relationship on cms_interactions (`select=*,author(full_name,avatar_url)`)
|
|
46
|
+
-- that returns id, full_name and avatar_url, and only for an interaction that is approved.
|
|
47
|
+
-- Its row type is the profile_cards view, which is security_invoker: querying the view
|
|
48
|
+
-- directly yields exactly what profiles allows the caller, nothing more.
|
|
49
|
+
-- 6. user_security_settings loses its owner INSERT/UPDATE policies. evaluateTwoFactor()
|
|
50
|
+
-- trusts mfa_enabled / mfa_type to decide whether /cms asks for a second factor, and the
|
|
51
|
+
-- owner could rewrite both over REST with a first-factor-only session. The security
|
|
52
|
+
-- actions verify the code or TOTP first and write through the service role.
|
|
53
|
+
-- 7. Scheduled content stays private until its time. posts already checked published_at;
|
|
54
|
+
-- pages and products now do too, and products no longer list draft or archived rows (nor
|
|
55
|
+
-- their variants) to anyone but staff. The app already filtered these itself; the Data API
|
|
56
|
+
-- did not.
|
|
57
|
+
-- 8. generate_order_invoice_number() and assign_order_invoice_metadata() become service-role
|
|
58
|
+
-- only (any visitor, or any account, could burn a number from order_invoice_number_seq per
|
|
59
|
+
-- call; every real caller already goes through the service role), and anon loses EXECUTE
|
|
60
|
+
-- on upsert_product_with_variants(), whose is_admin() guard is NULL without a session and
|
|
61
|
+
-- relies on RLS. /setup's post-apply grant pass (lib/setup/schema-apply.ts) re-applies
|
|
62
|
+
-- these revokes after its blanket grants.
|
|
63
|
+
-- 9. TRUNCATE is revoked from anon and authenticated on every table: row-level security does
|
|
64
|
+
-- not govern it, and no API role has a reason to hold it.
|
|
65
|
+
-- 10. site_settings reads become an allow-list. The baseline denied seven secret keys and
|
|
66
|
+
-- published everything else to the public key: staff notification addresses, the business
|
|
67
|
+
-- invoice identity, SMTP host and sender, the 2FA posture, onboarding state, payment
|
|
68
|
+
-- public config. Now anon reads the keys the public site renders or routes on
|
|
69
|
+
-- (`is_admin_created` drives the /setup redirect in proxy.ts, `enabled_payment_providers`
|
|
70
|
+
-- every Buy button); a signed-in customer additionally reads `invoice_settings` for their
|
|
71
|
+
-- own invoices; ADMIN and WRITER read the keys the CMS shell loads; ADMIN reads
|
|
72
|
+
-- everything, so a key a future feature adds is ADMIN-only until this policy lists it.
|
|
73
|
+
-- Writes are unchanged.
|
|
74
|
+
--
|
|
75
|
+
-- Idempotent, and safe on a database that recorded the baseline without running it (docs/04 →
|
|
76
|
+
-- "How a squash crosses live databases"): every SELECT policy on profiles is dropped by name
|
|
77
|
+
-- from pg_policies before the replacement is created, so a generation-1 policy under another
|
|
78
|
+
-- name cannot keep the table open.
|
|
79
|
+
|
|
80
|
+
-- ---------------------------------------------------------------------------
|
|
81
|
+
-- 1. Role helpers: owner-privileged lookup in a non-exposed schema, invoker wrappers in public
|
|
82
|
+
-- ---------------------------------------------------------------------------
|
|
83
|
+
CREATE SCHEMA IF NOT EXISTS private;
|
|
84
|
+
REVOKE ALL ON SCHEMA private FROM PUBLIC;
|
|
85
|
+
-- Policies evaluate with the querying role's rights, and policies without a TO clause run
|
|
86
|
+
-- for anon too, so every API role can call the helper from inside a policy.
|
|
87
|
+
GRANT USAGE ON SCHEMA private TO anon, authenticated, service_role;
|
|
88
|
+
|
|
89
|
+
CREATE OR REPLACE FUNCTION private.current_user_role() RETURNS public.user_role
|
|
90
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
91
|
+
SET search_path TO ''
|
|
92
|
+
AS $$
|
|
93
|
+
SELECT role FROM public.profiles WHERE id = auth.uid();
|
|
94
|
+
$$;
|
|
95
|
+
|
|
96
|
+
REVOKE ALL ON FUNCTION private.current_user_role() FROM PUBLIC;
|
|
97
|
+
GRANT EXECUTE ON FUNCTION private.current_user_role() TO anon, authenticated, service_role;
|
|
98
|
+
|
|
99
|
+
-- Same names, same signatures, same NULL-without-a-session semantics as the baseline.
|
|
100
|
+
CREATE OR REPLACE FUNCTION public.get_current_user_role() RETURNS public.user_role
|
|
101
|
+
LANGUAGE sql STABLE SECURITY INVOKER
|
|
102
|
+
SET search_path TO 'public'
|
|
103
|
+
AS $$
|
|
104
|
+
SELECT private.current_user_role();
|
|
105
|
+
$$;
|
|
106
|
+
|
|
107
|
+
CREATE OR REPLACE FUNCTION public.is_admin() RETURNS boolean
|
|
108
|
+
LANGUAGE sql STABLE SECURITY INVOKER
|
|
109
|
+
SET search_path TO 'public'
|
|
110
|
+
AS $$
|
|
111
|
+
SELECT private.current_user_role() = 'ADMIN'::public.user_role;
|
|
112
|
+
$$;
|
|
113
|
+
|
|
114
|
+
REVOKE ALL ON FUNCTION public.get_current_user_role() FROM PUBLIC;
|
|
115
|
+
GRANT EXECUTE ON FUNCTION public.get_current_user_role() TO anon, authenticated, service_role;
|
|
116
|
+
REVOKE ALL ON FUNCTION public.is_admin() FROM PUBLIC;
|
|
117
|
+
GRANT EXECUTE ON FUNCTION public.is_admin() TO anon, authenticated, service_role;
|
|
118
|
+
|
|
119
|
+
-- ---------------------------------------------------------------------------
|
|
120
|
+
-- 2. profiles: own row or staff reads, no anon, role pinned for non-admins
|
|
121
|
+
-- ---------------------------------------------------------------------------
|
|
122
|
+
DO $$
|
|
123
|
+
DECLARE p record;
|
|
124
|
+
BEGIN
|
|
125
|
+
FOR p IN
|
|
126
|
+
SELECT policyname FROM pg_policies
|
|
127
|
+
WHERE schemaname = 'public' AND tablename = 'profiles' AND cmd = 'SELECT'
|
|
128
|
+
LOOP
|
|
129
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON public.profiles', p.policyname);
|
|
130
|
+
END LOOP;
|
|
131
|
+
END $$;
|
|
132
|
+
|
|
133
|
+
CREATE POLICY profiles_read_policy ON public.profiles
|
|
134
|
+
FOR SELECT TO authenticated
|
|
135
|
+
USING (
|
|
136
|
+
id = (SELECT auth.uid())
|
|
137
|
+
OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
DROP POLICY IF EXISTS profiles_update_policy ON public.profiles;
|
|
141
|
+
CREATE POLICY profiles_update_policy ON public.profiles
|
|
142
|
+
FOR UPDATE TO authenticated
|
|
143
|
+
USING (
|
|
144
|
+
id = (SELECT auth.uid())
|
|
145
|
+
OR (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role
|
|
146
|
+
)
|
|
147
|
+
WITH CHECK (
|
|
148
|
+
(SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role
|
|
149
|
+
OR (id = (SELECT auth.uid()) AND role = (SELECT public.get_current_user_role()))
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
-- ---------------------------------------------------------------------------
|
|
153
|
+
-- 3. role changes from a user session are admin-only (backstop for the policy above)
|
|
154
|
+
-- ---------------------------------------------------------------------------
|
|
155
|
+
CREATE OR REPLACE FUNCTION public.protect_profile_role() RETURNS trigger
|
|
156
|
+
LANGUAGE plpgsql
|
|
157
|
+
SET search_path TO 'public'
|
|
158
|
+
AS $$
|
|
159
|
+
BEGIN
|
|
160
|
+
IF NEW.role IS DISTINCT FROM OLD.role
|
|
161
|
+
AND auth.uid() IS NOT NULL
|
|
162
|
+
AND (SELECT public.get_current_user_role()) IS DISTINCT FROM 'ADMIN'::public.user_role
|
|
163
|
+
THEN
|
|
164
|
+
RAISE EXCEPTION 'Only an administrator can change a user''s role'
|
|
165
|
+
USING ERRCODE = 'insufficient_privilege';
|
|
166
|
+
END IF;
|
|
167
|
+
RETURN NEW;
|
|
168
|
+
END;
|
|
169
|
+
$$;
|
|
170
|
+
|
|
171
|
+
-- Trigger functions are never called by API roles (02013).
|
|
172
|
+
REVOKE ALL ON FUNCTION public.protect_profile_role() FROM PUBLIC, anon, authenticated;
|
|
173
|
+
GRANT EXECUTE ON FUNCTION public.protect_profile_role() TO service_role;
|
|
174
|
+
|
|
175
|
+
DROP TRIGGER IF EXISTS protect_profile_role ON public.profiles;
|
|
176
|
+
CREATE TRIGGER protect_profile_role
|
|
177
|
+
BEFORE UPDATE ON public.profiles
|
|
178
|
+
FOR EACH ROW
|
|
179
|
+
WHEN (OLD.role IS DISTINCT FROM NEW.role)
|
|
180
|
+
EXECUTE FUNCTION public.protect_profile_role();
|
|
181
|
+
|
|
182
|
+
-- ---------------------------------------------------------------------------
|
|
183
|
+
-- 4. Revision tables: staff read only
|
|
184
|
+
-- ---------------------------------------------------------------------------
|
|
185
|
+
DROP POLICY IF EXISTS page_revisions_read_policy ON public.page_revisions;
|
|
186
|
+
CREATE POLICY page_revisions_read_policy ON public.page_revisions
|
|
187
|
+
FOR SELECT TO authenticated
|
|
188
|
+
USING (
|
|
189
|
+
(SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
DROP POLICY IF EXISTS post_revisions_read_policy ON public.post_revisions;
|
|
193
|
+
CREATE POLICY post_revisions_read_policy ON public.post_revisions
|
|
194
|
+
FOR SELECT TO authenticated
|
|
195
|
+
USING (
|
|
196
|
+
(SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
DROP POLICY IF EXISTS product_revisions_read_policy ON public.product_revisions;
|
|
200
|
+
CREATE POLICY product_revisions_read_policy ON public.product_revisions
|
|
201
|
+
FOR SELECT TO authenticated
|
|
202
|
+
USING (
|
|
203
|
+
(SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
-- ---------------------------------------------------------------------------
|
|
207
|
+
-- 5. Public author card: cms_interactions?select=*,author(full_name,avatar_url)
|
|
208
|
+
-- ---------------------------------------------------------------------------
|
|
209
|
+
CREATE OR REPLACE VIEW public.profile_cards
|
|
210
|
+
WITH (security_invoker = true) AS
|
|
211
|
+
SELECT id, full_name, avatar_url FROM public.profiles;
|
|
212
|
+
|
|
213
|
+
GRANT SELECT ON public.profile_cards TO anon, authenticated, service_role;
|
|
214
|
+
|
|
215
|
+
-- One card per interaction row the caller can already see. The EXISTS re-reads the row so a
|
|
216
|
+
-- hand-built argument cannot turn the function into a name lookup for arbitrary ids. The
|
|
217
|
+
-- owner-privileged read lives in private (advisor lints 0028/0029, as in section 1); the
|
|
218
|
+
-- public function is the invoker wrapper PostgREST embeds as a computed relationship.
|
|
219
|
+
CREATE OR REPLACE FUNCTION private.author_card(p_interaction_id uuid, p_user_id uuid)
|
|
220
|
+
RETURNS SETOF public.profile_cards ROWS 1
|
|
221
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
222
|
+
SET search_path TO ''
|
|
223
|
+
AS $$
|
|
224
|
+
SELECT p.id, p.full_name, p.avatar_url
|
|
225
|
+
FROM public.profiles p
|
|
226
|
+
WHERE p.id = p_user_id
|
|
227
|
+
AND EXISTS (
|
|
228
|
+
SELECT 1 FROM public.cms_interactions ci
|
|
229
|
+
WHERE ci.id = p_interaction_id
|
|
230
|
+
AND ci.user_id = p_user_id
|
|
231
|
+
AND ci.status = 'approved'::public.approval_status
|
|
232
|
+
);
|
|
233
|
+
$$;
|
|
234
|
+
|
|
235
|
+
REVOKE ALL ON FUNCTION private.author_card(uuid, uuid) FROM PUBLIC;
|
|
236
|
+
GRANT EXECUTE ON FUNCTION private.author_card(uuid, uuid) TO anon, authenticated, service_role;
|
|
237
|
+
|
|
238
|
+
CREATE OR REPLACE FUNCTION public.author(public.cms_interactions)
|
|
239
|
+
RETURNS SETOF public.profile_cards ROWS 1
|
|
240
|
+
LANGUAGE sql STABLE SECURITY INVOKER
|
|
241
|
+
SET search_path TO 'public'
|
|
242
|
+
AS $$
|
|
243
|
+
SELECT * FROM private.author_card($1.id, $1.user_id);
|
|
244
|
+
$$;
|
|
245
|
+
|
|
246
|
+
REVOKE ALL ON FUNCTION public.author(public.cms_interactions) FROM PUBLIC;
|
|
247
|
+
GRANT EXECUTE ON FUNCTION public.author(public.cms_interactions) TO anon, authenticated, service_role;
|
|
248
|
+
|
|
249
|
+
-- ---------------------------------------------------------------------------
|
|
250
|
+
-- 6. user_security_settings: written by the server only, read by its owner
|
|
251
|
+
-- ---------------------------------------------------------------------------
|
|
252
|
+
DROP POLICY IF EXISTS user_security_settings_insert_own_policy ON public.user_security_settings;
|
|
253
|
+
DROP POLICY IF EXISTS user_security_settings_update_own_policy ON public.user_security_settings;
|
|
254
|
+
|
|
255
|
+
-- ---------------------------------------------------------------------------
|
|
256
|
+
-- 7. Scheduled, draft and archived content is staff-only until it is live
|
|
257
|
+
-- ---------------------------------------------------------------------------
|
|
258
|
+
DROP POLICY IF EXISTS pages_anon_read_policy ON public.pages;
|
|
259
|
+
CREATE POLICY pages_anon_read_policy ON public.pages
|
|
260
|
+
FOR SELECT TO anon
|
|
261
|
+
USING (
|
|
262
|
+
status = 'published'::public.page_status
|
|
263
|
+
AND (published_at IS NULL OR published_at <= now())
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
DROP POLICY IF EXISTS pages_read_policy ON public.pages;
|
|
267
|
+
CREATE POLICY pages_read_policy ON public.pages
|
|
268
|
+
FOR SELECT TO authenticated
|
|
269
|
+
USING (
|
|
270
|
+
(status = 'published'::public.page_status AND (published_at IS NULL OR published_at <= now()))
|
|
271
|
+
OR (author_id = (SELECT auth.uid()) AND status <> 'published'::public.page_status)
|
|
272
|
+
OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
DROP POLICY IF EXISTS "Public can view products" ON public.products;
|
|
276
|
+
CREATE POLICY "Public can view products" ON public.products
|
|
277
|
+
FOR SELECT TO anon, authenticated
|
|
278
|
+
USING (
|
|
279
|
+
(status = 'active' AND (published_at IS NULL OR published_at <= now()))
|
|
280
|
+
OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
DROP POLICY IF EXISTS "Public read product_variants" ON public.product_variants;
|
|
284
|
+
CREATE POLICY "Public read product_variants" ON public.product_variants
|
|
285
|
+
FOR SELECT TO anon, authenticated
|
|
286
|
+
USING (
|
|
287
|
+
EXISTS (
|
|
288
|
+
SELECT 1 FROM public.products p
|
|
289
|
+
WHERE p.id = product_variants.product_id
|
|
290
|
+
AND p.status = 'active'
|
|
291
|
+
AND (p.published_at IS NULL OR p.published_at <= now())
|
|
292
|
+
)
|
|
293
|
+
OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
-- ---------------------------------------------------------------------------
|
|
297
|
+
-- 8. Invoice numbering and the product upsert are not public RPCs
|
|
298
|
+
-- ---------------------------------------------------------------------------
|
|
299
|
+
REVOKE EXECUTE ON FUNCTION public.generate_order_invoice_number() FROM PUBLIC, anon, authenticated;
|
|
300
|
+
REVOKE EXECUTE ON FUNCTION public.assign_order_invoice_metadata(uuid, timestamp with time zone) FROM PUBLIC, anon, authenticated;
|
|
301
|
+
REVOKE ALL ON SEQUENCE public.order_invoice_number_seq FROM anon, authenticated;
|
|
302
|
+
REVOKE EXECUTE ON FUNCTION public.upsert_product_with_variants(jsonb) FROM PUBLIC, anon;
|
|
303
|
+
|
|
304
|
+
-- ---------------------------------------------------------------------------
|
|
305
|
+
-- 9. API roles never truncate
|
|
306
|
+
-- ---------------------------------------------------------------------------
|
|
307
|
+
REVOKE TRUNCATE ON ALL TABLES IN SCHEMA public FROM anon, authenticated;
|
|
308
|
+
|
|
309
|
+
-- ---------------------------------------------------------------------------
|
|
310
|
+
-- 10. site_settings: read tiers instead of a deny-list
|
|
311
|
+
-- ---------------------------------------------------------------------------
|
|
312
|
+
-- Every SELECT policy goes, by name, so a generation-1 policy under another name cannot keep
|
|
313
|
+
-- the table open (same reasoning as profiles above).
|
|
314
|
+
DO $$
|
|
315
|
+
DECLARE p record;
|
|
316
|
+
BEGIN
|
|
317
|
+
FOR p IN
|
|
318
|
+
SELECT policyname FROM pg_policies
|
|
319
|
+
WHERE schemaname = 'public' AND tablename = 'site_settings' AND cmd = 'SELECT'
|
|
320
|
+
LOOP
|
|
321
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON public.site_settings', p.policyname);
|
|
322
|
+
END LOOP;
|
|
323
|
+
END $$;
|
|
324
|
+
|
|
325
|
+
-- Keys the public site renders or routes on. Each one has an anonymous reader:
|
|
326
|
+
-- app/lib/site-settings.ts, app/layout.tsx, proxy.ts (language detection, is_admin_created),
|
|
327
|
+
-- app/robots.txt, app/llms.txt, components/BlockRenderer.tsx (bot protection), the logo
|
|
328
|
+
-- loader, libs/ecommerce payments/queries.ts and tax-calculation.ts, and the history-repair
|
|
329
|
+
-- tool's anon fallback for migration_baseline_generation.
|
|
330
|
+
CREATE POLICY site_settings_anon_read_policy ON public.site_settings
|
|
331
|
+
FOR SELECT TO anon
|
|
332
|
+
USING (
|
|
333
|
+
key = ANY (ARRAY[
|
|
334
|
+
'site_title', 'site_description', 'site_keywords', 'site_social_image',
|
|
335
|
+
'footer_copyright', 'footer_show_attribution', 'global_css',
|
|
336
|
+
'language_detection_settings', 'seo_robots_settings', 'bot_protection_public',
|
|
337
|
+
'active_logo_id', 'is_admin_created', 'privacy_settings',
|
|
338
|
+
'enabled_payment_providers', 'ecommerce_inventory_settings',
|
|
339
|
+
'migration_baseline_generation'
|
|
340
|
+
])
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
CREATE POLICY site_settings_read_policy ON public.site_settings
|
|
344
|
+
FOR SELECT TO authenticated
|
|
345
|
+
USING (
|
|
346
|
+
key = ANY (ARRAY[
|
|
347
|
+
'site_title', 'site_description', 'site_keywords', 'site_social_image',
|
|
348
|
+
'footer_copyright', 'footer_show_attribution', 'global_css',
|
|
349
|
+
'language_detection_settings', 'seo_robots_settings', 'bot_protection_public',
|
|
350
|
+
'active_logo_id', 'is_admin_created', 'privacy_settings',
|
|
351
|
+
'enabled_payment_providers', 'ecommerce_inventory_settings',
|
|
352
|
+
'migration_baseline_generation',
|
|
353
|
+
-- a customer's own invoice carries the seller block (libs/ecommerce invoice-server.ts)
|
|
354
|
+
'invoice_settings'
|
|
355
|
+
])
|
|
356
|
+
OR (
|
|
357
|
+
-- loaded by the CMS shell and dashboard for every staff role
|
|
358
|
+
key = ANY (ARRAY['security_settings', 'onboarding_state', 'email_public', 'cortex_ai_site_brief'])
|
|
359
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
|
|
360
|
+
)
|
|
361
|
+
OR (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role
|
|
362
|
+
);
|