From c0ac762f5de1690d8de63f734f77570f6113c10c Mon Sep 17 00:00:00 2001 From: APXLBS <203673464+teixr12@users.noreply.github.com> Date: Sun, 10 May 2026 22:19:01 -0300 Subject: [PATCH 1/6] fix(rbac): enforce apikey expiration policy in permission checks --- supabase/functions/_backend/utils/supabase.ts | 8 +- ..._enforce_rbac_apikey_expiration_policy.sql | 679 ++++++++++++++++++ tests/rbac-permissions.test.ts | 239 ++++++ 3 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 4b9514b5ea..55fc57d069 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -311,15 +311,17 @@ export async function apikeyHasOrgRightWithPolicy( c: Context, key: Database['public']['Tables']['apikeys']['Row'], orgId: string, - supabase: SupabaseClient, + _supabase: SupabaseClient, ): Promise<{ valid: boolean, error?: string }> { // First check basic org access if (!apikeyHasOrgRight(key, orgId)) { return { valid: false, error: 'invalid_org_id' } } - // Then check if org requires expiring keys - const policyCheck = await checkApikeyMeetsOrgPolicy(c, key, orgId, supabase) + // Then check if org requires expiring keys. The scope check above proves the + // key is org-scoped; use service role for the policy lookup so runtime + // permission denials for non-expiring keys do not hide the policy row. + const policyCheck = await checkApikeyMeetsOrgPolicy(c, key, orgId, supabaseAdmin(c)) if (!policyCheck.valid) { return policyCheck } diff --git a/supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql b/supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql new file mode 100644 index 0000000000..885d366727 --- /dev/null +++ b/supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql @@ -0,0 +1,679 @@ +-- Enforce org API-key expiration policy during RBAC permission checks. +-- +-- API-key create/update triggers already reject new non-expiring keys for orgs +-- that require expiration. Existing non-expiring keys can predate a policy +-- change, so the RBAC direct permission functions must deny them at runtime. + +CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"( + "p_permission_key" "text", + "p_user_id" "uuid", + "p_org_id" "uuid", + "p_app_id" character varying, + "p_channel_id" bigint, + "p_apikey" "text" DEFAULT NULL::"text" +) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_allowed boolean := false; + v_use_rbac boolean; + v_effective_org_id uuid := p_org_id; + v_effective_user_id uuid := p_user_id; + v_effective_app_id character varying := p_app_id; + v_legacy_right public.user_min_right; + v_apikey_principal uuid; + v_apikey_has_bindings boolean := false; + v_override boolean; + v_channel_scope boolean := false; + v_org_enforcing_2fa boolean; + v_org_requires_apikey_expiration boolean := false; + v_password_policy_ok boolean; + v_api_key public.apikeys%ROWTYPE; + v_channel_org_id uuid; + v_channel_app_id character varying; +BEGIN + IF p_permission_key IS NULL OR p_permission_key = '' THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); + RETURN false; + END IF; + + IF p_channel_id IS NOT NULL AND p_permission_key LIKE 'channel.%' THEN + v_channel_scope := true; + END IF; + + IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN + SELECT owner_org INTO v_effective_org_id + FROM public.apps + WHERE app_id = p_app_id + LIMIT 1; + END IF; + + IF p_channel_id IS NOT NULL THEN + SELECT owner_org, app_id + INTO v_channel_org_id, v_channel_app_id + FROM public.channels + WHERE id = p_channel_id + LIMIT 1; + + IF v_channel_org_id IS NOT NULL THEN + v_effective_org_id := v_channel_org_id; + v_effective_app_id := v_channel_app_id; + END IF; + END IF; + + IF p_apikey IS NOT NULL THEN + SELECT * INTO v_api_key + FROM public.find_apikey_by_value(p_apikey) + LIMIT 1; + + IF v_api_key.id IS NULL THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NOT_FOUND', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id + )); + RETURN false; + END IF; + + IF public.is_apikey_expired(v_api_key.expires_at) THEN + PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object( + 'key_id', v_api_key.id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id + )); + RETURN false; + END IF; + + IF p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_USER_MISMATCH', jsonb_build_object( + 'permission', p_permission_key, + 'session_user_id', p_user_id, + 'apikey_user_id', v_api_key.user_id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id + )); + RETURN false; + END IF; + + v_effective_user_id := v_api_key.user_id; + + IF v_effective_org_id IS NULL THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NO_ORG', jsonb_build_object( + 'permission', p_permission_key, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'key_id', v_api_key.id + )); + RETURN false; + END IF; + + SELECT COALESCE(o.require_apikey_expiration, false) + INTO v_org_requires_apikey_expiration + FROM public.orgs o + WHERE o.id = v_effective_org_id + LIMIT 1; + + IF COALESCE(v_org_requires_apikey_expiration, false) AND v_api_key.expires_at IS NULL THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_EXPIRATION_REQUIRED', jsonb_build_object( + 'permission', p_permission_key, + 'key_id', v_api_key.id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id + )); + RETURN false; + END IF; + + IF COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 + AND NOT (v_effective_org_id = ANY(v_api_key.limited_to_orgs)) + THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_ORG_RESTRICT', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'key_id', v_api_key.id + )); + RETURN false; + END IF; + + IF COALESCE(array_length(v_api_key.limited_to_apps, 1), 0) > 0 THEN + IF v_effective_app_id IS NULL OR NOT (v_effective_app_id = ANY(v_api_key.limited_to_apps)) THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_APP_RESTRICT', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'key_id', v_api_key.id + )); + RETURN false; + END IF; + END IF; + END IF; + + IF v_effective_org_id IS NOT NULL THEN + SELECT enforcing_2fa INTO v_org_enforcing_2fa + FROM public.orgs + WHERE id = v_effective_org_id; + + IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'user_id', v_effective_user_id, + 'has_apikey', p_apikey IS NOT NULL + )); + RETURN false; + END IF; + END IF; + + IF v_effective_org_id IS NOT NULL THEN + v_password_policy_ok := public.user_meets_password_policy(v_effective_user_id, v_effective_org_id); + IF v_password_policy_ok = false THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'user_id', v_effective_user_id, + 'has_apikey', p_apikey IS NOT NULL + )); + RETURN false; + END IF; + END IF; + + v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); + + IF v_use_rbac THEN + IF v_api_key.id IS NOT NULL THEN + v_apikey_principal := v_api_key.rbac_id; + + IF v_apikey_principal IS NOT NULL THEN + SELECT EXISTS( + SELECT 1 FROM public.role_bindings + WHERE principal_type = public.rbac_principal_apikey() + AND principal_id = v_apikey_principal + ) INTO v_apikey_has_bindings; + + IF v_apikey_has_bindings THEN + v_allowed := public.rbac_has_permission( + public.rbac_principal_apikey(), v_apikey_principal, + p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id + ); + + IF v_channel_scope THEN + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_apikey() + AND o.principal_id = v_apikey_principal + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + END IF; + END IF; + + IF NOT v_allowed THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( + 'permission', p_permission_key, + 'user_id', v_effective_user_id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'has_apikey', true, + 'apikey_has_bindings', true + )); + END IF; + + RETURN v_allowed; + END IF; + END IF; + END IF; + + IF v_effective_user_id IS NOT NULL THEN + v_allowed := public.rbac_has_permission( + public.rbac_principal_user(), v_effective_user_id, + p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id + ); + + IF v_channel_scope THEN + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_user() + AND o.principal_id = v_effective_user_id + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + ELSE + IF EXISTS ( + SELECT 1 + FROM public.channel_permission_overrides o + JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id + JOIN public.groups g ON g.id = gm.group_id + WHERE o.principal_type = public.rbac_principal_group() + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + AND o.is_allowed = false + AND g.org_id = v_effective_org_id + ) THEN + v_allowed := false; + ELSIF EXISTS ( + SELECT 1 + FROM public.channel_permission_overrides o + JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id + JOIN public.groups g ON g.id = gm.group_id + WHERE o.principal_type = public.rbac_principal_group() + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + AND o.is_allowed = true + AND g.org_id = v_effective_org_id + ) THEN + v_allowed := true; + END IF; + END IF; + END IF; + END IF; + + IF NOT v_allowed AND v_api_key.id IS NOT NULL THEN + v_apikey_principal := v_api_key.rbac_id; + + IF v_apikey_principal IS NOT NULL THEN + v_allowed := public.rbac_has_permission( + public.rbac_principal_apikey(), v_apikey_principal, + p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id + ); + + IF v_channel_scope THEN + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_apikey() + AND o.principal_id = v_apikey_principal + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + END IF; + END IF; + END IF; + END IF; + + IF NOT v_allowed THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( + 'permission', p_permission_key, + 'user_id', v_effective_user_id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'has_apikey', p_apikey IS NOT NULL + )); + END IF; + + RETURN v_allowed; + ELSE + v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); + + IF v_legacy_right IS NULL THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( + 'permission', p_permission_key, + 'user_id', p_user_id + )); + RETURN false; + END IF; + + IF p_apikey IS NOT NULL AND v_effective_app_id IS NOT NULL THEN + RETURN public.has_app_right_apikey(v_effective_app_id, v_legacy_right, v_effective_user_id, p_apikey); + ELSIF v_effective_app_id IS NOT NULL THEN + RETURN public.has_app_right_userid(v_effective_app_id, v_legacy_right, v_effective_user_id); + ELSE + RETURN public.check_min_rights_legacy(v_legacy_right, v_effective_user_id, v_effective_org_id, v_effective_app_id, p_channel_id); + END IF; + END IF; +END; +$$; + +ALTER FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") FROM PUBLIC; +GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") TO "authenticated"; +GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") TO "service_role"; + +CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct_no_password_policy"( + "p_permission_key" "text", + "p_user_id" "uuid", + "p_org_id" "uuid", + "p_app_id" character varying, + "p_channel_id" bigint, + "p_apikey" "text" DEFAULT NULL::"text" +) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_allowed boolean := false; + v_use_rbac boolean; + v_effective_org_id uuid := p_org_id; + v_effective_user_id uuid := p_user_id; + v_effective_app_id character varying := p_app_id; + v_legacy_right public.user_min_right; + v_apikey_principal uuid; + v_apikey_has_bindings boolean := false; + v_override boolean; + v_channel_scope boolean := false; + v_org_enforcing_2fa boolean; + v_org_requires_apikey_expiration boolean := false; + v_api_key public.apikeys%ROWTYPE; + v_channel_org_id uuid; + v_channel_app_id character varying; +BEGIN + IF p_permission_key IS NULL OR p_permission_key = '' THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); + RETURN false; + END IF; + + IF p_channel_id IS NOT NULL AND p_permission_key LIKE 'channel.%' THEN + v_channel_scope := true; + END IF; + + IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN + SELECT owner_org INTO v_effective_org_id + FROM public.apps + WHERE app_id = p_app_id + LIMIT 1; + END IF; + + IF p_channel_id IS NOT NULL THEN + SELECT owner_org, app_id + INTO v_channel_org_id, v_channel_app_id + FROM public.channels + WHERE id = p_channel_id + LIMIT 1; + + IF v_channel_org_id IS NOT NULL THEN + v_effective_org_id := v_channel_org_id; + v_effective_app_id := v_channel_app_id; + END IF; + END IF; + + IF p_apikey IS NOT NULL THEN + SELECT * INTO v_api_key + FROM public.find_apikey_by_value(p_apikey) + LIMIT 1; + + IF v_api_key.id IS NULL THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NOT_FOUND', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id + )); + RETURN false; + END IF; + + IF public.is_apikey_expired(v_api_key.expires_at) THEN + PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object( + 'key_id', v_api_key.id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id + )); + RETURN false; + END IF; + + IF p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_USER_MISMATCH', jsonb_build_object( + 'permission', p_permission_key, + 'session_user_id', p_user_id, + 'apikey_user_id', v_api_key.user_id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id + )); + RETURN false; + END IF; + + v_effective_user_id := v_api_key.user_id; + + IF v_effective_org_id IS NULL THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NO_ORG', jsonb_build_object( + 'permission', p_permission_key, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'key_id', v_api_key.id + )); + RETURN false; + END IF; + + SELECT COALESCE(o.require_apikey_expiration, false) + INTO v_org_requires_apikey_expiration + FROM public.orgs o + WHERE o.id = v_effective_org_id + LIMIT 1; + + IF COALESCE(v_org_requires_apikey_expiration, false) AND v_api_key.expires_at IS NULL THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_EXPIRATION_REQUIRED', jsonb_build_object( + 'permission', p_permission_key, + 'key_id', v_api_key.id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id + )); + RETURN false; + END IF; + + IF COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 + AND NOT (v_effective_org_id = ANY(v_api_key.limited_to_orgs)) + THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_ORG_RESTRICT', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'key_id', v_api_key.id + )); + RETURN false; + END IF; + + IF COALESCE(array_length(v_api_key.limited_to_apps, 1), 0) > 0 THEN + IF v_effective_app_id IS NULL OR NOT (v_effective_app_id = ANY(v_api_key.limited_to_apps)) THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_APP_RESTRICT', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'key_id', v_api_key.id + )); + RETURN false; + END IF; + END IF; + END IF; + + IF v_effective_org_id IS NOT NULL THEN + SELECT enforcing_2fa INTO v_org_enforcing_2fa + FROM public.orgs + WHERE id = v_effective_org_id; + + IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'user_id', v_effective_user_id, + 'has_apikey', p_apikey IS NOT NULL + )); + RETURN false; + END IF; + END IF; + + v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); + + IF v_use_rbac THEN + IF v_api_key.id IS NOT NULL THEN + v_apikey_principal := v_api_key.rbac_id; + + IF v_apikey_principal IS NOT NULL THEN + SELECT EXISTS( + SELECT 1 FROM public.role_bindings + WHERE principal_type = public.rbac_principal_apikey() + AND principal_id = v_apikey_principal + ) INTO v_apikey_has_bindings; + + IF v_apikey_has_bindings THEN + v_allowed := public.rbac_has_permission( + public.rbac_principal_apikey(), v_apikey_principal, + p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id + ); + + IF v_channel_scope THEN + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_apikey() + AND o.principal_id = v_apikey_principal + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + END IF; + END IF; + + IF NOT v_allowed THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( + 'permission', p_permission_key, + 'user_id', v_effective_user_id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'has_apikey', true, + 'apikey_has_bindings', true + )); + END IF; + + RETURN v_allowed; + END IF; + END IF; + END IF; + + IF v_effective_user_id IS NOT NULL THEN + v_allowed := public.rbac_has_permission( + public.rbac_principal_user(), v_effective_user_id, + p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id + ); + + IF v_channel_scope THEN + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_user() + AND o.principal_id = v_effective_user_id + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + ELSE + IF EXISTS ( + SELECT 1 + FROM public.channel_permission_overrides o + JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id + JOIN public.groups g ON g.id = gm.group_id + WHERE o.principal_type = public.rbac_principal_group() + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + AND o.is_allowed = false + AND g.org_id = v_effective_org_id + ) THEN + v_allowed := false; + ELSIF EXISTS ( + SELECT 1 + FROM public.channel_permission_overrides o + JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id + JOIN public.groups g ON g.id = gm.group_id + WHERE o.principal_type = public.rbac_principal_group() + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + AND o.is_allowed = true + AND g.org_id = v_effective_org_id + ) THEN + v_allowed := true; + END IF; + END IF; + END IF; + END IF; + + IF NOT v_allowed AND v_api_key.id IS NOT NULL THEN + v_apikey_principal := v_api_key.rbac_id; + + IF v_apikey_principal IS NOT NULL THEN + v_allowed := public.rbac_has_permission( + public.rbac_principal_apikey(), v_apikey_principal, + p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id + ); + + IF v_channel_scope THEN + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_apikey() + AND o.principal_id = v_apikey_principal + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + END IF; + END IF; + END IF; + END IF; + + IF NOT v_allowed THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( + 'permission', p_permission_key, + 'user_id', v_effective_user_id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'has_apikey', p_apikey IS NOT NULL + )); + END IF; + + RETURN v_allowed; + ELSE + v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); + + IF v_legacy_right IS NULL THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( + 'permission', p_permission_key, + 'user_id', v_effective_user_id + )); + RETURN false; + END IF; + + IF p_apikey IS NOT NULL AND v_effective_app_id IS NOT NULL THEN + RETURN public.has_app_right_apikey(v_effective_app_id, v_legacy_right, v_effective_user_id, p_apikey); + ELSIF v_effective_app_id IS NOT NULL THEN + RETURN public.has_app_right_userid(v_effective_app_id, v_legacy_right, v_effective_user_id); + ELSE + RETURN public.check_min_rights_legacy_no_password_policy(v_legacy_right, v_effective_user_id, v_effective_org_id, v_effective_app_id, p_channel_id); + END IF; + END IF; +END; +$$; + +ALTER FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") FROM PUBLIC; +GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") TO "authenticated"; +GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") TO "service_role"; diff --git a/tests/rbac-permissions.test.ts b/tests/rbac-permissions.test.ts index 94d0938097..3298b75fd7 100644 --- a/tests/rbac-permissions.test.ts +++ b/tests/rbac-permissions.test.ts @@ -744,6 +744,245 @@ describe('rbac permission system', () => { expect(allowedResult.rows[0].allowed).toBe(true) }) + it('should deny existing non-expiring api keys after org starts requiring expiration', async () => { + const testId = randomUUID() + const orgId = randomUUID() + const appUuid = randomUUID() + const appId = `com.rbac.expiration-policy.${testId}` + const scopedKey = `rbac-expiration-policy-${testId}` + + await query(` + INSERT INTO public.orgs ( + id, + name, + management_email, + created_by, + use_new_rbac, + require_apikey_expiration, + enforcing_2fa + ) VALUES ( + $1::uuid, + $2, + $3, + $4::uuid, + true, + false, + false + ) + `, [ + orgId, + `rbac-permissions.test expiration policy ${testId}`, + `rbac-expiration-policy-${testId}@capgo.app`, + USER_ID, + ]) + + await query(` + INSERT INTO public.apps (id, app_id, name, icon_url, owner_org) + VALUES ($1::uuid, $2, $3, $4, $5::uuid) + `, [ + appUuid, + appId, + `RBAC expiration policy app ${testId}`, + 'rbac-expiration-policy-icon', + orgId, + ]) + + await query(` + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + granted_by, + is_direct + ) + SELECT + public.rbac_principal_user(), + $1::uuid, + r.id, + public.rbac_scope_app(), + $2::uuid, + $3::uuid, + $1::uuid, + true + FROM public.roles r + WHERE r.name = public.rbac_role_app_reader() + LIMIT 1 + `, [USER_ID, orgId, appUuid]) + + await query(` + INSERT INTO public.apikeys ( + user_id, + key, + key_hash, + mode, + name, + limited_to_orgs, + limited_to_apps, + expires_at + ) VALUES ( + $1::uuid, + $2, + NULL, + 'write', + $3, + ARRAY[$4::uuid], + ARRAY[$5]::text[], + NULL + ) + `, [ + USER_ID, + scopedKey, + `RBAC expiration policy ${scopedKey}`, + orgId, + appId, + ]) + + const beforePolicyFlip = await query(` + SELECT public.rbac_check_permission_direct( + 'app.read', + $1::uuid, + $2::uuid, + $3, + NULL::bigint, + $4 + ) AS allowed + `, [USER_ID, orgId, appId, scopedKey]) + + await query(` + UPDATE public.orgs + SET require_apikey_expiration = true + WHERE id = $1::uuid + `, [orgId]) + + const afterPolicyFlip = await query(` + SELECT + public.rbac_check_permission_direct( + 'app.read', + $1::uuid, + $2::uuid, + $3, + NULL::bigint, + $4 + ) AS direct_allowed, + public.rbac_check_permission_direct_no_password_policy( + 'app.read', + $1::uuid, + $2::uuid, + $3, + NULL::bigint, + $4 + ) AS direct_no_password_allowed + `, [USER_ID, orgId, appId, scopedKey]) + + expect(beforePolicyFlip.rows[0].allowed).toBe(true) + expect(afterPolicyFlip.rows[0].direct_allowed).toBe(false) + expect(afterPolicyFlip.rows[0].direct_no_password_allowed).toBe(false) + }) + + it('should apply channel deny overrides in no-password direct checks', async () => { + const testId = randomUUID() + const orgId = randomUUID() + const appUuid = randomUUID() + const appId = `com.rbac.no-password-channel-deny.${testId}` + + await query(` + INSERT INTO public.orgs ( + id, + name, + management_email, + created_by, + use_new_rbac, + enforcing_2fa + ) VALUES ($1::uuid, $2, $3, $4::uuid, true, false) + `, [ + orgId, + `rbac-permissions.test no password channel deny ${testId}`, + `rbac-no-password-channel-deny-${testId}@capgo.app`, + USER_ID, + ]) + + await query(` + INSERT INTO public.apps (id, app_id, name, icon_url, owner_org) + VALUES ($1::uuid, $2, $3, $4, $5::uuid) + `, [ + appUuid, + appId, + `RBAC no password channel deny app ${testId}`, + 'rbac-no-password-channel-deny-icon', + orgId, + ]) + + await query(` + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + granted_by, + is_direct + ) + SELECT + public.rbac_principal_user(), + $1::uuid, + r.id, + public.rbac_scope_app(), + $2::uuid, + $3::uuid, + $1::uuid, + true + FROM public.roles r + WHERE r.name = public.rbac_role_app_developer() + LIMIT 1 + `, [USER_ID, orgId, appUuid]) + + const versionResult = await query(` + INSERT INTO public.app_versions (app_id, name, owner_org, user_id, storage_provider) + VALUES ($1, $2, $3::uuid, $4::uuid, 'r2-direct') + RETURNING id + `, [appId, `1.0.0-no-password-channel-deny-${testId}`, orgId, USER_ID]) + + const channelResult = await query(` + INSERT INTO public.channels (name, app_id, version, created_by, owner_org) + VALUES ($1, $2, $3::bigint, $4::uuid, $5::uuid) + RETURNING id + `, [`production-${testId}`, appId, versionResult.rows[0].id, USER_ID, orgId]) + + await query(` + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_user(), + $1::uuid, + $2::bigint, + public.rbac_perm_channel_promote_bundle(), + false + ) + `, [USER_ID, channelResult.rows[0].id]) + + const result = await query(` + SELECT public.rbac_check_permission_direct_no_password_policy( + public.rbac_perm_channel_promote_bundle(), + $1::uuid, + $2::uuid, + $3, + $4::bigint, + NULL + ) AS allowed + `, [USER_ID, orgId, appId, channelResult.rows[0].id]) + + expect(result.rows[0].allowed).toBe(false) + }) + it('should block direct channel version updates when promote_bundle is denied for the channel', async () => { const testId = randomUUID() const orgId = randomUUID() From 9af6165fcf8fd7a9d3bb238c5d687908b900f1b2 Mon Sep 17 00:00:00 2001 From: APXLBS <203673464+teixr12@users.noreply.github.com> Date: Mon, 11 May 2026 05:03:45 -0300 Subject: [PATCH 2/6] fix(rbac): enforce max api key expiration policy --- ..._enforce_rbac_apikey_expiration_policy.sql | 56 ++++++- tests/rbac-permissions.test.ts | 140 ++++++++++++++++++ 2 files changed, 189 insertions(+), 7 deletions(-) diff --git a/supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql b/supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql index 885d366727..c1c93e9bfc 100644 --- a/supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql +++ b/supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql @@ -1,8 +1,8 @@ -- Enforce org API-key expiration policy during RBAC permission checks. -- --- API-key create/update triggers already reject new non-expiring keys for orgs --- that require expiration. Existing non-expiring keys can predate a policy --- change, so the RBAC direct permission functions must deny them at runtime. +-- API-key create/update triggers already reject new keys that violate org +-- expiration policy. Existing keys can predate a policy change, so the RBAC +-- direct permission functions must deny them at runtime. CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"( "p_permission_key" "text", @@ -28,6 +28,7 @@ DECLARE v_channel_scope boolean := false; v_org_enforcing_2fa boolean; v_org_requires_apikey_expiration boolean := false; + v_org_max_apikey_expiration_days integer; v_password_policy_ok boolean; v_api_key public.apikeys%ROWTYPE; v_channel_org_id uuid; @@ -111,8 +112,12 @@ BEGIN RETURN false; END IF; - SELECT COALESCE(o.require_apikey_expiration, false) - INTO v_org_requires_apikey_expiration + SELECT + COALESCE(o.require_apikey_expiration, false), + o.max_apikey_expiration_days + INTO + v_org_requires_apikey_expiration, + v_org_max_apikey_expiration_days FROM public.orgs o WHERE o.id = v_effective_org_id LIMIT 1; @@ -128,6 +133,22 @@ BEGIN RETURN false; END IF; + IF v_org_max_apikey_expiration_days IS NOT NULL + AND v_api_key.expires_at IS NOT NULL + AND v_api_key.expires_at > now() + make_interval(days => v_org_max_apikey_expiration_days) + THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_EXPIRATION_EXCEEDS_MAX', jsonb_build_object( + 'permission', p_permission_key, + 'key_id', v_api_key.id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'max_apikey_expiration_days', v_org_max_apikey_expiration_days, + 'expires_at', v_api_key.expires_at + )); + RETURN false; + END IF; + IF COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 AND NOT (v_effective_org_id = ANY(v_api_key.limited_to_orgs)) THEN @@ -373,6 +394,7 @@ DECLARE v_channel_scope boolean := false; v_org_enforcing_2fa boolean; v_org_requires_apikey_expiration boolean := false; + v_org_max_apikey_expiration_days integer; v_api_key public.apikeys%ROWTYPE; v_channel_org_id uuid; v_channel_app_id character varying; @@ -455,8 +477,12 @@ BEGIN RETURN false; END IF; - SELECT COALESCE(o.require_apikey_expiration, false) - INTO v_org_requires_apikey_expiration + SELECT + COALESCE(o.require_apikey_expiration, false), + o.max_apikey_expiration_days + INTO + v_org_requires_apikey_expiration, + v_org_max_apikey_expiration_days FROM public.orgs o WHERE o.id = v_effective_org_id LIMIT 1; @@ -472,6 +498,22 @@ BEGIN RETURN false; END IF; + IF v_org_max_apikey_expiration_days IS NOT NULL + AND v_api_key.expires_at IS NOT NULL + AND v_api_key.expires_at > now() + make_interval(days => v_org_max_apikey_expiration_days) + THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_EXPIRATION_EXCEEDS_MAX', jsonb_build_object( + 'permission', p_permission_key, + 'key_id', v_api_key.id, + 'org_id', v_effective_org_id, + 'app_id', v_effective_app_id, + 'channel_id', p_channel_id, + 'max_apikey_expiration_days', v_org_max_apikey_expiration_days, + 'expires_at', v_api_key.expires_at + )); + RETURN false; + END IF; + IF COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 AND NOT (v_effective_org_id = ANY(v_api_key.limited_to_orgs)) THEN diff --git a/tests/rbac-permissions.test.ts b/tests/rbac-permissions.test.ts index 3298b75fd7..463d1f70ec 100644 --- a/tests/rbac-permissions.test.ts +++ b/tests/rbac-permissions.test.ts @@ -882,6 +882,146 @@ describe('rbac permission system', () => { expect(afterPolicyFlip.rows[0].direct_no_password_allowed).toBe(false) }) + it('should deny existing overlong api keys after org lowers max expiration days', async () => { + const testId = randomUUID() + const orgId = randomUUID() + const appUuid = randomUUID() + const appId = `com.rbac.max-expiration-policy.${testId}` + const scopedKey = `rbac-max-expiration-policy-${testId}` + + await query(` + INSERT INTO public.orgs ( + id, + name, + management_email, + created_by, + use_new_rbac, + require_apikey_expiration, + max_apikey_expiration_days, + enforcing_2fa + ) VALUES ( + $1::uuid, + $2, + $3, + $4::uuid, + true, + false, + NULL, + false + ) + `, [ + orgId, + `rbac-permissions.test max expiration policy ${testId}`, + `rbac-max-expiration-policy-${testId}@capgo.app`, + USER_ID, + ]) + + await query(` + INSERT INTO public.apps (id, app_id, name, icon_url, owner_org) + VALUES ($1::uuid, $2, $3, $4, $5::uuid) + `, [ + appUuid, + appId, + `RBAC max expiration policy app ${testId}`, + 'rbac-max-expiration-policy-icon', + orgId, + ]) + + await query(` + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + granted_by, + is_direct + ) + SELECT + public.rbac_principal_user(), + $1::uuid, + r.id, + public.rbac_scope_app(), + $2::uuid, + $3::uuid, + $1::uuid, + true + FROM public.roles r + WHERE r.name = public.rbac_role_app_reader() + LIMIT 1 + `, [USER_ID, orgId, appUuid]) + + await query(` + INSERT INTO public.apikeys ( + user_id, + key, + key_hash, + mode, + name, + limited_to_orgs, + limited_to_apps, + expires_at + ) VALUES ( + $1::uuid, + $2, + NULL, + 'write', + $3, + ARRAY[$4::uuid], + ARRAY[$5]::text[], + now() + interval '120 days' + ) + `, [ + USER_ID, + scopedKey, + `RBAC max expiration policy ${scopedKey}`, + orgId, + appId, + ]) + + const beforePolicyFlip = await query(` + SELECT public.rbac_check_permission_direct( + 'app.read', + $1::uuid, + $2::uuid, + $3, + NULL::bigint, + $4 + ) AS allowed + `, [USER_ID, orgId, appId, scopedKey]) + + await query(` + UPDATE public.orgs + SET max_apikey_expiration_days = 30 + WHERE id = $1::uuid + `, [orgId]) + + const afterPolicyFlip = await query(` + SELECT + public.rbac_check_permission_direct( + 'app.read', + $1::uuid, + $2::uuid, + $3, + NULL::bigint, + $4 + ) AS direct_allowed, + public.rbac_check_permission_direct_no_password_policy( + 'app.read', + $1::uuid, + $2::uuid, + $3, + NULL::bigint, + $4 + ) AS direct_no_password_allowed + `, [USER_ID, orgId, appId, scopedKey]) + + expect(beforePolicyFlip.rows[0].allowed).toBe(true) + expect(afterPolicyFlip.rows[0].direct_allowed).toBe(false) + expect(afterPolicyFlip.rows[0].direct_no_password_allowed).toBe(false) + }) + it('should apply channel deny overrides in no-password direct checks', async () => { const testId = randomUUID() const orgId = randomUUID() From b28b487bc36fc340ee85270569008db959632fce Mon Sep 17 00:00:00 2001 From: APXLBS <203673464+teixr12@users.noreply.github.com> Date: Mon, 11 May 2026 05:14:28 -0300 Subject: [PATCH 3/6] docs(rbac): clarify api key policy lookup --- supabase/functions/_backend/utils/supabase.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 55fc57d069..349c0f60d0 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -306,6 +306,10 @@ export function apikeyHasOrgRight(key: Database['public']['Tables']['apikeys'][' /** * Check if API key has org access AND meets org's API key policy requirements * Returns { valid: true } if all checks pass, or { valid: false, error: string } if not + * + * @param _supabase Deprecated compatibility parameter; policy lookups use + * supabaseAdmin(c) after local org-scope validation so RBAC denials do not hide + * the org policy row. */ export async function apikeyHasOrgRightWithPolicy( c: Context, From 2c3dc12cffd7cf45f9f1d94201b17a183485e5d8 Mon Sep 17 00:00:00 2001 From: APXLBS <203673464+teixr12@users.noreply.github.com> Date: Mon, 11 May 2026 17:28:23 -0300 Subject: [PATCH 4/6] fix: enforce org max api key age in TS policy helper --- .../_backend/public/webhooks/index.ts | 3 ++ supabase/functions/_backend/utils/supabase.ts | 10 ++++- tests/webhooks-apikey-policy.test.ts | 40 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/supabase/functions/_backend/public/webhooks/index.ts b/supabase/functions/_backend/public/webhooks/index.ts index 0b9ef46c8f..c01932d99c 100644 --- a/supabase/functions/_backend/public/webhooks/index.ts +++ b/supabase/functions/_backend/public/webhooks/index.ts @@ -40,6 +40,9 @@ async function assertWebhookOrgPolicy( if (orgCheck.error === 'org_requires_expiring_key') { throw quickError(401, 'org_requires_expiring_key', 'This organization requires API keys with an expiration date. Please use a different key or update this key with an expiration date.') } + if (orgCheck.error === 'expiration_exceeds_max') { + throw quickError(401, 'expiration_exceeds_max', 'API key expiration exceeds this organization\'s maximum allowed validity window. Please use a different key or update this key with a shorter expiration date.') + } throw simpleError('invalid_org_id', 'You can\'t access this organization', { org_id: orgId }) } diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 349c0f60d0..c936aa1019 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -1694,7 +1694,7 @@ export async function checkApikeyMeetsOrgPolicy( ): Promise<{ valid: boolean, error?: string }> { const { data: org, error } = await supabase .from('orgs') - .select('require_apikey_expiration') + .select('require_apikey_expiration, max_apikey_expiration_days') .eq('id', orgId) .single() @@ -1712,6 +1712,14 @@ export async function checkApikeyMeetsOrgPolicy( return { valid: false, error: 'org_requires_expiring_key' } } + if (org.max_apikey_expiration_days && key.expires_at) { + const maxDate = new Date() + maxDate.setDate(maxDate.getDate() + org.max_apikey_expiration_days) + if (new Date(key.expires_at) > maxDate) { + return { valid: false, error: 'expiration_exceeds_max' } + } + } + return { valid: true } } diff --git a/tests/webhooks-apikey-policy.test.ts b/tests/webhooks-apikey-policy.test.ts index 30b6424110..7017628992 100644 --- a/tests/webhooks-apikey-policy.test.ts +++ b/tests/webhooks-apikey-policy.test.ts @@ -12,6 +12,7 @@ const WEBHOOKS_RETRY_URL = getEndpointUrl('/webhooks/deliveries/retry') const legacyApiKeySeedId = numericGlobalId * 2 const expiringSubkeySeedId = legacyApiKeySeedId + 1 const delegatedApiKeySeedId = expiringSubkeySeedId + 1 +const overlongApiKeySeedId = delegatedApiKeySeedId + 1 const seededWebhookId = randomUUID() const seededDeliveryId = randomUUID() @@ -21,6 +22,8 @@ let expiringSubkeyId: number | null = null let expiringSubkeyValue: string | null = null let delegatedApiKeyId: number | null = null let delegatedApiKeyValue: string | null = null +let overlongApiKeyId: number | null = null +let overlongApiKeyValue: string | null = null let createdWebhookId: string | null = null let createdDeliveryId: string | null = null let policyOwnerUserId: string | null = null @@ -92,6 +95,23 @@ beforeAll(async () => { legacyApiKeyId = legacyKeyData.id legacyApiKeyValue = legacyKeyData.key + const { data: overlongKeyData, error: overlongKeyError } = await supabase.from('apikeys').insert({ + id: overlongApiKeySeedId, + user_id: policyOwnerUserId, + key: `overlong-webhook-key-${globalId}`, + key_hash: null, + mode: 'all', + name: `overlong-webhook-key-${globalId}`, + limited_to_apps: [], + limited_to_orgs: [policyOrgId], + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + }).select('id, key').single() + if (overlongKeyError || !overlongKeyData) { + throw new Error(`Failed to seed overlong webhook API key: ${overlongKeyError?.message ?? 'missing key data'}`) + } + overlongApiKeyId = overlongKeyData.id + overlongApiKeyValue = overlongKeyData.key + // Seed preconditions directly so policy tests do not depend on webhook delivery side effects. const { error: webhookError } = await (supabase as any).from('webhooks').insert({ id: seededWebhookId, @@ -210,6 +230,10 @@ afterAll(async () => { await supabase.from('apikeys').delete().eq('id', delegatedApiKeyId) } + if (overlongApiKeyId) { + await supabase.from('apikeys').delete().eq('id', overlongApiKeyId) + } + await supabase.from('role_bindings').delete().eq('org_id', policyOrgId) await supabase.from('org_users').delete().eq('org_id', policyOrgId) await supabase.from('orgs').delete().eq('id', policyOrgId) @@ -260,6 +284,22 @@ describe('webhook endpoints enforce org API key expiration policy', () => { expect(data.error).toBe('org_requires_expiring_key') }) + it('rejects webhook listing for existing org key beyond lowered max expiration days', async () => { + if (!overlongApiKeyValue) + throw new Error('Overlong API key was not created') + + const response = await fetch(`${WEBHOOKS_URL}?orgId=${policyOrgId}`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': overlongApiKeyValue, + }, + }) + + expect(response.status).toBe(401) + const data = await response.json() as { error: string } + expect(data.error).toBe('expiration_exceeds_max') + }) + it('rejects webhook deletion for legacy non-expiring org key', async () => { if (!legacyApiKeyValue || !createdWebhookId) throw new Error('Webhook deletion prerequisites were not created') From 1604f91afe7849f4e5989bfb3dcaae5809202c53 Mon Sep 17 00:00:00 2001 From: APXLBS <203673464+teixr12@users.noreply.github.com> Date: Mon, 11 May 2026 17:32:34 -0300 Subject: [PATCH 5/6] chore: refresh rbac expiration migration timestamp --- ...l => 20260511203500_enforce_rbac_apikey_expiration_policy.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260511013000_enforce_rbac_apikey_expiration_policy.sql => 20260511203500_enforce_rbac_apikey_expiration_policy.sql} (100%) diff --git a/supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql b/supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql similarity index 100% rename from supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql rename to supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql From 7f7523f68d6db8e279bc25c257f6190696ccd35b Mon Sep 17 00:00:00 2001 From: APXLBS <203673464+teixr12@users.noreply.github.com> Date: Mon, 11 May 2026 17:37:18 -0300 Subject: [PATCH 6/6] fix: anchor max key age checks to creation time --- supabase/functions/_backend/utils/supabase.ts | 9 +++++++-- ...1203500_enforce_rbac_apikey_expiration_policy.sql | 12 ++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index c936aa1019..6f5b497b79 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -1713,9 +1713,14 @@ export async function checkApikeyMeetsOrgPolicy( } if (org.max_apikey_expiration_days && key.expires_at) { - const maxDate = new Date() + const createdAt = key.created_at ? new Date(key.created_at) : null + const expiresAt = new Date(key.expires_at) + if (!createdAt || Number.isNaN(createdAt.getTime()) || Number.isNaN(expiresAt.getTime())) { + return { valid: false, error: 'expiration_exceeds_max' } + } + const maxDate = new Date(createdAt) maxDate.setDate(maxDate.getDate() + org.max_apikey_expiration_days) - if (new Date(key.expires_at) > maxDate) { + if (expiresAt > maxDate) { return { valid: false, error: 'expiration_exceeds_max' } } } diff --git a/supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql b/supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql index c1c93e9bfc..57c5fddcd5 100644 --- a/supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql +++ b/supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql @@ -135,7 +135,10 @@ BEGIN IF v_org_max_apikey_expiration_days IS NOT NULL AND v_api_key.expires_at IS NOT NULL - AND v_api_key.expires_at > now() + make_interval(days => v_org_max_apikey_expiration_days) + AND ( + v_api_key.created_at IS NULL + OR v_api_key.expires_at > v_api_key.created_at + make_interval(days => v_org_max_apikey_expiration_days) + ) THEN PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_EXPIRATION_EXCEEDS_MAX', jsonb_build_object( 'permission', p_permission_key, @@ -144,6 +147,7 @@ BEGIN 'app_id', v_effective_app_id, 'channel_id', p_channel_id, 'max_apikey_expiration_days', v_org_max_apikey_expiration_days, + 'created_at', v_api_key.created_at, 'expires_at', v_api_key.expires_at )); RETURN false; @@ -500,7 +504,10 @@ BEGIN IF v_org_max_apikey_expiration_days IS NOT NULL AND v_api_key.expires_at IS NOT NULL - AND v_api_key.expires_at > now() + make_interval(days => v_org_max_apikey_expiration_days) + AND ( + v_api_key.created_at IS NULL + OR v_api_key.expires_at > v_api_key.created_at + make_interval(days => v_org_max_apikey_expiration_days) + ) THEN PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_EXPIRATION_EXCEEDS_MAX', jsonb_build_object( 'permission', p_permission_key, @@ -509,6 +516,7 @@ BEGIN 'app_id', v_effective_app_id, 'channel_id', p_channel_id, 'max_apikey_expiration_days', v_org_max_apikey_expiration_days, + 'created_at', v_api_key.created_at, 'expires_at', v_api_key.expires_at )); RETURN false;