Skip to content

Commit bfff204

Browse files
committed
fix(rls): fail fast anon REST scans
1 parent 7ed1f27 commit bfff204

File tree

3 files changed

+409
-0
lines changed

3 files changed

+409
-0
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
-- Fail fast for unauthenticated PostgREST queries and avoid per-row API key resolution in RLS.
2+
--
3+
-- Goal:
4+
-- - Unauthenticated anon requests (no capgkey header, auth.uid() is NULL) must not scan large tables.
5+
-- - Policies should be index-friendly for common predicates (e.g. app_id IN allowed_app_ids()).
6+
--
7+
-- Context:
8+
-- PostgREST requests with the public anon key can hit RLS policies. If the policy evaluates expensive
9+
-- identity/auth logic per row (e.g. get_identity_org_appid(owner_org, app_id)), an unfiltered query can
10+
-- trigger statement_timeouts and cause cascading failures.
11+
12+
-- 1) Statement-scoped guard: true only when the request is authenticated OR carries a valid Capgo API key.
13+
CREATE OR REPLACE FUNCTION "public"."has_auth_or_valid_apikey"("keymode" "public"."key_mode"[])
14+
RETURNS boolean
15+
LANGUAGE "plpgsql" STABLE SECURITY DEFINER
16+
SET "search_path" TO ''
17+
AS $$
18+
DECLARE
19+
v_user_id uuid;
20+
v_api_key_text text;
21+
v_api_key record;
22+
BEGIN
23+
SELECT auth.uid() INTO v_user_id;
24+
IF v_user_id IS NOT NULL THEN
25+
RETURN true;
26+
END IF;
27+
28+
SELECT public.get_apikey_header() INTO v_api_key_text;
29+
IF v_api_key_text IS NULL THEN
30+
RETURN false;
31+
END IF;
32+
33+
SELECT * FROM public.find_apikey_by_value(v_api_key_text) INTO v_api_key;
34+
IF v_api_key.id IS NULL THEN
35+
RETURN false;
36+
END IF;
37+
38+
IF NOT (v_api_key.mode = ANY(keymode)) THEN
39+
RETURN false;
40+
END IF;
41+
42+
IF public.is_apikey_expired(v_api_key.expires_at) THEN
43+
RETURN false;
44+
END IF;
45+
46+
RETURN true;
47+
END;
48+
$$;
49+
50+
GRANT EXECUTE ON FUNCTION "public"."has_auth_or_valid_apikey"("keymode" "public"."key_mode"[]) TO "anon";
51+
GRANT EXECUTE ON FUNCTION "public"."has_auth_or_valid_apikey"("keymode" "public"."key_mode"[]) TO "authenticated";
52+
53+
-- 2) Compute readable app_ids once per statement, then let policies use a simple index predicate:
54+
-- app_id = ANY(allowed_read_apps()).
55+
CREATE OR REPLACE FUNCTION "public"."allowed_read_apps"()
56+
RETURNS text[]
57+
LANGUAGE "plpgsql" STABLE SECURITY DEFINER
58+
SET "search_path" TO ''
59+
AS $$
60+
DECLARE
61+
v_user_id uuid;
62+
v_api_key_text text;
63+
v_api_key public.apikeys%ROWTYPE;
64+
v_allowed text[] := '{}'::text[];
65+
v_app record;
66+
v_use_rbac boolean;
67+
v_perm text := public.rbac_permission_for_legacy(
68+
'read'::public.user_min_right,
69+
public.rbac_scope_app()
70+
);
71+
v_enforcing_2fa boolean;
72+
BEGIN
73+
SELECT auth.uid() INTO v_user_id;
74+
75+
-- Always load api key if present; RBAC permissions may be bound to the API key principal.
76+
SELECT public.get_apikey_header() INTO v_api_key_text;
77+
IF v_api_key_text IS NOT NULL THEN
78+
SELECT * FROM public.find_apikey_by_value(v_api_key_text) INTO v_api_key;
79+
IF v_api_key.id IS NOT NULL
80+
AND v_api_key.mode = ANY('{read,upload,write,all}'::public.key_mode[])
81+
AND NOT public.is_apikey_expired(v_api_key.expires_at)
82+
THEN
83+
IF v_user_id IS NULL THEN
84+
v_user_id := v_api_key.user_id;
85+
END IF;
86+
ELSE
87+
-- Treat invalid/mismatched/expired keys as absent (fail closed).
88+
v_api_key := NULL;
89+
END IF;
90+
END IF;
91+
92+
-- No auth and no usable API key.
93+
IF v_user_id IS NULL AND v_api_key.id IS NULL THEN
94+
RETURN v_allowed;
95+
END IF;
96+
97+
-- Candidate apps come from:
98+
-- - legacy org_users bindings (org-wide or app-wide, but not channel bindings)
99+
-- - RBAC org/app bindings (user principal or apikey principal)
100+
FOR v_app IN
101+
SELECT DISTINCT a.app_id, a.owner_org
102+
FROM public.apps a
103+
WHERE
104+
-- Legacy org membership / app access.
105+
EXISTS (
106+
SELECT 1
107+
FROM public.org_users ou
108+
WHERE ou.user_id = v_user_id
109+
AND ou.org_id = a.owner_org
110+
AND ou.channel_id IS NULL
111+
AND (ou.app_id IS NULL OR ou.app_id = a.app_id)
112+
)
113+
OR
114+
-- RBAC: org-level bindings (implies possible access across apps via inheritance).
115+
EXISTS (
116+
SELECT 1
117+
FROM public.role_bindings rb
118+
WHERE rb.scope_type = public.rbac_scope_org()
119+
AND rb.org_id = a.owner_org
120+
AND (
121+
(rb.principal_type = public.rbac_principal_user() AND rb.principal_id = v_user_id)
122+
OR
123+
(v_api_key.rbac_id IS NOT NULL AND rb.principal_type = public.rbac_principal_apikey() AND rb.principal_id = v_api_key.rbac_id)
124+
)
125+
)
126+
OR
127+
-- RBAC: app-level bindings (apps.id is the RBAC scope identifier).
128+
EXISTS (
129+
SELECT 1
130+
FROM public.role_bindings rb
131+
WHERE rb.scope_type = public.rbac_scope_app()
132+
AND rb.app_id = a.id
133+
AND (
134+
(rb.principal_type = public.rbac_principal_user() AND rb.principal_id = v_user_id)
135+
OR
136+
(v_api_key.rbac_id IS NOT NULL AND rb.principal_type = public.rbac_principal_apikey() AND rb.principal_id = v_api_key.rbac_id)
137+
)
138+
)
139+
LOOP
140+
-- Enforce API key scoping (if present).
141+
IF v_api_key.id IS NOT NULL
142+
AND COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0
143+
AND NOT (v_app.owner_org = ANY(v_api_key.limited_to_orgs))
144+
THEN
145+
CONTINUE;
146+
END IF;
147+
148+
IF v_api_key.id IS NOT NULL
149+
AND v_api_key.limited_to_apps IS DISTINCT FROM '{}'
150+
AND NOT (v_app.app_id = ANY(v_api_key.limited_to_apps))
151+
THEN
152+
CONTINUE;
153+
END IF;
154+
155+
v_use_rbac := public.rbac_is_enabled_for_org(v_app.owner_org);
156+
157+
IF NOT v_use_rbac THEN
158+
-- Legacy rights (includes org 2FA + password policy checks).
159+
IF public.check_min_rights_legacy(
160+
'read'::public.user_min_right,
161+
v_user_id,
162+
v_app.owner_org,
163+
v_app.app_id,
164+
NULL::bigint
165+
) THEN
166+
v_allowed := array_append(v_allowed, v_app.app_id);
167+
END IF;
168+
ELSE
169+
-- Mirror check_min_rights() org gating for RBAC orgs (2FA + password policy).
170+
SELECT o.enforcing_2fa INTO v_enforcing_2fa
171+
FROM public.orgs o
172+
WHERE o.id = v_app.owner_org;
173+
174+
IF v_enforcing_2fa = true AND (v_user_id IS NULL OR NOT public.has_2fa_enabled(v_user_id)) THEN
175+
CONTINUE;
176+
END IF;
177+
178+
IF NOT public.user_meets_password_policy(v_user_id, v_app.owner_org) THEN
179+
CONTINUE;
180+
END IF;
181+
182+
-- Allow if the user or the API key principal has the required RBAC permission.
183+
IF v_user_id IS NOT NULL
184+
AND public.rbac_has_permission(
185+
public.rbac_principal_user(),
186+
v_user_id,
187+
v_perm,
188+
v_app.owner_org,
189+
v_app.app_id,
190+
NULL::bigint
191+
)
192+
THEN
193+
v_allowed := array_append(v_allowed, v_app.app_id);
194+
ELSIF v_api_key.id IS NOT NULL
195+
AND v_api_key.rbac_id IS NOT NULL
196+
AND public.rbac_has_permission(
197+
public.rbac_principal_apikey(),
198+
v_api_key.rbac_id,
199+
v_perm,
200+
v_app.owner_org,
201+
v_app.app_id,
202+
NULL::bigint
203+
)
204+
THEN
205+
v_allowed := array_append(v_allowed, v_app.app_id);
206+
END IF;
207+
END IF;
208+
END LOOP;
209+
210+
RETURN v_allowed;
211+
END;
212+
$$;
213+
214+
GRANT EXECUTE ON FUNCTION "public"."allowed_read_apps"() TO "anon";
215+
GRANT EXECUTE ON FUNCTION "public"."allowed_read_apps"() TO "authenticated";
216+
217+
-- 3) Apply fail-fast + index-friendly policies on the largest affected tables.
218+
219+
-- audit_logs: keep org_id predicate but add a one-time guard so unauthenticated anon requests do not scan.
220+
DROP POLICY IF EXISTS "Allow select for auth, api keys (super_admin+)" ON "public"."audit_logs";
221+
CREATE POLICY "Allow select for auth, api keys (super_admin+)" ON "public"."audit_logs"
222+
FOR SELECT TO "anon", "authenticated"
223+
USING (
224+
public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[])
225+
AND "org_id" = ANY("public"."audit_logs_allowed_orgs"())
226+
);
227+
228+
-- app_versions + app_versions_meta: avoid per-row identity resolution; use allowed_read_apps().
229+
DROP POLICY IF EXISTS "Allow for auth, api keys (read+)" ON "public"."app_versions";
230+
CREATE POLICY "Allow for auth, api keys (read+)" ON "public"."app_versions"
231+
FOR SELECT TO "anon", "authenticated"
232+
USING (
233+
public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[])
234+
AND "app_id" = ANY("public"."allowed_read_apps"())
235+
);
236+
237+
DROP POLICY IF EXISTS "Allow read for auth (read+)" ON "public"."app_versions_meta";
238+
CREATE POLICY "Allow read for auth (read+)" ON "public"."app_versions_meta"
239+
FOR SELECT TO "anon", "authenticated"
240+
USING (
241+
public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[])
242+
AND "app_id" = ANY("public"."allowed_read_apps"())
243+
);
244+
245+
-- 4) (Optional hardening) Replace common read policies to avoid per-row get_identity_org_appid() on large tables.
246+
-- apps
247+
DROP POLICY IF EXISTS "Allow for auth, api keys (read+)" ON "public"."apps";
248+
CREATE POLICY "Allow for auth, api keys (read+)" ON "public"."apps"
249+
FOR SELECT TO "anon", "authenticated"
250+
USING (
251+
public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[])
252+
AND "app_id" = ANY("public"."allowed_read_apps"())
253+
);
254+
255+
-- channels
256+
DROP POLICY IF EXISTS "Allow select for auth, api keys (read+)" ON "public"."channels";
257+
CREATE POLICY "Allow select for auth, api keys (read+)" ON "public"."channels"
258+
FOR SELECT TO "anon", "authenticated"
259+
USING (
260+
public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[])
261+
AND "app_id" = ANY("public"."allowed_read_apps"())
262+
);
263+
264+
-- channel_devices
265+
DROP POLICY IF EXISTS "Allow read for auth, api keys (read+)" ON "public"."channel_devices";
266+
DROP POLICY IF EXISTS "Allow read for auth (read+)" ON "public"."channel_devices";
267+
CREATE POLICY "Allow read for auth, api keys (read+)" ON "public"."channel_devices"
268+
FOR SELECT TO "anon", "authenticated"
269+
USING (
270+
public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[])
271+
AND "app_id" = ANY("public"."allowed_read_apps"())
272+
);
273+
274+
-- build_requests
275+
DROP POLICY IF EXISTS "Allow org members to select build_requests" ON "public"."build_requests";
276+
CREATE POLICY "Allow org members to select build_requests" ON "public"."build_requests"
277+
FOR SELECT TO "anon", "authenticated"
278+
USING (
279+
public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[])
280+
AND "app_id" = ANY("public"."allowed_read_apps"())
281+
);

0 commit comments

Comments
 (0)