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 4b9514b5ea..6f5b497b79 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -306,20 +306,26 @@ 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, 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 } @@ -1688,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() @@ -1706,6 +1712,19 @@ export async function checkApikeyMeetsOrgPolicy( return { valid: false, error: 'org_requires_expiring_key' } } + if (org.max_apikey_expiration_days && key.expires_at) { + 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 (expiresAt > maxDate) { + return { valid: false, error: 'expiration_exceeds_max' } + } + } + return { valid: true } } diff --git a/supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql b/supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql new file mode 100644 index 0000000000..57c5fddcd5 --- /dev/null +++ b/supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql @@ -0,0 +1,729 @@ +-- Enforce org API-key expiration policy during RBAC permission checks. +-- +-- 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", + "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_org_max_apikey_expiration_days integer; + 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), + 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; + + 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 v_org_max_apikey_expiration_days IS NOT NULL + AND v_api_key.expires_at IS NOT NULL + 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, + '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, + 'created_at', v_api_key.created_at, + '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 + 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_org_max_apikey_expiration_days integer; + 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), + 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; + + 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 v_org_max_apikey_expiration_days IS NOT NULL + AND v_api_key.expires_at IS NOT NULL + 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, + '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, + 'created_at', v_api_key.created_at, + '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 + 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..463d1f70ec 100644 --- a/tests/rbac-permissions.test.ts +++ b/tests/rbac-permissions.test.ts @@ -744,6 +744,385 @@ 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 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() + 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() 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')