@nextblock-cms/db 0.22.1 → 0.22.3
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/lib/media-actions.cjs.js +1 -1
- package/lib/media-actions.d.ts +13 -35
- package/lib/media-actions.es.js +25 -49
- package/lib/media-actions.test.d.ts +1 -0
- package/lib/media-record.cjs.js +1 -0
- package/lib/media-record.d.ts +46 -0
- package/lib/media-record.es.js +47 -0
- package/lib/package-validation.cjs.js +1 -1
- package/lib/package-validation.es.js +1 -1
- package/lib/secret-crypto.cjs.js +1 -1
- package/lib/secret-crypto.es.js +1 -0
- package/lib/supabase/server.cjs.js +1 -1
- package/lib/supabase/server.es.js +1 -0
- package/package.json +1 -1
- package/server.cjs.js +1 -1
- package/server.d.ts +1 -0
- package/server.es.js +6 -5
- 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 -362
- package/supabase/migrations/02022_security_hardening_writer_boundary_and_aal.sql +504 -504
- package/supabase/migrations/02024_advisor_trigger_grants_and_pg_net_schema.sql +113 -0
- package/supabase/migrations/02025_api_role_grant_hardening.sql +66 -0
- package/supabase/migrations/02026_authenticated_write_grant_hardening.sql +91 -0
- package/supabase/migrations/02027_api_role_select_grant_hardening.sql +150 -0
- package/supabase/migrations/02028_writer_settings_allowlist_email_factor_aal_and_media_keys.sql +284 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
-- 02024: clear the Supabase Security Advisor warnings that came back after the 02022/02023
|
|
2
|
+
-- deploys, and move a `pg_net` that was installed in `public` into `extensions`.
|
|
3
|
+
--
|
|
4
|
+
-- Why the SECURITY DEFINER warnings (lints 0028/0029) returned
|
|
5
|
+
-- 02013 revoked anon/authenticated EXECUTE on the two SECURITY DEFINER trigger functions
|
|
6
|
+
-- (handle_new_user(), update_product_ratings()) and the advisor was clean. But every
|
|
7
|
+
-- production build runs apps/nextblock/tools/build-migrate.mjs, whose migrate-core.mjs
|
|
8
|
+
-- ends with a blanket `grant execute on all functions in schema public to anon,
|
|
9
|
+
-- authenticated, service_role` and, unlike lib/setup/schema-apply.ts (the /setup path),
|
|
10
|
+
-- had no pass taking that EXECUTE back off trigger functions or re-applying the API-role
|
|
11
|
+
-- revokes of 02021/02022. So each deploy silently handed the privilege back. The hook is
|
|
12
|
+
-- fixed in the same change (and a test now keeps the two files' SQL identical); this
|
|
13
|
+
-- migration repairs the databases that were already re-granted. Postgres checks EXECUTE
|
|
14
|
+
-- on a trigger function only at CREATE TRIGGER time, never when the trigger fires, and
|
|
15
|
+
-- PostgREST cannot call a `RETURNS trigger` function via /rpc, so nothing loses access.
|
|
16
|
+
--
|
|
17
|
+
-- Why `pg_net` (lint 0014, "Extension in Public")
|
|
18
|
+
-- A bare `create extension pg_net` lands in `public` (the first search_path entry), which
|
|
19
|
+
-- exposes its objects through the API schema. pg_net is NOT relocatable, so the lint's
|
|
20
|
+
-- `alter extension … set schema` is refused; the only way out is drop + re-create WITH
|
|
21
|
+
-- SCHEMA extensions. Its own objects live in the `net` schema either way; only the
|
|
22
|
+
-- extension's registration moves, and the request queue / response log are transient.
|
|
23
|
+
-- pg_cron job commands are plain text (no dependency), so a scheduled `net.http_get`
|
|
24
|
+
-- keeps working. Guarded: a database without pg_net, or with it already outside
|
|
25
|
+
-- `public`, is untouched. Same statement tools/scripts/schedule-sandbox-reset.js runs.
|
|
26
|
+
--
|
|
27
|
+
-- Everything here is idempotent and safe on a fresh install, the sandbox replay and a
|
|
28
|
+
-- /setup-installed database. The two Auth-dashboard warnings (leaked password protection,
|
|
29
|
+
-- MFA options) are project settings, not schema, and are not addressed here.
|
|
30
|
+
|
|
31
|
+
-- ---------------------------------------------------------------------------
|
|
32
|
+
-- 1. Trigger functions: the API roles never need EXECUTE
|
|
33
|
+
-- (identical to the post-apply pass in migrate-core.mjs / schema-apply.ts)
|
|
34
|
+
-- ---------------------------------------------------------------------------
|
|
35
|
+
do $nb_trg$
|
|
36
|
+
declare r record;
|
|
37
|
+
begin
|
|
38
|
+
for r in
|
|
39
|
+
select p.oid::regprocedure as sig
|
|
40
|
+
from pg_proc p
|
|
41
|
+
join pg_type t on t.oid = p.prorettype
|
|
42
|
+
where p.pronamespace = 'public'::regnamespace and t.typname = 'trigger'
|
|
43
|
+
loop
|
|
44
|
+
execute format('revoke execute on function %s from public, anon, authenticated', r.sig);
|
|
45
|
+
end loop;
|
|
46
|
+
end $nb_trg$;
|
|
47
|
+
|
|
48
|
+
-- The two the advisor names, stated explicitly so the intent survives a grep (02013 parity).
|
|
49
|
+
REVOKE EXECUTE ON FUNCTION public.handle_new_user() FROM PUBLIC, anon, authenticated;
|
|
50
|
+
GRANT EXECUTE ON FUNCTION public.handle_new_user() TO service_role;
|
|
51
|
+
|
|
52
|
+
REVOKE EXECUTE ON FUNCTION public.update_product_ratings() FROM PUBLIC, anon, authenticated;
|
|
53
|
+
GRANT EXECUTE ON FUNCTION public.update_product_ratings() TO service_role;
|
|
54
|
+
|
|
55
|
+
-- ---------------------------------------------------------------------------
|
|
56
|
+
-- 2. The API-role revokes of 02021 / 02022 that the same blanket grant handed back
|
|
57
|
+
-- (identical to API_ROLE_REVOKE_SQL in migrate-core.mjs / schema-apply.ts)
|
|
58
|
+
-- ---------------------------------------------------------------------------
|
|
59
|
+
do $nb_api$
|
|
60
|
+
begin
|
|
61
|
+
revoke truncate on all tables in schema public from anon, authenticated;
|
|
62
|
+
if to_regprocedure('public.generate_order_invoice_number()') is not null then
|
|
63
|
+
revoke execute on function public.generate_order_invoice_number() from public, anon, authenticated;
|
|
64
|
+
end if;
|
|
65
|
+
if to_regprocedure('public.assign_order_invoice_metadata(uuid, timestamp with time zone)') is not null then
|
|
66
|
+
revoke execute on function public.assign_order_invoice_metadata(uuid, timestamp with time zone) from public, anon, authenticated;
|
|
67
|
+
end if;
|
|
68
|
+
if to_regclass('public.order_invoice_number_seq') is not null then
|
|
69
|
+
revoke all on sequence public.order_invoice_number_seq from anon, authenticated;
|
|
70
|
+
end if;
|
|
71
|
+
if to_regprocedure('public.upsert_product_with_variants(jsonb)') is not null then
|
|
72
|
+
revoke execute on function public.upsert_product_with_variants(jsonb) from public, anon;
|
|
73
|
+
end if;
|
|
74
|
+
if to_regprocedure('public.bump_email_2fa_attempts(uuid)') is not null then
|
|
75
|
+
revoke execute on function public.bump_email_2fa_attempts(uuid) from public, anon, authenticated;
|
|
76
|
+
end if;
|
|
77
|
+
if to_regprocedure('public.redeem_coupon_atomically(uuid)') is not null then
|
|
78
|
+
revoke execute on function public.redeem_coupon_atomically(uuid) from public, anon, authenticated;
|
|
79
|
+
end if;
|
|
80
|
+
end $nb_api$;
|
|
81
|
+
|
|
82
|
+
-- ---------------------------------------------------------------------------
|
|
83
|
+
-- 3. pg_net out of `public` (not relocatable: drop + re-create WITH SCHEMA extensions)
|
|
84
|
+
-- ---------------------------------------------------------------------------
|
|
85
|
+
do $nb_pg_net$
|
|
86
|
+
declare
|
|
87
|
+
v_version text;
|
|
88
|
+
begin
|
|
89
|
+
select e.extversion
|
|
90
|
+
into v_version
|
|
91
|
+
from pg_extension e
|
|
92
|
+
where e.extname = 'pg_net'
|
|
93
|
+
and e.extnamespace = 'public'::regnamespace;
|
|
94
|
+
|
|
95
|
+
if v_version is null then
|
|
96
|
+
return; -- not installed, or already registered outside public
|
|
97
|
+
end if;
|
|
98
|
+
|
|
99
|
+
if to_regnamespace('extensions') is null then
|
|
100
|
+
raise notice '02024: pg_net % is registered in schema public and this database has no extensions schema; reinstall it by hand with `create extension pg_net with schema <schema>`', v_version;
|
|
101
|
+
return;
|
|
102
|
+
end if;
|
|
103
|
+
|
|
104
|
+
-- The pg_net worker holds net.http_request_queue while a request is in flight. Fail fast
|
|
105
|
+
-- rather than queue behind it; re-running the migration is cheap.
|
|
106
|
+
perform set_config('lock_timeout', '15s', true);
|
|
107
|
+
|
|
108
|
+
drop extension pg_net;
|
|
109
|
+
create extension pg_net with schema extensions;
|
|
110
|
+
|
|
111
|
+
raise notice '02024: pg_net re-registered in schema extensions (was % in public, now %)',
|
|
112
|
+
v_version, (select extversion from pg_extension where extname = 'pg_net');
|
|
113
|
+
end $nb_pg_net$;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
-- 02025: grant-level hardening for the API roles (2026-10-21 posture review of 02021-02024).
|
|
2
|
+
--
|
|
3
|
+
-- Grants decide whether a role may touch a table at all; RLS decides which rows. This file
|
|
4
|
+
-- removes three places where RLS had become the only barrier. It changes no row a legitimate
|
|
5
|
+
-- request can reach: every statement below takes away a privilege that no RLS policy lets the
|
|
6
|
+
-- role use.
|
|
7
|
+
--
|
|
8
|
+
-- 1. anon is read-only at the grant level.
|
|
9
|
+
-- Supabase's historical default privileges, and the baseline (02003), granted anon
|
|
10
|
+
-- INSERT/UPDATE/DELETE/REFERENCES/TRIGGER on every table in `public`. No policy in the
|
|
11
|
+
-- schema lets anon write anything (checked against pg_policies on 2026-10-21: zero
|
|
12
|
+
-- INSERT/UPDATE/DELETE/ALL policies reach anon or PUBLIC), so those grants only meant that
|
|
13
|
+
-- a single permissive policy added by mistake would publish a table for writing to anyone
|
|
14
|
+
-- with the public key. Every anonymous write path in the app (forms, consent, comments,
|
|
15
|
+
-- checkout) already goes through a server route with the service role. Sequence USAGE and
|
|
16
|
+
-- UPDATE go too: without INSERT, anon never draws a value.
|
|
17
|
+
--
|
|
18
|
+
-- 2. cortex_request_log is service-role only again.
|
|
19
|
+
-- 02022 created it with REVOKE ALL FROM anon, authenticated and no API policy. The
|
|
20
|
+
-- post-apply pass of both apply paths (lib/setup/schema-apply.ts and the build hook in
|
|
21
|
+
-- tools/lib/migrate-core.mjs) then ran `grant all on all tables ... to authenticated` and
|
|
22
|
+
-- `grant select ... to anon` on every deploy, handing the table back to both roles. RLS
|
|
23
|
+
-- still returned no row, but a signed-in WRITER could issue DELETEs against the counter
|
|
24
|
+
-- that rate-limits the AI routes, and tools/scripts/security-verify.sql failed three checks
|
|
25
|
+
-- on any deployed database. Both apply paths now repeat this revoke (API_ROLE_REVOKE_SQL).
|
|
26
|
+
--
|
|
27
|
+
-- 3. profiles.role is pinned by grants, not only by policy and trigger.
|
|
28
|
+
-- The column was protected by profiles_update_policy's WITH CHECK and the
|
|
29
|
+
-- protect_profile_role trigger (02021). authenticated now holds INSERT and UPDATE on every
|
|
30
|
+
-- profiles column EXCEPT `role` (and `id` on UPDATE), so a policy regression can no longer
|
|
31
|
+
-- reopen self-promotion to ADMIN. User-session writes only ever touch full_name,
|
|
32
|
+
-- avatar_url, website, phone and updated_at (account page, checkout autofill); role changes
|
|
33
|
+
-- go through the service role in app/cms/users/actions.ts, which these grants do not
|
|
34
|
+
-- affect. DELETE, REFERENCES and TRIGGER on profiles are revoked from the API roles: no
|
|
35
|
+
-- policy grants DELETE to them and account deletion runs through auth.admin. The column
|
|
36
|
+
-- list is read from the catalog so a column added later keeps today's behaviour.
|
|
37
|
+
--
|
|
38
|
+
-- Both apply paths repeat all three after their blanket grants, so a deploy cannot undo
|
|
39
|
+
-- them; tools/scripts/security-verify.sql asserts them. Idempotent: safe to re-run.
|
|
40
|
+
|
|
41
|
+
-- 1. anon: read-only at the grant level
|
|
42
|
+
REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public FROM anon;
|
|
43
|
+
REVOKE USAGE, UPDATE ON ALL SEQUENCES IN SCHEMA public FROM anon;
|
|
44
|
+
|
|
45
|
+
-- 2. cortex_request_log: service_role only (its SELECT, INSERT, DELETE grant from 02022 stays)
|
|
46
|
+
REVOKE ALL ON TABLE public.cortex_request_log FROM PUBLIC, anon, authenticated;
|
|
47
|
+
|
|
48
|
+
-- 3. profiles: every column but `role` (and `id` on UPDATE) for authenticated
|
|
49
|
+
DO $nb_profiles$
|
|
50
|
+
DECLARE
|
|
51
|
+
v_insert_cols text;
|
|
52
|
+
v_update_cols text;
|
|
53
|
+
BEGIN
|
|
54
|
+
SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FILTER (WHERE a.attname <> 'role'),
|
|
55
|
+
string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FILTER (WHERE a.attname NOT IN ('id', 'role'))
|
|
56
|
+
INTO v_insert_cols, v_update_cols
|
|
57
|
+
FROM pg_attribute a
|
|
58
|
+
WHERE a.attrelid = 'public.profiles'::regclass AND a.attnum > 0 AND NOT a.attisdropped;
|
|
59
|
+
|
|
60
|
+
-- Revoking the table-level privilege also revokes every column-level grant of that kind,
|
|
61
|
+
-- so the column grants below are the complete list afterwards.
|
|
62
|
+
REVOKE INSERT, UPDATE, DELETE, REFERENCES, TRIGGER ON TABLE public.profiles FROM PUBLIC, anon, authenticated;
|
|
63
|
+
EXECUTE format('GRANT INSERT (%s) ON TABLE public.profiles TO authenticated', v_insert_cols);
|
|
64
|
+
EXECUTE format('GRANT UPDATE (%s) ON TABLE public.profiles TO authenticated', v_update_cols);
|
|
65
|
+
END
|
|
66
|
+
$nb_profiles$;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
-- 02026: authenticated holds no table write privilege that no RLS policy lets it use
|
|
2
|
+
-- (2026-10-21, the follow-up 02025 left open).
|
|
3
|
+
--
|
|
4
|
+
-- 02025 made anon read-only at the grant level. authenticated still held SELECT, INSERT,
|
|
5
|
+
-- UPDATE, DELETE, REFERENCES and TRIGGER on every table in `public` (the baseline 02003, then
|
|
6
|
+
-- `grant all on all tables in schema public to authenticated` in the post-apply pass on every
|
|
7
|
+
-- deploy). On the tables below RLS was the only barrier between a signed-in session and a
|
|
8
|
+
-- write: no permissive policy for authenticated or PUBLIC covers the listed commands, only a
|
|
9
|
+
-- `TO service_role` policy or none at all. Every app write to them runs server-side with the
|
|
10
|
+
-- service role after its own checks: 2FA challenges, trusted devices and security settings
|
|
11
|
+
-- (lib/auth, settings/security), consent logs, contact-seller enquiries and message threads,
|
|
12
|
+
-- UCP carts, coupon redemptions (checkout providers), license activations (lib/packages),
|
|
13
|
+
-- the Freemius catalog sync, the update checker's alerts and Cortex's mutation audit.
|
|
14
|
+
--
|
|
15
|
+
-- Behaviour-neutral by construction: authenticated could never use a revoked privilege. Its
|
|
16
|
+
-- INSERT failed the RLS check and its UPDATE or DELETE matched no row; now the grant refuses
|
|
17
|
+
-- the statement before RLS is consulted, and no row any request reaches changes. (One CMS
|
|
18
|
+
-- action, updateFreemiusOverride in libs/ecommerce, updates freemius_pricing through the
|
|
19
|
+
-- cookie client: RLS has always discarded that UPDATE while the action reported success, and
|
|
20
|
+
-- it now reports the permission error instead.) Every command that has a policy keeps its
|
|
21
|
+
-- grant: the ADMIN UPDATE on message_threads, product_inquiries and system_alerts, the ADMIN
|
|
22
|
+
-- INSERT on site_script_revisions, the owner DELETE on user_trusted_devices, and every SELECT.
|
|
23
|
+
-- The pre-flight block re-checks that against pg_policies on the target database and aborts
|
|
24
|
+
-- without changing anything if a policy has appeared since this list was taken (a replay
|
|
25
|
+
-- through 02025 plus the post-apply pass, 2026-10-21).
|
|
26
|
+
--
|
|
27
|
+
-- REFERENCES and TRIGGER go from every table, as 02025 did for anon: they only let a role
|
|
28
|
+
-- create a foreign key or a trigger, and no API request runs DDL. Foreign-key actions are not
|
|
29
|
+
-- affected by any of this: ON DELETE CASCADE / SET NULL run as the owner of the referencing
|
|
30
|
+
-- table, so an ADMIN deleting a product still removes its freemius_plans and freemius_pricing
|
|
31
|
+
-- rows, and deleting an order still removes its coupon_redemptions.
|
|
32
|
+
--
|
|
33
|
+
-- Both apply paths repeat these revokes after their blanket grants (API_ROLE_REVOKE_SQL). To
|
|
34
|
+
-- give authenticated one of these privileges later, add the policy and the GRANT in the same
|
|
35
|
+
-- migration and take the table out of both copies of that list, or the next deploy revokes it
|
|
36
|
+
-- again. tools/scripts/security-verify.sql sections 26-27 fail on a write grant no policy uses
|
|
37
|
+
-- and on a write policy whose grant is missing. Idempotent: safe to re-run.
|
|
38
|
+
|
|
39
|
+
DO $nb_preflight$
|
|
40
|
+
DECLARE
|
|
41
|
+
offenders text;
|
|
42
|
+
BEGIN
|
|
43
|
+
SELECT string_agg(format('%s %s (policy %s)', l.tbl, r.cmd, p.policyname), ', ' ORDER BY l.tbl, r.cmd, p.policyname)
|
|
44
|
+
INTO offenders
|
|
45
|
+
FROM (VALUES
|
|
46
|
+
('cortex_ai_db_mutation_audit', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
47
|
+
('coupon_redemptions', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
48
|
+
('email_2fa_challenges', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
49
|
+
('freemius_plans', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
50
|
+
('freemius_pricing', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
51
|
+
('package_activations', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
52
|
+
('privacy_consent_logs', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
53
|
+
('thread_messages', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
54
|
+
('ucp_cart_sessions', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
55
|
+
('user_security_settings', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
56
|
+
('message_threads', ARRAY['INSERT', 'DELETE']),
|
|
57
|
+
('product_inquiries', ARRAY['INSERT', 'DELETE']),
|
|
58
|
+
('system_alerts', ARRAY['INSERT', 'DELETE']),
|
|
59
|
+
('site_script_revisions', ARRAY['UPDATE', 'DELETE']),
|
|
60
|
+
('user_trusted_devices', ARRAY['INSERT', 'UPDATE'])
|
|
61
|
+
) AS l(tbl, cmds)
|
|
62
|
+
CROSS JOIN LATERAL unnest(l.cmds) AS r(cmd)
|
|
63
|
+
JOIN pg_policies p
|
|
64
|
+
ON p.schemaname = 'public' AND p.tablename = l.tbl
|
|
65
|
+
AND p.permissive = 'PERMISSIVE' AND p.cmd IN (r.cmd, 'ALL')
|
|
66
|
+
AND p.roles && ARRAY['authenticated', 'public']::name[];
|
|
67
|
+
IF offenders IS NOT NULL THEN
|
|
68
|
+
RAISE EXCEPTION '02026 would revoke a privilege that an RLS policy lets authenticated use: %. Nothing was changed; drop that table/command from this file and from API_ROLE_REVOKE_SQL, or drop the policy.', offenders;
|
|
69
|
+
END IF;
|
|
70
|
+
END
|
|
71
|
+
$nb_preflight$;
|
|
72
|
+
|
|
73
|
+
-- 1. writes that no policy lets authenticated use
|
|
74
|
+
REVOKE INSERT, UPDATE, DELETE ON TABLE
|
|
75
|
+
public.cortex_ai_db_mutation_audit,
|
|
76
|
+
public.coupon_redemptions,
|
|
77
|
+
public.email_2fa_challenges,
|
|
78
|
+
public.freemius_plans,
|
|
79
|
+
public.freemius_pricing,
|
|
80
|
+
public.package_activations,
|
|
81
|
+
public.privacy_consent_logs,
|
|
82
|
+
public.thread_messages,
|
|
83
|
+
public.ucp_cart_sessions,
|
|
84
|
+
public.user_security_settings
|
|
85
|
+
FROM authenticated;
|
|
86
|
+
REVOKE INSERT, DELETE ON TABLE public.message_threads, public.product_inquiries, public.system_alerts FROM authenticated;
|
|
87
|
+
REVOKE UPDATE, DELETE ON TABLE public.site_script_revisions FROM authenticated;
|
|
88
|
+
REVOKE INSERT, UPDATE ON TABLE public.user_trusted_devices FROM authenticated;
|
|
89
|
+
|
|
90
|
+
-- 2. REFERENCES / TRIGGER: DDL-only, unused by every API request
|
|
91
|
+
REVOKE REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public FROM authenticated;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
-- 02027: anon and authenticated hold SELECT only where an RLS policy lets them read
|
|
2
|
+
-- (2026-10-21, the read side of 02025/02026).
|
|
3
|
+
--
|
|
4
|
+
-- 02025 made anon read-only and 02026 left authenticated only the writes some policy uses.
|
|
5
|
+
-- SELECT was still granted to both roles on every table in `public` (the baseline 02003, then
|
|
6
|
+
-- `grant select on all tables in schema public to anon` and `grant all ... to authenticated` in
|
|
7
|
+
-- the post-apply pass on every deploy). On the tables below no permissive SELECT or ALL policy
|
|
8
|
+
-- reaches the role (or PUBLIC), so RLS was the only barrier between the public key and orders,
|
|
9
|
+
-- profiles, coupons, licenses, MCP tokens, revisions, drafts, 2FA challenges, carts and
|
|
10
|
+
-- messages: one permissive policy added by mistake, or a table created with RLS off, would
|
|
11
|
+
-- have published it to anyone holding that key.
|
|
12
|
+
--
|
|
13
|
+
-- anon: every table that only staff, an owner or the service role reads (29, listed below).
|
|
14
|
+
-- authenticated: email_2fa_challenges and ucp_cart_sessions, whose only policy is TO
|
|
15
|
+
-- service_role (lib/auth/twoFactor and the UCP routes read them with the service role;
|
|
16
|
+
-- 02026 already took authenticated's writes on both).
|
|
17
|
+
-- profile_cards (02021): anon loses SELECT, authenticated loses INSERT/UPDATE/DELETE. It is a
|
|
18
|
+
-- security_invoker view over profiles, so anon could only ever read it through a SELECT on
|
|
19
|
+
-- profiles, which goes here. The public author card does not read the view: author()
|
|
20
|
+
-- returns its row type from private.author_card(), a SECURITY DEFINER function, so
|
|
21
|
+
-- `cms_interactions?select=*,author(full_name,avatar_url)` keeps working for anon (checked
|
|
22
|
+
-- through PostgREST on 2026-10-21). The view is a read card; nothing writes through it.
|
|
23
|
+
--
|
|
24
|
+
-- Unlike a write, a SELECT can be needed on a table the request never names: a policy
|
|
25
|
+
-- subquery (`EXISTS (SELECT ... FROM x)`), a security_invoker view, an invoker function or a
|
|
26
|
+
-- PostgREST embed (`select=*,x(...)`) all read x as the calling role, and they fail with 42501
|
|
27
|
+
-- once the grant is gone, where RLS used to return no row (or a null embed). The census behind
|
|
28
|
+
-- this list (a replay through 02026 plus the post-apply pass, 2026-10-21) found:
|
|
29
|
+
-- - one policy that reads another of these tables, order_items' owner policy (orders): it is
|
|
30
|
+
-- TO authenticated, which keeps SELECT on orders;
|
|
31
|
+
-- - one view, profile_cards (above);
|
|
32
|
+
-- - invoker functions reading them: apply_order_inventory_deduction (orders, order_items),
|
|
33
|
+
-- called by the app only with the service role and already refused to anon by its
|
|
34
|
+
-- SELECT ... FOR UPDATE since 02025 (FOR UPDATE needs UPDATE), and the trigger functions of
|
|
35
|
+
-- site_script_revisions / thread_messages, which fire only on writes anon cannot make;
|
|
36
|
+
-- - no anonymous query in the app (getSsgSupabaseClient, the browser client, the cookie client
|
|
37
|
+
-- on signed-out paths) embeds a listed table; the public author card goes through author().
|
|
38
|
+
-- The anonymous paths that need one of these tables (checkout success, order access tokens,
|
|
39
|
+
-- contact seller, threads, consent, forms, UCP, MCP tokens, the sign-up read of
|
|
40
|
+
-- system_configuration) read it with the service role after their own checks. The few
|
|
41
|
+
-- cookie-client reads a signed-out request can reach (a draft lookup in draft mode or with
|
|
42
|
+
-- NEXTBLOCK_VISUAL_EDITING_ENABLED, verifyPackageOnline on an install with no service key,
|
|
43
|
+
-- the MCP route's revision capture, a custom block's `profiles` relation) already got no
|
|
44
|
+
-- row and handle the error the same way: no draft, not licensed, no revision, no record.
|
|
45
|
+
-- Behaviour-neutral for every request that worked: an anon or authenticated read of a listed
|
|
46
|
+
-- table returned no row before and is refused by the grant now. The pre-flight block re-checks
|
|
47
|
+
-- the catalog side on the target database and aborts without changing anything if a policy
|
|
48
|
+
-- lets the role read a listed table, if a policy the role is subject to reads one, or if a view
|
|
49
|
+
-- the role can read depends on one.
|
|
50
|
+
--
|
|
51
|
+
-- Both apply paths repeat these revokes after their blanket grants (API_ROLE_REVOKE_SQL). To let
|
|
52
|
+
-- one of these roles read a listed table later, add the policy and GRANT SELECT in the same
|
|
53
|
+
-- migration and take the table out of both copies of that list, or the next deploy revokes it
|
|
54
|
+
-- again. tools/scripts/security-verify.sql sections 28-29 fail on a SELECT grant no policy uses
|
|
55
|
+
-- and on a read policy whose grant is missing. Idempotent: safe to re-run.
|
|
56
|
+
|
|
57
|
+
DO $nb_preflight$
|
|
58
|
+
DECLARE
|
|
59
|
+
offenders text;
|
|
60
|
+
BEGIN
|
|
61
|
+
WITH l(role, tbl) AS (
|
|
62
|
+
SELECT 'anon'::name, t
|
|
63
|
+
FROM unnest(ARRAY[
|
|
64
|
+
'content_drafts', 'cortex_ai_db_mutation_audit', 'coupon_freemius_mappings',
|
|
65
|
+
'coupon_products', 'coupon_redemptions', 'coupons', 'email_2fa_challenges',
|
|
66
|
+
'form_endpoints', 'mcp_access_tokens', 'message_threads', 'order_items', 'orders',
|
|
67
|
+
'package_activations', 'page_revisions', 'post_revisions', 'privacy_consent_logs',
|
|
68
|
+
'product_drafts', 'product_freemius_sale_coupons', 'product_inquiries',
|
|
69
|
+
'product_revisions', 'profile_cards', 'profiles', 'site_script_revisions',
|
|
70
|
+
'system_alerts', 'system_configuration', 'thread_messages', 'ucp_cart_sessions',
|
|
71
|
+
'user_addresses', 'user_security_settings', 'user_trusted_devices']::name[]) AS t
|
|
72
|
+
UNION ALL
|
|
73
|
+
SELECT 'authenticated'::name, t
|
|
74
|
+
FROM unnest(ARRAY['email_2fa_challenges', 'ucp_cart_sessions']::name[]) AS t
|
|
75
|
+
), r AS (
|
|
76
|
+
SELECT l.role, l.tbl, to_regclass('public.' || quote_ident(l.tbl)) AS oid FROM l
|
|
77
|
+
), hits(what) AS (
|
|
78
|
+
-- a policy that lets the role read a listed table
|
|
79
|
+
SELECT format('%s reads %s through policy %s', r.role, r.tbl, p.policyname)
|
|
80
|
+
FROM r
|
|
81
|
+
JOIN pg_policies p
|
|
82
|
+
ON p.schemaname = 'public' AND p.tablename = r.tbl
|
|
83
|
+
AND p.permissive = 'PERMISSIVE' AND p.cmd IN ('SELECT', 'ALL')
|
|
84
|
+
AND p.roles && ARRAY[r.role, 'public']::name[]
|
|
85
|
+
UNION ALL
|
|
86
|
+
-- a policy the role is subject to, on any other table, whose expression reads a listed table
|
|
87
|
+
SELECT format('policy %s on %s reads %s as %s', pol.polname, pol.polrelid::regclass, r.tbl, r.role)
|
|
88
|
+
FROM r
|
|
89
|
+
JOIN pg_depend d
|
|
90
|
+
ON d.classid = 'pg_policy'::regclass AND d.refclassid = 'pg_class'::regclass AND d.refobjid = r.oid
|
|
91
|
+
JOIN pg_policy pol ON pol.oid = d.objid
|
|
92
|
+
WHERE pol.polrelid <> r.oid
|
|
93
|
+
AND (0::oid = ANY (pol.polroles) OR (SELECT oid FROM pg_roles WHERE rolname = r.role) = ANY (pol.polroles))
|
|
94
|
+
UNION ALL
|
|
95
|
+
-- a view the role keeps SELECT on that reads a listed table
|
|
96
|
+
SELECT format('view %s reads %s and stays readable by %s', rw.ev_class::regclass, r.tbl, r.role)
|
|
97
|
+
FROM r
|
|
98
|
+
JOIN pg_depend d
|
|
99
|
+
ON d.classid = 'pg_rewrite'::regclass AND d.refclassid = 'pg_class'::regclass AND d.refobjid = r.oid
|
|
100
|
+
JOIN pg_rewrite rw ON rw.oid = d.objid
|
|
101
|
+
WHERE rw.ev_class <> r.oid
|
|
102
|
+
AND has_table_privilege(r.role, rw.ev_class, 'SELECT')
|
|
103
|
+
AND NOT EXISTS (SELECT 1 FROM r r2 WHERE r2.role = r.role AND r2.oid = rw.ev_class)
|
|
104
|
+
)
|
|
105
|
+
SELECT string_agg(DISTINCT what, '; ') INTO offenders FROM hits;
|
|
106
|
+
IF offenders IS NOT NULL THEN
|
|
107
|
+
RAISE EXCEPTION '02027 would revoke a SELECT that a request still needs: %. Nothing was changed; drop that table from this file and from API_ROLE_REVOKE_SQL, or change the policy or view.', offenders;
|
|
108
|
+
END IF;
|
|
109
|
+
END
|
|
110
|
+
$nb_preflight$;
|
|
111
|
+
|
|
112
|
+
-- 1. anon: SELECT on every table no policy lets anon read
|
|
113
|
+
REVOKE SELECT ON TABLE
|
|
114
|
+
public.content_drafts,
|
|
115
|
+
public.cortex_ai_db_mutation_audit,
|
|
116
|
+
public.coupon_freemius_mappings,
|
|
117
|
+
public.coupon_products,
|
|
118
|
+
public.coupon_redemptions,
|
|
119
|
+
public.coupons,
|
|
120
|
+
public.email_2fa_challenges,
|
|
121
|
+
public.form_endpoints,
|
|
122
|
+
public.mcp_access_tokens,
|
|
123
|
+
public.message_threads,
|
|
124
|
+
public.order_items,
|
|
125
|
+
public.orders,
|
|
126
|
+
public.package_activations,
|
|
127
|
+
public.page_revisions,
|
|
128
|
+
public.post_revisions,
|
|
129
|
+
public.privacy_consent_logs,
|
|
130
|
+
public.product_drafts,
|
|
131
|
+
public.product_freemius_sale_coupons,
|
|
132
|
+
public.product_inquiries,
|
|
133
|
+
public.product_revisions,
|
|
134
|
+
public.profiles,
|
|
135
|
+
public.site_script_revisions,
|
|
136
|
+
public.system_alerts,
|
|
137
|
+
public.system_configuration,
|
|
138
|
+
public.thread_messages,
|
|
139
|
+
public.ucp_cart_sessions,
|
|
140
|
+
public.user_addresses,
|
|
141
|
+
public.user_security_settings,
|
|
142
|
+
public.user_trusted_devices
|
|
143
|
+
FROM anon;
|
|
144
|
+
|
|
145
|
+
-- 2. authenticated: the two tables only the service role reads
|
|
146
|
+
REVOKE SELECT ON TABLE public.email_2fa_challenges, public.ucp_cart_sessions FROM authenticated;
|
|
147
|
+
|
|
148
|
+
-- 3. profile_cards: a read card for staff and owners (through profiles' policies)
|
|
149
|
+
REVOKE SELECT ON TABLE public.profile_cards FROM anon;
|
|
150
|
+
REVOKE INSERT, UPDATE, DELETE ON TABLE public.profile_cards FROM authenticated;
|