@nextblock-cms/db 0.22.0 → 0.22.2
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/lib/supabase/server.d.ts +151 -2
- package/lib/supabase/types.d.ts +160 -2
- package/package.json +1 -1
- package/supabase/functions/nextblock-migration-ingest/index.ts +64 -1
- package/supabase/migrations/02022_security_hardening_writer_boundary_and_aal.sql +504 -0
- package/supabase/migrations/02023_aal2_for_orders_profiles_site_settings.sql +171 -0
- package/supabase/migrations/02024_advisor_trigger_grants_and_pg_net_schema.sql +113 -0
|
@@ -7,6 +7,58 @@ const corsHeaders = {
|
|
|
7
7
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Dedicated bearer for this function. It runs with the service-role key (which bypasses RLS) and
|
|
12
|
+
* can wipe content, mint confirmed users and fetch arbitrary URLs, so it must NEVER be reachable
|
|
13
|
+
* with the public anon JWT (the platform `verify_jwt` default accepts the anon key, which ships in
|
|
14
|
+
* every site bundle). The operator sets MIGRATION_INGEST_BEARER to a high-entropy secret that is
|
|
15
|
+
* NOT the anon or service key; the function is fail-closed — with the var unset it rejects every
|
|
16
|
+
* request, because this is a maintainer migration tool, not a public endpoint.
|
|
17
|
+
*/
|
|
18
|
+
function isAuthorizedIngestRequest(req: Request): boolean {
|
|
19
|
+
const expected = (Deno.env.get('MIGRATION_INGEST_BEARER') ?? '').trim();
|
|
20
|
+
if (!expected) return false;
|
|
21
|
+
const provided = (req.headers.get('Authorization') ?? '').replace(/^Bearer\s+/i, '').trim();
|
|
22
|
+
if (!provided || provided.length !== expected.length) return false;
|
|
23
|
+
// constant-time compare
|
|
24
|
+
let diff = 0;
|
|
25
|
+
for (let i = 0; i < expected.length; i += 1) diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
26
|
+
return diff === 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* SSRF guard for the outbound media fetch. Blocks obvious private, loopback, link-local and
|
|
31
|
+
* cloud-metadata destinations and requires http(s). Defence in depth behind the bearer: an
|
|
32
|
+
* authorized caller still should not be able to turn this into an internal port scanner or a
|
|
33
|
+
* cloud-credential exfiltrator. (DNS rebinding is further limited by redirect:'error' on the
|
|
34
|
+
* fetch; full rebind protection would require pinning the resolved IP, which the edge runtime
|
|
35
|
+
* does not expose.)
|
|
36
|
+
*/
|
|
37
|
+
function isBlockedFetchTarget(rawUrl: string): boolean {
|
|
38
|
+
let u: URL;
|
|
39
|
+
try {
|
|
40
|
+
u = new URL(rawUrl);
|
|
41
|
+
} catch {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return true;
|
|
45
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
46
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal')) return true;
|
|
47
|
+
// IPv6 loopback / link-local / unique-local
|
|
48
|
+
if (host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) return true;
|
|
49
|
+
// IPv4 literals in private / loopback / link-local / metadata ranges
|
|
50
|
+
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
51
|
+
if (m) {
|
|
52
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
53
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
54
|
+
if (a === 169 && b === 254) return true; // link-local incl. 169.254.169.254 metadata
|
|
55
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
56
|
+
if (a === 192 && b === 168) return true;
|
|
57
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
10
62
|
// Upload S3 / R2 Proxy Handler
|
|
11
63
|
async function processMediaUpload(url: string, r2Config: any, warnings: string[]): Promise<{ publicUrl: string; objectKey: string; contentType: string } | null> {
|
|
12
64
|
if (!r2Config || !r2Config.account_id) {
|
|
@@ -37,11 +89,15 @@ async function processMediaUpload(url: string, r2Config: any, warnings: string[]
|
|
|
37
89
|
const extension = contentType.split('/')[1] || 'jpg';
|
|
38
90
|
filename = `upload_${crypto.randomUUID()}.${extension}`;
|
|
39
91
|
} else {
|
|
92
|
+
if (isBlockedFetchTarget(url)) {
|
|
93
|
+
warnings.push(`Refused to fetch blocked/internal URL: ${url}`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
40
96
|
const controller = new AbortController();
|
|
41
97
|
const timeoutId = setTimeout(() => controller.abort(), 6000); // 6-second timeout
|
|
42
98
|
let fileResponse;
|
|
43
99
|
try {
|
|
44
|
-
fileResponse = await fetch(url, { signal: controller.signal });
|
|
100
|
+
fileResponse = await fetch(url, { signal: controller.signal, redirect: 'error' });
|
|
45
101
|
clearTimeout(timeoutId);
|
|
46
102
|
} catch (err: any) {
|
|
47
103
|
clearTimeout(timeoutId);
|
|
@@ -91,6 +147,13 @@ Deno.serve(async (req: Request) => {
|
|
|
91
147
|
return new Response('ok', { headers: corsHeaders });
|
|
92
148
|
}
|
|
93
149
|
|
|
150
|
+
if (!isAuthorizedIngestRequest(req)) {
|
|
151
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
152
|
+
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' },
|
|
153
|
+
status: 401,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
94
157
|
try {
|
|
95
158
|
const supabaseClient = createClient(
|
|
96
159
|
Deno.env.get('SUPABASE_URL') ?? '',
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
-- 02022: close the privilege and disclosure holes the 2026-10-19 red-team confirmed on a local
|
|
2
|
+
-- replay of this folder. Append-only, idempotent, forward-only (02021 is the prior fix; see its
|
|
3
|
+
-- header and docs/04 "Row Level Security Patterns"). Every policy is dropped by name before it is
|
|
4
|
+
-- recreated so a generation-1 policy under a different name cannot keep the old behaviour.
|
|
5
|
+
--
|
|
6
|
+
-- What this migration fixes (DB layer only; the matching app-code changes ship in the same PR):
|
|
7
|
+
--
|
|
8
|
+
-- 1. site_settings write tiers. 02021 tiered READS by key but WRITES still let any WRITER write
|
|
9
|
+
-- every non-"secret" key. Four of those keys are security-sensitive sinks a WRITER could abuse
|
|
10
|
+
-- over PostgREST with only a first-factor session:
|
|
11
|
+
-- * privacy_settings.custom_scripts -> site-wide stored XSS (executes for visitors and in /cms)
|
|
12
|
+
-- * global_css -> site-wide raw <style> injection (incl. /cms)
|
|
13
|
+
-- * is_admin_created -> reset it to re-open the first-admin bootstrap
|
|
14
|
+
-- (WRITER -> ADMIN) AND to funnel all traffic to /setup
|
|
15
|
+
-- (full-site DoS; proxy.ts reads it)
|
|
16
|
+
-- * security_settings -> flip enforce_staff_2fa / stretch trusted_device_days
|
|
17
|
+
-- They move into the ADMIN-only branch of the three write policies. Reads are unchanged
|
|
18
|
+
-- (02021 still lets anon read privacy_settings/global_css/is_admin_created for the public site
|
|
19
|
+
-- and the /setup redirect; security_settings stays ADMIN/WRITER-read).
|
|
20
|
+
--
|
|
21
|
+
-- 2. handle_new_user() becomes a true one-shot. The baseline granted ADMIN to a new sign-up
|
|
22
|
+
-- whenever site_settings.is_admin_created was false, trusting a mutable flag. Now it also
|
|
23
|
+
-- requires that NO admin profile exists yet, so resetting the flag (section 1 already blocks
|
|
24
|
+
-- that for a WRITER, this is defense in depth) or a concurrent first-boot race can never mint a
|
|
25
|
+
-- second ADMIN. Self-heals the flag either way. service_role/postgres writes are unaffected
|
|
26
|
+
-- (the trigger runs as the table owner regardless of caller).
|
|
27
|
+
--
|
|
28
|
+
-- 3. package_activations holds PLAINTEXT license keys. 02014 let ADMIN *and* WRITER read them;
|
|
29
|
+
-- a content-role account does not need vendor license keys. Read drops to ADMIN, and is gated
|
|
30
|
+
-- by a satisfied second factor (section 5).
|
|
31
|
+
--
|
|
32
|
+
-- 4. Scheduled/draft/archived product rows still leaked their media links and category links to
|
|
33
|
+
-- anon (02021 re-cut products + product_variants but not these), and inventory_items published
|
|
34
|
+
-- every SKU's stock. Each anon read is scoped to a product that is actually live, matching the
|
|
35
|
+
-- product_variants policy 02021 already ships. Staff still read everything.
|
|
36
|
+
--
|
|
37
|
+
-- 5. aal2-in-RLS for the sharpest ADMIN-only tables. The app enforces 2FA only in proxy.ts /
|
|
38
|
+
-- app/cms/layout.tsx, so an ADMIN session JWT used directly against PostgREST reaches every
|
|
39
|
+
-- ADMIN-gated table with a first factor alone. private.meets_aal_requirement() + the
|
|
40
|
+
-- public.is_admin_mfa() invoker wrapper (same private/public split as 02021's role helpers, to
|
|
41
|
+
-- keep advisor lints 0028/0029 quiet) require aal2 ONLY for a user who actually enrolled a
|
|
42
|
+
-- factor, so a no-MFA admin is never locked out. Applied here to mcp_access_tokens (self-minting
|
|
43
|
+
-- a usable bearer is a persistence primitive) and package_activations (plaintext keys). Widening
|
|
44
|
+
-- is_admin_mfa() to the other ADMIN policies is a larger, separately-reviewed change (docs/04).
|
|
45
|
+
--
|
|
46
|
+
-- 6. email_2fa_challenges.attempts: a failure counter so lib/auth/twoFactor.ts can lock a code
|
|
47
|
+
-- pool after a few wrong guesses (the email factor had no attempt limit).
|
|
48
|
+
--
|
|
49
|
+
-- 7. ucp_cart_sessions.access_token_hash: sha256 of a per-cart capability token the UCP routes
|
|
50
|
+
-- now require, so a cart UUID alone no longer reads/overwrites/cancels a cart (buyer PII).
|
|
51
|
+
--
|
|
52
|
+
-- 8. redeem_coupon_atomically(uuid): one locked UPDATE that re-checks redemption_limit and
|
|
53
|
+
-- increments redemptions_count in SQL (the app's read-then-write lost updates past the cap).
|
|
54
|
+
-- service_role-only.
|
|
55
|
+
--
|
|
56
|
+
-- 9. media update/delete bound to the uploader: a WRITER touches only rows they uploaded (and
|
|
57
|
+
-- cannot re-assign uploader_id); ADMIN keeps every row.
|
|
58
|
+
--
|
|
59
|
+
-- 10. cortex_request_log: service-role-only per-user accounting the /api/ai/* routes use to cap
|
|
60
|
+
-- a WRITER's OpenRouter spend (no rate or budget cap existed).
|
|
61
|
+
|
|
62
|
+
-- ---------------------------------------------------------------------------
|
|
63
|
+
-- 1. site_settings: move four sensitive keys into the ADMIN-only write tier
|
|
64
|
+
-- ---------------------------------------------------------------------------
|
|
65
|
+
DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
|
|
66
|
+
CREATE POLICY site_settings_insert_policy ON public.site_settings
|
|
67
|
+
FOR INSERT TO authenticated
|
|
68
|
+
WITH CHECK (
|
|
69
|
+
(key <> ALL (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
|
|
70
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
71
|
+
OR (key = ANY (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
|
|
72
|
+
AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
|
|
76
|
+
CREATE POLICY site_settings_update_policy ON public.site_settings
|
|
77
|
+
FOR UPDATE TO authenticated
|
|
78
|
+
USING (
|
|
79
|
+
(key <> ALL (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
|
|
80
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
81
|
+
OR (key = ANY (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
|
|
82
|
+
AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
|
|
83
|
+
)
|
|
84
|
+
WITH CHECK (
|
|
85
|
+
(key <> ALL (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
|
|
86
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
87
|
+
OR (key = ANY (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
|
|
88
|
+
AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
|
|
92
|
+
CREATE POLICY site_settings_delete_policy ON public.site_settings
|
|
93
|
+
FOR DELETE TO authenticated
|
|
94
|
+
USING (
|
|
95
|
+
(key <> ALL (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
|
|
96
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
97
|
+
OR (key = ANY (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
|
|
98
|
+
AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
-- ---------------------------------------------------------------------------
|
|
102
|
+
-- 2. handle_new_user(): ADMIN only for the genuine first account, never again
|
|
103
|
+
-- ---------------------------------------------------------------------------
|
|
104
|
+
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
|
105
|
+
RETURNS trigger
|
|
106
|
+
LANGUAGE plpgsql
|
|
107
|
+
SECURITY DEFINER
|
|
108
|
+
SET search_path TO 'public'
|
|
109
|
+
AS $function$
|
|
110
|
+
DECLARE
|
|
111
|
+
admin_flag_set boolean := false;
|
|
112
|
+
admin_exists boolean := false;
|
|
113
|
+
user_role public.user_role;
|
|
114
|
+
v_full_name text;
|
|
115
|
+
v_avatar_url text;
|
|
116
|
+
BEGIN
|
|
117
|
+
INSERT INTO public.site_settings (key, value)
|
|
118
|
+
VALUES ('is_admin_created', 'false'::jsonb)
|
|
119
|
+
ON CONFLICT (key) DO NOTHING;
|
|
120
|
+
|
|
121
|
+
SELECT COALESCE(value::jsonb::boolean, false)
|
|
122
|
+
INTO admin_flag_set
|
|
123
|
+
FROM public.site_settings
|
|
124
|
+
WHERE key = 'is_admin_created'
|
|
125
|
+
FOR UPDATE;
|
|
126
|
+
|
|
127
|
+
-- The flag alone is not trusted: a reset (now ADMIN-only to write, but defense in depth) or a
|
|
128
|
+
-- concurrent first-boot must never mint a second ADMIN. Promotion requires BOTH that the flag is
|
|
129
|
+
-- unset AND that no admin profile exists yet. The FOR UPDATE above serialises concurrent sign-ups.
|
|
130
|
+
SELECT EXISTS (SELECT 1 FROM public.profiles WHERE role = 'ADMIN'::public.user_role)
|
|
131
|
+
INTO admin_exists;
|
|
132
|
+
|
|
133
|
+
IF admin_flag_set = false AND admin_exists = false THEN
|
|
134
|
+
user_role := 'ADMIN'::public.user_role;
|
|
135
|
+
ELSE
|
|
136
|
+
user_role := 'USER'::public.user_role;
|
|
137
|
+
END IF;
|
|
138
|
+
|
|
139
|
+
-- Always mark bootstrap complete once any account is created, so a drifted/reset flag self-heals.
|
|
140
|
+
UPDATE public.site_settings
|
|
141
|
+
SET value = 'true'::jsonb
|
|
142
|
+
WHERE key = 'is_admin_created' AND value IS DISTINCT FROM 'true'::jsonb;
|
|
143
|
+
|
|
144
|
+
v_full_name := NEW.raw_user_meta_data->>'full_name';
|
|
145
|
+
v_avatar_url := NEW.raw_user_meta_data->>'avatar_url';
|
|
146
|
+
|
|
147
|
+
INSERT INTO public.profiles (id, role, full_name, avatar_url)
|
|
148
|
+
VALUES (NEW.id, user_role, v_full_name, v_avatar_url)
|
|
149
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
150
|
+
full_name = EXCLUDED.full_name,
|
|
151
|
+
avatar_url = EXCLUDED.avatar_url;
|
|
152
|
+
|
|
153
|
+
RETURN NEW;
|
|
154
|
+
END;
|
|
155
|
+
$function$;
|
|
156
|
+
|
|
157
|
+
-- ---------------------------------------------------------------------------
|
|
158
|
+
-- 5a. aal2-in-RLS helpers (defined before section 3/5b use them)
|
|
159
|
+
-- ---------------------------------------------------------------------------
|
|
160
|
+
-- True unless the caller has a verified MFA factor but the current session is not aal2. A user
|
|
161
|
+
-- who never enrolled a factor always passes, so no admin is ever locked out by enabling this.
|
|
162
|
+
CREATE OR REPLACE FUNCTION private.meets_aal_requirement()
|
|
163
|
+
RETURNS boolean
|
|
164
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
165
|
+
SET search_path TO ''
|
|
166
|
+
AS $$
|
|
167
|
+
SELECT COALESCE((auth.jwt() ->> 'aal'), 'aal1') = 'aal2'
|
|
168
|
+
OR NOT EXISTS (
|
|
169
|
+
SELECT 1 FROM auth.mfa_factors f
|
|
170
|
+
WHERE f.user_id = auth.uid() AND f.status = 'verified'
|
|
171
|
+
);
|
|
172
|
+
$$;
|
|
173
|
+
REVOKE ALL ON FUNCTION private.meets_aal_requirement() FROM PUBLIC;
|
|
174
|
+
GRANT EXECUTE ON FUNCTION private.meets_aal_requirement() TO anon, authenticated, service_role;
|
|
175
|
+
|
|
176
|
+
-- SECURITY INVOKER public wrapper (never a SECURITY DEFINER function in public — advisor 0028/0029).
|
|
177
|
+
CREATE OR REPLACE FUNCTION public.is_admin_mfa()
|
|
178
|
+
RETURNS boolean
|
|
179
|
+
LANGUAGE sql STABLE SECURITY INVOKER
|
|
180
|
+
SET search_path TO 'public'
|
|
181
|
+
AS $$
|
|
182
|
+
SELECT public.is_admin() AND private.meets_aal_requirement();
|
|
183
|
+
$$;
|
|
184
|
+
REVOKE ALL ON FUNCTION public.is_admin_mfa() FROM PUBLIC;
|
|
185
|
+
GRANT EXECUTE ON FUNCTION public.is_admin_mfa() TO anon, authenticated, service_role;
|
|
186
|
+
|
|
187
|
+
-- ---------------------------------------------------------------------------
|
|
188
|
+
-- 3. package_activations: ADMIN read only, second factor required
|
|
189
|
+
-- ---------------------------------------------------------------------------
|
|
190
|
+
DROP POLICY IF EXISTS "Staff read package activations" ON public.package_activations;
|
|
191
|
+
CREATE POLICY "Staff read package activations" ON public.package_activations
|
|
192
|
+
FOR SELECT TO authenticated
|
|
193
|
+
USING ((SELECT public.is_admin_mfa()));
|
|
194
|
+
|
|
195
|
+
-- ---------------------------------------------------------------------------
|
|
196
|
+
-- 4. Draft/archived/scheduled products: media, categories and stock stay staff-only
|
|
197
|
+
-- ---------------------------------------------------------------------------
|
|
198
|
+
DROP POLICY IF EXISTS "Public can view product media" ON public.product_media;
|
|
199
|
+
CREATE POLICY "Public can view product media" ON public.product_media
|
|
200
|
+
FOR SELECT TO anon, authenticated
|
|
201
|
+
USING (
|
|
202
|
+
EXISTS (
|
|
203
|
+
SELECT 1 FROM public.products p
|
|
204
|
+
WHERE p.id = product_media.product_id
|
|
205
|
+
AND p.status = 'active'
|
|
206
|
+
AND (p.published_at IS NULL OR p.published_at <= now())
|
|
207
|
+
)
|
|
208
|
+
OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role])
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
DROP POLICY IF EXISTS "Public can view product_categories" ON public.product_categories;
|
|
212
|
+
CREATE POLICY "Public can view product_categories" ON public.product_categories
|
|
213
|
+
FOR SELECT TO anon, authenticated
|
|
214
|
+
USING (
|
|
215
|
+
EXISTS (
|
|
216
|
+
SELECT 1 FROM public.products p
|
|
217
|
+
WHERE p.id = product_categories.product_id
|
|
218
|
+
AND p.status = 'active'
|
|
219
|
+
AND (p.published_at IS NULL OR p.published_at <= now())
|
|
220
|
+
)
|
|
221
|
+
OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role])
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
DROP POLICY IF EXISTS "Public can view inventory items" ON public.inventory_items;
|
|
225
|
+
CREATE POLICY "Public can view inventory items" ON public.inventory_items
|
|
226
|
+
FOR SELECT TO anon, authenticated
|
|
227
|
+
USING (
|
|
228
|
+
EXISTS (
|
|
229
|
+
SELECT 1 FROM public.products p
|
|
230
|
+
WHERE p.sku = inventory_items.sku
|
|
231
|
+
AND p.status = 'active'
|
|
232
|
+
AND (p.published_at IS NULL OR p.published_at <= now())
|
|
233
|
+
)
|
|
234
|
+
OR EXISTS (
|
|
235
|
+
SELECT 1 FROM public.product_variants v
|
|
236
|
+
JOIN public.products p ON p.id = v.product_id
|
|
237
|
+
WHERE v.sku = inventory_items.sku
|
|
238
|
+
AND p.status = 'active'
|
|
239
|
+
AND (p.published_at IS NULL OR p.published_at <= now())
|
|
240
|
+
)
|
|
241
|
+
OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role])
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
-- ---------------------------------------------------------------------------
|
|
245
|
+
-- 5b. aal2 for mcp_access_tokens (token self-mint is a persistence primitive)
|
|
246
|
+
-- ---------------------------------------------------------------------------
|
|
247
|
+
DROP POLICY IF EXISTS mcp_access_tokens_admin_select ON public.mcp_access_tokens;
|
|
248
|
+
CREATE POLICY mcp_access_tokens_admin_select ON public.mcp_access_tokens
|
|
249
|
+
FOR SELECT TO authenticated USING ((SELECT public.is_admin_mfa()));
|
|
250
|
+
|
|
251
|
+
DROP POLICY IF EXISTS mcp_access_tokens_admin_insert ON public.mcp_access_tokens;
|
|
252
|
+
CREATE POLICY mcp_access_tokens_admin_insert ON public.mcp_access_tokens
|
|
253
|
+
FOR INSERT TO authenticated WITH CHECK ((SELECT public.is_admin_mfa()));
|
|
254
|
+
|
|
255
|
+
DROP POLICY IF EXISTS mcp_access_tokens_admin_update ON public.mcp_access_tokens;
|
|
256
|
+
CREATE POLICY mcp_access_tokens_admin_update ON public.mcp_access_tokens
|
|
257
|
+
FOR UPDATE TO authenticated USING ((SELECT public.is_admin_mfa())) WITH CHECK ((SELECT public.is_admin_mfa()));
|
|
258
|
+
|
|
259
|
+
DROP POLICY IF EXISTS mcp_access_tokens_admin_delete ON public.mcp_access_tokens;
|
|
260
|
+
CREATE POLICY mcp_access_tokens_admin_delete ON public.mcp_access_tokens
|
|
261
|
+
FOR DELETE TO authenticated USING ((SELECT public.is_admin_mfa()));
|
|
262
|
+
|
|
263
|
+
-- ---------------------------------------------------------------------------
|
|
264
|
+
-- 6. Email-2FA brute force: give each challenge a failure counter
|
|
265
|
+
-- ---------------------------------------------------------------------------
|
|
266
|
+
-- The email second-factor had no attempt limit: a party holding a victim's password (first
|
|
267
|
+
-- factor) could iterate all 10^6 codes against the unthrottled verify path. The counter lets
|
|
268
|
+
-- lib/auth/twoFactor.ts lock the live code pool after a few wrong guesses. Column only; no grant
|
|
269
|
+
-- needed (table already granted), and the table is service-role/owner-written.
|
|
270
|
+
ALTER TABLE public.email_2fa_challenges ADD COLUMN IF NOT EXISTS attempts integer NOT NULL DEFAULT 0;
|
|
271
|
+
|
|
272
|
+
-- Atomic failure-counter bump so concurrent wrong guesses cannot each read the same count and
|
|
273
|
+
-- overwrite one another (a read-then-write in app code is racy). Increments every live code for
|
|
274
|
+
-- the user under the row locks and returns the new maximum; the server burns the pool when that
|
|
275
|
+
-- reaches the limit. SECURITY DEFINER in `private` (never callable over /rpc) and service_role-only.
|
|
276
|
+
CREATE OR REPLACE FUNCTION private.bump_email_2fa_attempts(p_user_id uuid)
|
|
277
|
+
RETURNS integer
|
|
278
|
+
LANGUAGE sql VOLATILE SECURITY DEFINER
|
|
279
|
+
SET search_path TO ''
|
|
280
|
+
AS $$
|
|
281
|
+
WITH bumped AS (
|
|
282
|
+
UPDATE public.email_2fa_challenges
|
|
283
|
+
SET attempts = attempts + 1
|
|
284
|
+
WHERE user_id = p_user_id
|
|
285
|
+
AND consumed_at IS NULL
|
|
286
|
+
AND expires_at > now()
|
|
287
|
+
RETURNING attempts
|
|
288
|
+
)
|
|
289
|
+
SELECT COALESCE(MAX(attempts), 0)::integer FROM bumped;
|
|
290
|
+
$$;
|
|
291
|
+
REVOKE ALL ON FUNCTION private.bump_email_2fa_attempts(uuid) FROM PUBLIC, anon, authenticated;
|
|
292
|
+
GRANT EXECUTE ON FUNCTION private.bump_email_2fa_attempts(uuid) TO service_role;
|
|
293
|
+
|
|
294
|
+
-- SECURITY INVOKER public wrapper so the server can call it through PostgREST rpc with the
|
|
295
|
+
-- service-role client (the private function is not itself API-reachable). Not granted to anon.
|
|
296
|
+
CREATE OR REPLACE FUNCTION public.bump_email_2fa_attempts(p_user_id uuid)
|
|
297
|
+
RETURNS integer
|
|
298
|
+
LANGUAGE sql VOLATILE SECURITY INVOKER
|
|
299
|
+
SET search_path TO 'public'
|
|
300
|
+
AS $$
|
|
301
|
+
SELECT private.bump_email_2fa_attempts(p_user_id);
|
|
302
|
+
$$;
|
|
303
|
+
REVOKE ALL ON FUNCTION public.bump_email_2fa_attempts(uuid) FROM PUBLIC, anon, authenticated;
|
|
304
|
+
GRANT EXECUTE ON FUNCTION public.bump_email_2fa_attempts(uuid) TO service_role;
|
|
305
|
+
|
|
306
|
+
-- ---------------------------------------------------------------------------
|
|
307
|
+
-- 7. UCP carts: a per-cart capability token (ucp-cart-idor)
|
|
308
|
+
-- ---------------------------------------------------------------------------
|
|
309
|
+
-- /ucp/v1/carts/{id} authorised on the cart UUID alone through the service-role client, so anyone
|
|
310
|
+
-- who learned an id could read the buyer PII in buyer_identity, overwrite or cancel the cart. The
|
|
311
|
+
-- app now mints a random capability token when it creates a cart, returns it once, and stores
|
|
312
|
+
-- only its sha256 hex here; every later read/write must present it. Nullable so carts created
|
|
313
|
+
-- before this column stay readable by the app's legacy path until they expire (7 days). Column
|
|
314
|
+
-- only; the table is already granted and its sole policy is service_role-only (anon /
|
|
315
|
+
-- authenticated hold table grants but no policy, so RLS denies them every row).
|
|
316
|
+
ALTER TABLE public.ucp_cart_sessions ADD COLUMN IF NOT EXISTS access_token_hash text;
|
|
317
|
+
|
|
318
|
+
-- ---------------------------------------------------------------------------
|
|
319
|
+
-- 8. Coupons: atomic, limit-aware redemption counter (coupon-redemption-race)
|
|
320
|
+
-- ---------------------------------------------------------------------------
|
|
321
|
+
-- libs/ecommerce coupon-server.ts incremented redemptions_count with a SELECT then an UPDATE that
|
|
322
|
+
-- wrote an app-computed literal, and checked the limit against the lagging read: concurrent
|
|
323
|
+
-- checkouts each passed the gate and overwrote each other's increment, so a redemption_limit=N
|
|
324
|
+
-- coupon could be redeemed well past N. This single UPDATE takes the row lock, re-checks the
|
|
325
|
+
-- limit under it and increments in SQL, so exactly N calls return true. It returns false (never
|
|
326
|
+
-- NULL) when the coupon is missing, inactive or exhausted. SECURITY INVOKER and service_role-only:
|
|
327
|
+
-- the checkout server calls it with the service client (RLS bypass is intended there); no browser
|
|
328
|
+
-- or PostgREST caller may bump a counter directly.
|
|
329
|
+
CREATE OR REPLACE FUNCTION public.redeem_coupon_atomically(p_coupon_id uuid)
|
|
330
|
+
RETURNS boolean
|
|
331
|
+
LANGUAGE sql
|
|
332
|
+
SECURITY INVOKER
|
|
333
|
+
SET search_path TO 'public'
|
|
334
|
+
AS $$
|
|
335
|
+
WITH redeemed AS (
|
|
336
|
+
UPDATE public.coupons
|
|
337
|
+
SET redemptions_count = COALESCE(redemptions_count, 0) + 1,
|
|
338
|
+
updated_at = now()
|
|
339
|
+
WHERE id = p_coupon_id
|
|
340
|
+
AND is_active
|
|
341
|
+
AND (redemption_limit IS NULL OR COALESCE(redemptions_count, 0) < redemption_limit)
|
|
342
|
+
RETURNING 1
|
|
343
|
+
)
|
|
344
|
+
SELECT EXISTS (SELECT 1 FROM redeemed);
|
|
345
|
+
$$;
|
|
346
|
+
REVOKE ALL ON FUNCTION public.redeem_coupon_atomically(uuid) FROM PUBLIC, anon, authenticated;
|
|
347
|
+
GRANT EXECUTE ON FUNCTION public.redeem_coupon_atomically(uuid) TO service_role;
|
|
348
|
+
|
|
349
|
+
-- ---------------------------------------------------------------------------
|
|
350
|
+
-- 9. media: a WRITER edits and deletes only their own uploads (media-no-ownership-binding)
|
|
351
|
+
-- ---------------------------------------------------------------------------
|
|
352
|
+
-- media_update_policy / media_delete_policy let any WRITER overwrite or delete ANY media row
|
|
353
|
+
-- (nothing bound the row to its uploader), and the UPDATE check let a WRITER re-assign
|
|
354
|
+
-- uploader_id. ADMIN keeps every row. A WRITER keeps rows where uploader_id is their own id, and
|
|
355
|
+
-- WITH CHECK keeps uploader_id pinned to them on update. The /cms/media actions use the cookie
|
|
356
|
+
-- client, so this is enforced for the UI as well as for raw PostgREST. media_insert_policy and
|
|
357
|
+
-- media_read_policy are unchanged (the app sets uploader_id = the session user on insert).
|
|
358
|
+
DROP POLICY IF EXISTS media_update_policy ON public.media;
|
|
359
|
+
CREATE POLICY media_update_policy ON public.media
|
|
360
|
+
FOR UPDATE TO authenticated
|
|
361
|
+
USING (
|
|
362
|
+
(SELECT public.is_admin()) IS TRUE
|
|
363
|
+
OR (uploader_id = (SELECT auth.uid())
|
|
364
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
365
|
+
)
|
|
366
|
+
WITH CHECK (
|
|
367
|
+
(SELECT public.is_admin()) IS TRUE
|
|
368
|
+
OR (uploader_id = (SELECT auth.uid())
|
|
369
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
DROP POLICY IF EXISTS media_delete_policy ON public.media;
|
|
373
|
+
CREATE POLICY media_delete_policy ON public.media
|
|
374
|
+
FOR DELETE TO authenticated
|
|
375
|
+
USING (
|
|
376
|
+
(SELECT public.is_admin()) IS TRUE
|
|
377
|
+
OR (uploader_id = (SELECT auth.uid())
|
|
378
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
-- ---------------------------------------------------------------------------
|
|
382
|
+
-- 10. cortex_request_log: per-user request accounting for the AI routes (writer-openrouter-cost-abuse)
|
|
383
|
+
-- ---------------------------------------------------------------------------
|
|
384
|
+
-- The four /api/ai/* routes accepted unlimited calls from any WRITER with no per-user rate or
|
|
385
|
+
-- spend cap, each one billed to the operator's OpenRouter key. The routes now record one row per
|
|
386
|
+
-- accepted request (service client) and refuse with 429 once a user exceeds the window budget;
|
|
387
|
+
-- the index serves that count. Service-role only: RLS is ON with NO anon/authenticated policy
|
|
388
|
+
-- (service_role bypasses RLS), and whatever Supabase's default privileges granted at CREATE is
|
|
389
|
+
-- revoked before the exact grant below, so a browser session can neither read other users'
|
|
390
|
+
-- activity nor pad or purge its own counter. Old rows are pruned by the same server path
|
|
391
|
+
-- (DELETE granted for that).
|
|
392
|
+
CREATE TABLE IF NOT EXISTS public.cortex_request_log (
|
|
393
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
394
|
+
user_id uuid NOT NULL,
|
|
395
|
+
route text NOT NULL,
|
|
396
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
397
|
+
);
|
|
398
|
+
CREATE INDEX IF NOT EXISTS cortex_request_log_user_created_idx
|
|
399
|
+
ON public.cortex_request_log (user_id, created_at DESC);
|
|
400
|
+
ALTER TABLE public.cortex_request_log ENABLE ROW LEVEL SECURITY;
|
|
401
|
+
REVOKE ALL ON TABLE public.cortex_request_log FROM PUBLIC, anon, authenticated, service_role;
|
|
402
|
+
GRANT SELECT, INSERT, DELETE ON TABLE public.cortex_request_log TO service_role;
|
|
403
|
+
|
|
404
|
+
-- ---------------------------------------------------------------------------
|
|
405
|
+
-- 11. Seed the shopper-facing copy the new checkout/coupon guards return (EN + FR)
|
|
406
|
+
-- ---------------------------------------------------------------------------
|
|
407
|
+
-- The coupon validation path now returns one uniform key for every rejection (so it is not an
|
|
408
|
+
-- existence oracle), and the server-side shipping re-validation adds two keys. t(key) returns the
|
|
409
|
+
-- KEY for an unseeded key, so these must ship or shoppers see a raw key. Bilingual per CLAUDE.md.
|
|
410
|
+
INSERT INTO public.translations (key, translations, created_at, updated_at)
|
|
411
|
+
VALUES
|
|
412
|
+
('ecommerce.coupon_invalid', '{"en":"This coupon code is invalid or can''t be applied to your cart.","fr":"Ce code promo est invalide ou ne peut pas être appliqué à votre panier."}'::jsonb, now(), now()),
|
|
413
|
+
('ecommerce.checkout_invalid_shipping_method', '{"en":"That shipping method is not available for your address. Please choose another.","fr":"Ce mode de livraison n''est pas disponible pour votre adresse. Veuillez en choisir un autre."}'::jsonb, now(), now()),
|
|
414
|
+
('ecommerce.checkout_shipping_address_required', '{"en":"A shipping address is required to calculate shipping.","fr":"Une adresse de livraison est requise pour calculer les frais d''expédition."}'::jsonb, now(), now())
|
|
415
|
+
ON CONFLICT (key) DO NOTHING;
|
|
416
|
+
|
|
417
|
+
-- ---------------------------------------------------------------------------
|
|
418
|
+
-- 12. order_items: a database backstop for the positive-quantity checkout guard
|
|
419
|
+
-- ---------------------------------------------------------------------------
|
|
420
|
+
-- The providers now clamp quantity, but apply_order_inventory_deduction and any other writer
|
|
421
|
+
-- still trust SUM(order_items.quantity). A CHECK closes every path. NOT VALID first so the add is
|
|
422
|
+
-- instant and cannot fail on any pre-existing bad row; VALIDATE separately (safe if all rows pass,
|
|
423
|
+
-- and this is a fresh constraint). Idempotent.
|
|
424
|
+
ALTER TABLE public.order_items DROP CONSTRAINT IF EXISTS order_items_quantity_positive;
|
|
425
|
+
ALTER TABLE public.order_items ADD CONSTRAINT order_items_quantity_positive CHECK (quantity > 0) NOT VALID;
|
|
426
|
+
ALTER TABLE public.order_items VALIDATE CONSTRAINT order_items_quantity_positive;
|
|
427
|
+
|
|
428
|
+
-- ---------------------------------------------------------------------------
|
|
429
|
+
-- 13. cms_interactions: cap how fast one account can create pending interactions
|
|
430
|
+
-- ---------------------------------------------------------------------------
|
|
431
|
+
-- cms_interactions_insert_policy admits unlimited own-user pending rows; a signed-up account can
|
|
432
|
+
-- flood the moderation queue straight through PostgREST (the app-layer limit never sees that path).
|
|
433
|
+
-- A BEFORE INSERT trigger enforces the cap in the DB for ordinary users only; staff and the
|
|
434
|
+
-- service role (moderation, replies) are exempt. SECURITY DEFINER in private, not API-callable.
|
|
435
|
+
CREATE OR REPLACE FUNCTION private.enforce_cms_interaction_rate_limit()
|
|
436
|
+
RETURNS trigger
|
|
437
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
438
|
+
SET search_path TO ''
|
|
439
|
+
AS $$
|
|
440
|
+
DECLARE
|
|
441
|
+
recent_count integer;
|
|
442
|
+
pending_total integer;
|
|
443
|
+
BEGIN
|
|
444
|
+
-- Only ordinary signed-in users are limited; service_role/postgres carry no session, staff are trusted.
|
|
445
|
+
IF auth.uid() IS NULL
|
|
446
|
+
OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]) THEN
|
|
447
|
+
RETURN NEW;
|
|
448
|
+
END IF;
|
|
449
|
+
|
|
450
|
+
SELECT count(*) INTO recent_count
|
|
451
|
+
FROM public.cms_interactions
|
|
452
|
+
WHERE user_id = auth.uid()
|
|
453
|
+
AND status = 'pending'::public.approval_status
|
|
454
|
+
AND created_at > now() - interval '10 minutes';
|
|
455
|
+
IF recent_count >= 5 THEN
|
|
456
|
+
RAISE EXCEPTION 'Too many submissions. Please wait a few minutes and try again.'
|
|
457
|
+
USING ERRCODE = 'check_violation';
|
|
458
|
+
END IF;
|
|
459
|
+
|
|
460
|
+
SELECT count(*) INTO pending_total
|
|
461
|
+
FROM public.cms_interactions
|
|
462
|
+
WHERE user_id = auth.uid()
|
|
463
|
+
AND status = 'pending'::public.approval_status;
|
|
464
|
+
IF pending_total >= 50 THEN
|
|
465
|
+
RAISE EXCEPTION 'You have too many submissions awaiting review.'
|
|
466
|
+
USING ERRCODE = 'check_violation';
|
|
467
|
+
END IF;
|
|
468
|
+
|
|
469
|
+
RETURN NEW;
|
|
470
|
+
END;
|
|
471
|
+
$$;
|
|
472
|
+
|
|
473
|
+
REVOKE ALL ON FUNCTION private.enforce_cms_interaction_rate_limit() FROM PUBLIC, anon, authenticated;
|
|
474
|
+
|
|
475
|
+
DROP TRIGGER IF EXISTS enforce_cms_interaction_rate_limit ON public.cms_interactions;
|
|
476
|
+
CREATE TRIGGER enforce_cms_interaction_rate_limit
|
|
477
|
+
BEFORE INSERT ON public.cms_interactions
|
|
478
|
+
FOR EACH ROW
|
|
479
|
+
EXECUTE FUNCTION private.enforce_cms_interaction_rate_limit();
|
|
480
|
+
|
|
481
|
+
-- ---------------------------------------------------------------------------
|
|
482
|
+
-- 14. aal2 for coupons and coupon_redemptions (money-adjacent, app reads them via service role)
|
|
483
|
+
-- ---------------------------------------------------------------------------
|
|
484
|
+
-- Widens the section-5 posture to the next tier whose app access never uses the cookie client
|
|
485
|
+
-- (CMS coupon pages and the storefront validator both use the service role), so gating the
|
|
486
|
+
-- authenticated admin policies behind is_admin_mfa() closes the direct-PostgREST aal1 path
|
|
487
|
+
-- without locking a "remember this device" admin out of any screen. Tables the CMS still reads
|
|
488
|
+
-- with the cookie client (orders, user_addresses, site_settings secrets, profiles) are left on the
|
|
489
|
+
-- plain role check until those reads move to the service role (docs/04).
|
|
490
|
+
DROP POLICY IF EXISTS coupons_admin_select_policy ON public.coupons;
|
|
491
|
+
CREATE POLICY coupons_admin_select_policy ON public.coupons
|
|
492
|
+
FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
493
|
+
DROP POLICY IF EXISTS coupons_admin_insert_policy ON public.coupons;
|
|
494
|
+
CREATE POLICY coupons_admin_insert_policy ON public.coupons
|
|
495
|
+
FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
496
|
+
DROP POLICY IF EXISTS coupons_admin_update_policy ON public.coupons;
|
|
497
|
+
CREATE POLICY coupons_admin_update_policy ON public.coupons
|
|
498
|
+
FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
499
|
+
DROP POLICY IF EXISTS coupons_admin_delete_policy ON public.coupons;
|
|
500
|
+
CREATE POLICY coupons_admin_delete_policy ON public.coupons
|
|
501
|
+
FOR DELETE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
502
|
+
DROP POLICY IF EXISTS coupon_redemptions_admin_select_policy ON public.coupon_redemptions;
|
|
503
|
+
CREATE POLICY coupon_redemptions_admin_select_policy ON public.coupon_redemptions
|
|
504
|
+
FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|