Skip to content

Commit f14f684

Browse files
authored
fix(rbac): bind CLI API key RPCs to request header (#2060)
1 parent 3cfae1b commit f14f684

4 files changed

Lines changed: 395 additions & 15 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
CREATE OR REPLACE FUNCTION "public"."cli_check_permission"(
2+
"apikey" "text" DEFAULT NULL,
3+
"permission_key" "text" DEFAULT NULL,
4+
"org_id" "uuid" DEFAULT NULL,
5+
"app_id" "text" DEFAULT NULL,
6+
"channel_id" bigint DEFAULT NULL
7+
) RETURNS boolean
8+
LANGUAGE "plpgsql" SECURITY DEFINER
9+
SET "search_path" TO ''
10+
AS $$
11+
DECLARE
12+
v_request_apikey text;
13+
v_api_key public.apikeys%ROWTYPE;
14+
BEGIN
15+
IF permission_key IS NULL OR permission_key = '' THEN
16+
RETURN false;
17+
END IF;
18+
19+
SELECT public.get_apikey_header() INTO v_request_apikey;
20+
21+
IF v_request_apikey IS NULL OR v_request_apikey = '' THEN
22+
RETURN false;
23+
END IF;
24+
25+
IF apikey IS NOT NULL AND apikey <> '' AND apikey IS DISTINCT FROM v_request_apikey THEN
26+
RETURN false;
27+
END IF;
28+
29+
SELECT * INTO v_api_key
30+
FROM public.find_apikey_by_value(v_request_apikey)
31+
LIMIT 1;
32+
33+
IF v_api_key.id IS NULL THEN
34+
RETURN false;
35+
END IF;
36+
37+
RETURN public.rbac_check_permission_direct(
38+
permission_key,
39+
v_api_key.user_id,
40+
org_id,
41+
app_id,
42+
channel_id,
43+
v_request_apikey
44+
);
45+
END;
46+
$$;
47+
48+
ALTER FUNCTION "public"."cli_check_permission"(
49+
"apikey" "text",
50+
"permission_key" "text",
51+
"org_id" "uuid",
52+
"app_id" "text",
53+
"channel_id" bigint
54+
) OWNER TO "postgres";
55+
56+
REVOKE ALL ON FUNCTION "public"."cli_check_permission"(
57+
"apikey" "text",
58+
"permission_key" "text",
59+
"org_id" "uuid",
60+
"app_id" "text",
61+
"channel_id" bigint
62+
) FROM PUBLIC;
63+
64+
GRANT EXECUTE ON FUNCTION "public"."cli_check_permission"(
65+
"apikey" "text",
66+
"permission_key" "text",
67+
"org_id" "uuid",
68+
"app_id" "text",
69+
"channel_id" bigint
70+
) TO "anon";
71+
GRANT EXECUTE ON FUNCTION "public"."cli_check_permission"(
72+
"apikey" "text",
73+
"permission_key" "text",
74+
"org_id" "uuid",
75+
"app_id" "text",
76+
"channel_id" bigint
77+
) TO "authenticated";
78+
GRANT EXECUTE ON FUNCTION "public"."cli_check_permission"(
79+
"apikey" "text",
80+
"permission_key" "text",
81+
"org_id" "uuid",
82+
"app_id" "text",
83+
"channel_id" bigint
84+
) TO "service_role";
85+
86+
COMMENT ON FUNCTION "public"."cli_check_permission"(
87+
"apikey" "text",
88+
"permission_key" "text",
89+
"org_id" "uuid",
90+
"app_id" "text",
91+
"channel_id" bigint
92+
) IS 'CLI permission wrapper bound to the request capgkey header. The apikey argument is retained for CLI compatibility and must match the header when provided.';
93+
94+
CREATE OR REPLACE FUNCTION "public"."get_accessible_apps_for_apikey_v2"(
95+
"apikey" "text" DEFAULT NULL
96+
) RETURNS SETOF "public"."apps"
97+
LANGUAGE "plpgsql" SECURITY DEFINER
98+
SET "search_path" TO ''
99+
AS $$
100+
DECLARE
101+
v_request_apikey text;
102+
v_api_key public.apikeys%ROWTYPE;
103+
BEGIN
104+
SELECT public.get_apikey_header() INTO v_request_apikey;
105+
106+
IF v_request_apikey IS NULL OR v_request_apikey = '' THEN
107+
RETURN;
108+
END IF;
109+
110+
IF apikey IS NOT NULL AND apikey <> '' AND apikey IS DISTINCT FROM v_request_apikey THEN
111+
RETURN;
112+
END IF;
113+
114+
SELECT * INTO v_api_key
115+
FROM public.find_apikey_by_value(v_request_apikey)
116+
LIMIT 1;
117+
118+
IF v_api_key.id IS NULL THEN
119+
RETURN;
120+
END IF;
121+
122+
RETURN QUERY
123+
SELECT a.*
124+
FROM public.apps a
125+
WHERE public.rbac_check_permission_direct(
126+
public.rbac_perm_app_read(),
127+
v_api_key.user_id,
128+
a.owner_org,
129+
a.app_id,
130+
NULL,
131+
v_request_apikey
132+
)
133+
ORDER BY a.created_at DESC;
134+
END;
135+
$$;
136+
137+
ALTER FUNCTION "public"."get_accessible_apps_for_apikey_v2"(
138+
"apikey" "text"
139+
) OWNER TO "postgres";
140+
141+
REVOKE ALL ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"(
142+
"apikey" "text"
143+
) FROM PUBLIC;
144+
145+
GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"(
146+
"apikey" "text"
147+
) TO "anon";
148+
GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"(
149+
"apikey" "text"
150+
) TO "authenticated";
151+
GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"(
152+
"apikey" "text"
153+
) TO "service_role";
154+
155+
COMMENT ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"(
156+
"apikey" "text"
157+
) IS 'Returns apps visible to the request capgkey using RBAC-aware permission checks with legacy fallback. The apikey argument is retained for CLI compatibility and must match the header when provided.';
158+
159+
CREATE OR REPLACE FUNCTION public.enforce_apikey_expiration_policy()
160+
RETURNS trigger
161+
LANGUAGE plpgsql
162+
SECURITY DEFINER
163+
SET search_path = ''
164+
AS $$
165+
DECLARE
166+
scoped_org RECORD;
167+
BEGIN
168+
IF TG_OP = 'UPDATE'
169+
AND NEW.expires_at IS NOT DISTINCT FROM OLD.expires_at
170+
AND NEW.limited_to_orgs IS NOT DISTINCT FROM OLD.limited_to_orgs
171+
AND NEW.limited_to_apps IS NOT DISTINCT FROM OLD.limited_to_apps THEN
172+
RETURN NEW;
173+
END IF;
174+
175+
FOR scoped_org IN
176+
WITH explicit_scope_orgs AS (
177+
SELECT unnest(COALESCE(NEW.limited_to_orgs, '{}'::uuid[])) AS org_id
178+
UNION
179+
SELECT public.apps.owner_org
180+
FROM public.apps
181+
WHERE public.apps.app_id = ANY(COALESCE(NEW.limited_to_apps, '{}'::text[]))
182+
),
183+
scope_orgs AS (
184+
SELECT explicit_scope_orgs.org_id
185+
FROM explicit_scope_orgs
186+
UNION
187+
SELECT public.org_users.org_id
188+
FROM public.org_users
189+
WHERE public.org_users.user_id = NEW.user_id
190+
AND COALESCE(array_length(NEW.limited_to_orgs, 1), 0) = 0
191+
AND COALESCE(array_length(NEW.limited_to_apps, 1), 0) = 0
192+
)
193+
SELECT
194+
public.orgs.id,
195+
public.orgs.require_apikey_expiration,
196+
public.orgs.max_apikey_expiration_days
197+
FROM public.orgs
198+
JOIN scope_orgs ON scope_orgs.org_id = public.orgs.id
199+
LOOP
200+
IF scoped_org.require_apikey_expiration AND NEW.expires_at IS NULL THEN
201+
RAISE EXCEPTION USING
202+
ERRCODE = 'P0001',
203+
MESSAGE = 'expiration_required',
204+
DETAIL = 'This organization requires API keys to have an expiration date';
205+
END IF;
206+
207+
IF scoped_org.max_apikey_expiration_days IS NOT NULL
208+
AND NEW.expires_at IS NOT NULL
209+
AND NEW.expires_at > clock_timestamp()
210+
+ make_interval(days => scoped_org.max_apikey_expiration_days) THEN
211+
RAISE EXCEPTION USING
212+
ERRCODE = 'P0001',
213+
MESSAGE = 'expiration_exceeds_max',
214+
DETAIL = format(
215+
'API key expiration cannot exceed %s days for this organization',
216+
scoped_org.max_apikey_expiration_days
217+
);
218+
END IF;
219+
END LOOP;
220+
221+
RETURN NEW;
222+
END;
223+
$$;
224+
225+
ALTER FUNCTION public.enforce_apikey_expiration_policy() OWNER TO postgres;
226+
227+
REVOKE ALL ON FUNCTION public.enforce_apikey_expiration_policy() FROM public;
228+
GRANT EXECUTE ON FUNCTION public.enforce_apikey_expiration_policy() TO service_role;
229+
230+
DROP TRIGGER IF EXISTS apikeys_enforce_expiration_policy ON public.apikeys;
231+
232+
CREATE TRIGGER apikeys_enforce_expiration_policy
233+
BEFORE INSERT OR UPDATE ON public.apikeys
234+
FOR EACH ROW
235+
EXECUTE FUNCTION public.enforce_apikey_expiration_policy();

supabase/tests/42_test_apikey_expiration.sql

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
BEGIN;
22

3-
SELECT plan(24);
3+
SELECT plan(26);
44

55
SELECT tests.authenticate_as_service_role();
66

@@ -443,6 +443,72 @@ SELECT
443443
'get_user_org_ids: Returns results for valid (not expired) API key'
444444
);
445445

446+
-- Test 25: Unscoped keys must honor expiration-required org memberships
447+
UPDATE orgs
448+
SET
449+
require_apikey_expiration = TRUE,
450+
max_apikey_expiration_days = 30
451+
WHERE id = '046a36ac-e03c-4590-9257-bd6c9dba9ee8';
452+
453+
SELECT
454+
throws_ok(
455+
$$
456+
INSERT INTO apikeys (
457+
id,
458+
user_id,
459+
key,
460+
mode,
461+
name,
462+
expires_at,
463+
limited_to_orgs,
464+
limited_to_apps
465+
)
466+
VALUES (
467+
99913,
468+
'6aa76066-55ef-4238-ade6-0b32334a4097'::uuid,
469+
'test-unscoped-key-missing-expiration',
470+
'all',
471+
'Test unscoped missing expiration',
472+
NULL,
473+
'{}'::uuid[],
474+
'{}'::text[]
475+
)
476+
$$,
477+
'P0001',
478+
'expiration_required',
479+
'enforce_apikey_expiration_policy: unscoped keys inherit membership expiration requirement'
480+
);
481+
482+
-- Test 26: Unscoped keys must honor max expiration days from org memberships
483+
SELECT
484+
throws_ok(
485+
$$
486+
INSERT INTO apikeys (
487+
id,
488+
user_id,
489+
key,
490+
mode,
491+
name,
492+
expires_at,
493+
limited_to_orgs,
494+
limited_to_apps
495+
)
496+
VALUES (
497+
99914,
498+
'6aa76066-55ef-4238-ade6-0b32334a4097'::uuid,
499+
'test-unscoped-key-too-long-expiration',
500+
'all',
501+
'Test unscoped too long expiration',
502+
now() + interval '31 days',
503+
'{}'::uuid[],
504+
'{}'::text[]
505+
)
506+
$$,
507+
'P0001',
508+
'expiration_exceeds_max',
509+
'enforce_apikey_expiration_policy: unscoped keys inherit membership max expiration days'
510+
);
511+
446512
SELECT *
447513
FROM finish();
448514

0 commit comments

Comments
 (0)