diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index 55f3dd0698..f42d66ba78 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -7,10 +7,30 @@ export type Json = | Json[] export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "14.1" + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } } public: { Tables: { @@ -23,8 +43,9 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string + rbac_id: string updated_at: string | null user_id: string } @@ -36,8 +57,9 @@ export type Database = { key_hash?: string | null limited_to_apps?: string[] | null limited_to_orgs?: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode?: Database["public"]["Enums"]["key_mode"] | null name: string + rbac_id?: string updated_at?: string | null user_id: string } @@ -49,8 +71,9 @@ export type Database = { key_hash?: string | null limited_to_apps?: string[] | null limited_to_orgs?: string[] | null - mode?: Database["public"]["Enums"]["key_mode"] + mode?: Database["public"]["Enums"]["key_mode"] | null name?: string + rbac_id?: string updated_at?: string | null user_id?: string } @@ -107,6 +130,7 @@ export type Database = { comment: string | null created_at: string | null deleted: boolean + deleted_at: string | null external_url: string | null id: number key_id: string | null @@ -132,6 +156,7 @@ export type Database = { comment?: string | null created_at?: string | null deleted?: boolean + deleted_at?: string | null external_url?: string | null id?: number key_id?: string | null @@ -157,6 +182,7 @@ export type Database = { comment?: string | null created_at?: string | null deleted?: boolean + deleted_at?: string | null external_url?: string | null id?: number key_id?: string | null @@ -246,6 +272,8 @@ export type Database = { } apps: { Row: { + allow_device_custom_id: boolean + allow_preview: boolean android_store_url: string | null app_id: string channel_device_count: number @@ -258,15 +286,19 @@ export type Database = { ios_store_url: string | null last_version: string | null manifest_bundle_count: number - need_onboarding: boolean name: string | null + need_onboarding: boolean owner_org: string retention: number + stats_refresh_requested_at: string | null + stats_updated_at: string | null transfer_history: Json[] | null updated_at: string | null user_id: string | null } Insert: { + allow_device_custom_id?: boolean + allow_preview?: boolean android_store_url?: string | null app_id: string channel_device_count?: number @@ -279,15 +311,19 @@ export type Database = { ios_store_url?: string | null last_version?: string | null manifest_bundle_count?: number - need_onboarding?: boolean name?: string | null + need_onboarding?: boolean owner_org: string retention?: number + stats_refresh_requested_at?: string | null + stats_updated_at?: string | null transfer_history?: Json[] | null updated_at?: string | null user_id?: string | null } Update: { + allow_device_custom_id?: boolean + allow_preview?: boolean android_store_url?: string | null app_id?: string channel_device_count?: number @@ -300,10 +336,12 @@ export type Database = { ios_store_url?: string | null last_version?: string | null manifest_bundle_count?: number - need_onboarding?: boolean name?: string | null + need_onboarding?: boolean owner_org?: string retention?: number + stats_refresh_requested_at?: string | null + stats_updated_at?: string | null transfer_history?: Json[] | null updated_at?: string | null user_id?: string | null @@ -434,15 +472,7 @@ export type Database = { platform?: string user_id?: string | null } - Relationships: [ - { - foreignKeyName: "build_logs_org_id_fkey" - columns: ["org_id"] - isOneToOne: false - referencedRelation: "orgs" - referencedColumns: ["id"] - }, - ] + Relationships: [] } build_requests: { Row: { @@ -612,6 +642,51 @@ export type Database = { }, ] } + channel_permission_overrides: { + Row: { + channel_id: number + created_at: string + id: string + is_allowed: boolean + permission_key: string + principal_id: string + principal_type: string + } + Insert: { + channel_id: number + created_at?: string + id?: string + is_allowed: boolean + permission_key: string + principal_id: string + principal_type: string + } + Update: { + channel_id?: number + created_at?: string + id?: string + is_allowed?: boolean + permission_key?: string + principal_id?: string + principal_type?: string + } + Relationships: [ + { + foreignKeyName: "channel_permission_overrides_channel_id_fkey" + columns: ["channel_id"] + isOneToOne: false + referencedRelation: "channels" + referencedColumns: ["id"] + }, + { + foreignKeyName: "channel_permission_overrides_permission_key_fkey" + columns: ["permission_key"] + isOneToOne: false + referencedRelation: "permissions" + referencedColumns: ["key"] + }, + ] + } channels: { Row: { allow_dev: boolean @@ -625,11 +700,13 @@ export type Database = { created_by: string disable_auto_update: Database["public"]["Enums"]["disable_update"] disable_auto_update_under_native: boolean + electron: boolean id: number ios: boolean name: string owner_org: string public: boolean + rbac_id: string updated_at: string version: number } @@ -645,11 +722,13 @@ export type Database = { created_by: string disable_auto_update?: Database["public"]["Enums"]["disable_update"] disable_auto_update_under_native?: boolean + electron?: boolean id?: number ios?: boolean name: string owner_org: string public?: boolean + rbac_id?: string updated_at?: string version: number } @@ -665,11 +744,13 @@ export type Database = { created_by?: string disable_auto_update?: Database["public"]["Enums"]["disable_update"] disable_auto_update_under_native?: boolean + electron?: boolean id?: number ios?: boolean name?: string owner_org?: string public?: boolean + rbac_id?: string updated_at?: string version?: number } @@ -831,6 +912,42 @@ export type Database = { } Relationships: [] } + daily_revenue_metrics: { + Row: { + churn_mrr: number + contraction_mrr: number + created_at: string + customer_id: string + date_id: string + expansion_mrr: number + new_business_mrr: number + opening_mrr: number + updated_at: string + } + Insert: { + churn_mrr?: number + contraction_mrr?: number + created_at?: string + customer_id: string + date_id: string + expansion_mrr?: number + new_business_mrr?: number + opening_mrr?: number + updated_at?: string + } + Update: { + churn_mrr?: number + contraction_mrr?: number + created_at?: string + customer_id?: string + date_id?: string + expansion_mrr?: number + new_business_mrr?: number + opening_mrr?: number + updated_at?: string + } + Relationships: [] + } daily_storage: { Row: { app_id: string @@ -860,7 +977,8 @@ export type Database = { get: number | null install: number | null uninstall: number | null - version_id: number + version_id: number | null + version_name: string } Insert: { app_id: string @@ -869,7 +987,8 @@ export type Database = { get?: number | null install?: number | null uninstall?: number | null - version_id: number + version_id?: number | null + version_name: string } Update: { app_id?: string @@ -878,7 +997,8 @@ export type Database = { get?: number | null install?: number | null uninstall?: number | null - version_id?: number + version_id?: number | null + version_name?: string } Relationships: [] } @@ -1074,22 +1194,43 @@ export type Database = { Row: { apps: number apps_active: number | null + build_avg_seconds_day_android: number + build_avg_seconds_day_ios: number + build_count_day_android: number + build_count_day_ios: number + build_total_seconds_day_android: number + build_total_seconds_day_ios: number + builds_android: number | null + builds_ios: number | null + builds_last_month: number | null + builds_last_month_android: number | null + builds_last_month_ios: number | null + builds_success_android: number | null + builds_success_ios: number | null + builds_success_total: number | null + builds_total: number | null bundle_storage_gb: number canceled_orgs: number + churn_revenue: number created_at: string | null credits_bought: number credits_consumed: number date_id: string + demo_apps_created: number devices_last_month: number | null + devices_last_month_android: number | null + devices_last_month_ios: number | null mrr: number need_upgrade: number | null new_paying_orgs: number not_paying: number | null + nrr: number onboarded: number | null + org_conversion_rate: number paying: number | null paying_monthly: number | null paying_yearly: number | null - plan_enterprise: number | null + plan_enterprise: number plan_enterprise_monthly: number plan_enterprise_yearly: number plan_maker: number | null @@ -1101,6 +1242,8 @@ export type Database = { plan_team: number | null plan_team_monthly: number plan_team_yearly: number + plugin_major_breakdown: Json + plugin_version_breakdown: Json registers_today: number revenue_enterprise: number revenue_maker: number @@ -1113,28 +1256,50 @@ export type Database = { updates: number updates_external: number | null updates_last_month: number | null + upgraded_orgs: number users: number | null users_active: number | null } Insert: { apps: number apps_active?: number | null + build_avg_seconds_day_android?: number + build_avg_seconds_day_ios?: number + build_count_day_android?: number + build_count_day_ios?: number + build_total_seconds_day_android?: number + build_total_seconds_day_ios?: number + builds_android?: number | null + builds_ios?: number | null + builds_last_month?: number | null + builds_last_month_android?: number | null + builds_last_month_ios?: number | null + builds_success_android?: number | null + builds_success_ios?: number | null + builds_success_total?: number | null + builds_total?: number | null bundle_storage_gb?: number canceled_orgs?: number + churn_revenue?: number created_at?: string | null credits_bought?: number credits_consumed?: number date_id: string + demo_apps_created?: number devices_last_month?: number | null + devices_last_month_android?: number | null + devices_last_month_ios?: number | null mrr?: number need_upgrade?: number | null new_paying_orgs?: number not_paying?: number | null + nrr?: number onboarded?: number | null + org_conversion_rate?: number paying?: number | null paying_monthly?: number | null paying_yearly?: number | null - plan_enterprise?: number | null + plan_enterprise?: number plan_enterprise_monthly?: number plan_enterprise_yearly?: number plan_maker?: number | null @@ -1146,6 +1311,8 @@ export type Database = { plan_team?: number | null plan_team_monthly?: number plan_team_yearly?: number + plugin_major_breakdown?: Json + plugin_version_breakdown?: Json registers_today?: number revenue_enterprise?: number revenue_maker?: number @@ -1158,28 +1325,50 @@ export type Database = { updates: number updates_external?: number | null updates_last_month?: number | null + upgraded_orgs?: number users?: number | null users_active?: number | null } Update: { apps?: number apps_active?: number | null + build_avg_seconds_day_android?: number + build_avg_seconds_day_ios?: number + build_count_day_android?: number + build_count_day_ios?: number + build_total_seconds_day_android?: number + build_total_seconds_day_ios?: number + builds_android?: number | null + builds_ios?: number | null + builds_last_month?: number | null + builds_last_month_android?: number | null + builds_last_month_ios?: number | null + builds_success_android?: number | null + builds_success_ios?: number | null + builds_success_total?: number | null + builds_total?: number | null bundle_storage_gb?: number canceled_orgs?: number + churn_revenue?: number created_at?: string | null credits_bought?: number credits_consumed?: number date_id?: string + demo_apps_created?: number devices_last_month?: number | null + devices_last_month_android?: number | null + devices_last_month_ios?: number | null mrr?: number need_upgrade?: number | null new_paying_orgs?: number not_paying?: number | null + nrr?: number onboarded?: number | null + org_conversion_rate?: number paying?: number | null paying_monthly?: number | null paying_yearly?: number | null - plan_enterprise?: number | null + plan_enterprise?: number plan_enterprise_monthly?: number plan_enterprise_yearly?: number plan_maker?: number | null @@ -1191,6 +1380,8 @@ export type Database = { plan_team?: number | null plan_team_monthly?: number plan_team_yearly?: number + plugin_major_breakdown?: Json + plugin_version_breakdown?: Json registers_today?: number revenue_enterprise?: number revenue_maker?: number @@ -1203,11 +1394,86 @@ export type Database = { updates?: number updates_external?: number | null updates_last_month?: number | null + upgraded_orgs?: number users?: number | null users_active?: number | null } Relationships: [] } + group_members: { + Row: { + added_at: string + added_by: string | null + group_id: string + user_id: string + } + Insert: { + added_at?: string + added_by?: string | null + group_id: string + user_id: string + } + Update: { + added_at?: string + added_by?: string | null + group_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "group_members_group_id_fkey" + columns: ["group_id"] + isOneToOne: false + referencedRelation: "groups" + referencedColumns: ["id"] + }, + { + foreignKeyName: "group_members_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + groups: { + Row: { + created_at: string + created_by: string | null + description: string | null + id: string + is_system: boolean + name: string + org_id: string + } + Insert: { + created_at?: string + created_by?: string | null + description?: string | null + id?: string + is_system?: boolean + name: string + org_id: string + } + Update: { + created_at?: string + created_by?: string | null + description?: string | null + id?: string + is_system?: boolean + name?: string + org_id?: string + } + Relationships: [ + { + foreignKeyName: "groups_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "orgs" + referencedColumns: ["id"] + }, + ] + } manifest: { Row: { app_version_id: number @@ -1281,6 +1547,59 @@ export type Database = { }, ] } + org_metrics_cache: { + Row: { + bandwidth: number + build_time_unit: number + cached_at: string + end_date: string + fail: number + get: number + install: number + mau: number + org_id: string + start_date: string + storage: number + uninstall: number + } + Insert: { + bandwidth: number + build_time_unit: number + cached_at?: string + end_date: string + fail: number + get: number + install: number + mau: number + org_id: string + start_date: string + storage: number + uninstall: number + } + Update: { + bandwidth?: number + build_time_unit?: number + cached_at?: string + end_date?: string + fail?: number + get?: number + install?: number + mau?: number + org_id?: string + start_date?: string + storage?: number + uninstall?: number + } + Relationships: [ + { + foreignKeyName: "org_metrics_cache_org_id_fkey" + columns: ["org_id"] + isOneToOne: true + referencedRelation: "orgs" + referencedColumns: ["id"] + }, + ] + } org_users: { Row: { app_id: string | null @@ -1288,6 +1607,7 @@ export type Database = { created_at: string | null id: number org_id: string + rbac_role_name: string | null updated_at: string | null user_id: string user_right: Database["public"]["Enums"]["user_min_right"] | null @@ -1298,6 +1618,7 @@ export type Database = { created_at?: string | null id?: number org_id: string + rbac_role_name?: string | null updated_at?: string | null user_id: string user_right?: Database["public"]["Enums"]["user_min_right"] | null @@ -1308,6 +1629,7 @@ export type Database = { created_at?: string | null id?: number org_id?: string + rbac_role_name?: string | null updated_at?: string | null user_id?: string user_right?: Database["public"]["Enums"]["user_min_right"] | null @@ -1349,8 +1671,10 @@ export type Database = { created_by: string customer_id: string | null email_preferences: Json + enforce_encrypted_bundles: boolean enforce_hashed_api_keys: boolean enforcing_2fa: boolean + has_usage_credits: boolean id: string last_stats_updated_at: string | null logo: string | null @@ -1359,16 +1683,22 @@ export type Database = { name: string password_policy_config: Json | null require_apikey_expiration: boolean + required_encryption_key: string | null + stats_refresh_requested_at: string | null stats_updated_at: string | null updated_at: string | null + use_new_rbac: boolean + website: string | null } Insert: { created_at?: string | null created_by: string customer_id?: string | null email_preferences?: Json + enforce_encrypted_bundles?: boolean enforce_hashed_api_keys?: boolean enforcing_2fa?: boolean + has_usage_credits?: boolean id?: string last_stats_updated_at?: string | null logo?: string | null @@ -1377,16 +1707,22 @@ export type Database = { name: string password_policy_config?: Json | null require_apikey_expiration?: boolean + required_encryption_key?: string | null + stats_refresh_requested_at?: string | null stats_updated_at?: string | null updated_at?: string | null + use_new_rbac?: boolean + website?: string | null } Update: { created_at?: string | null created_by?: string customer_id?: string | null email_preferences?: Json + enforce_encrypted_bundles?: boolean enforce_hashed_api_keys?: boolean enforcing_2fa?: boolean + has_usage_credits?: boolean id?: string last_stats_updated_at?: string | null logo?: string | null @@ -1395,8 +1731,12 @@ export type Database = { name?: string password_policy_config?: Json | null require_apikey_expiration?: boolean + required_encryption_key?: string | null + stats_refresh_requested_at?: string | null stats_updated_at?: string | null updated_at?: string | null + use_new_rbac?: boolean + website?: string | null } Relationships: [ { @@ -1415,6 +1755,41 @@ export type Database = { }, ] } + permissions: { + Row: { + bundle_id: number | null + created_at: string + description: string | null + id: string + key: string + scope_type: string + } + Insert: { + bundle_id?: number | null + created_at?: string + description?: string | null + id?: string + key: string + scope_type: string + } + Update: { + bundle_id?: number | null + created_at?: string + description?: string | null + id?: string + key?: string + scope_type?: string + } + Relationships: [ + { + foreignKeyName: "permissions_bundle_id_fkey" + columns: ["bundle_id"] + isOneToOne: false + referencedRelation: "app_versions" + referencedColumns: ["id"] + }, + ] + } plans: { Row: { bandwidth: number @@ -1472,49 +1847,303 @@ export type Database = { } Relationships: [] } - stats: { + processed_stripe_events: { Row: { - action: Database["public"]["Enums"]["stats_action"] - app_id: string created_at: string - device_id: string - id: number - version_name: string + customer_id: string + date_id: string + event_id: string } Insert: { - action: Database["public"]["Enums"]["stats_action"] - app_id: string - created_at: string - device_id: string - id?: never - version_name?: string + created_at?: string + customer_id: string + date_id: string + event_id: string } Update: { - action?: Database["public"]["Enums"]["stats_action"] - app_id?: string created_at?: string - device_id?: string - id?: never - version_name?: string + customer_id?: string + date_id?: string + event_id?: string } Relationships: [] } - storage_usage: { + role_bindings: { Row: { - app_id: string - device_id: string - file_size: number - id: number - timestamp: string + app_id: string | null + bundle_id: number | null + channel_id: string | null + expires_at: string | null + granted_at: string + granted_by: string + id: string + is_direct: boolean + org_id: string | null + principal_id: string + principal_type: string + reason: string | null + role_id: string + scope_type: string } Insert: { - app_id: string - device_id: string - file_size: number - id?: number - timestamp?: string - } - Update: { + app_id?: string | null + bundle_id?: number | null + channel_id?: string | null + expires_at?: string | null + granted_at?: string + granted_by: string + id?: string + is_direct?: boolean + org_id?: string | null + principal_id: string + principal_type: string + reason?: string | null + role_id: string + scope_type: string + } + Update: { + app_id?: string | null + bundle_id?: number | null + channel_id?: string | null + expires_at?: string | null + granted_at?: string + granted_by?: string + id?: string + is_direct?: boolean + org_id?: string | null + principal_id?: string + principal_type?: string + reason?: string | null + role_id?: string + scope_type?: string + } + Relationships: [ + { + foreignKeyName: "role_bindings_app_id_fkey" + columns: ["app_id"] + isOneToOne: false + referencedRelation: "apps" + referencedColumns: ["id"] + }, + { + foreignKeyName: "role_bindings_bundle_id_fkey" + columns: ["bundle_id"] + isOneToOne: false + referencedRelation: "app_versions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "role_bindings_channel_id_fkey" + columns: ["channel_id"] + isOneToOne: false + referencedRelation: "channels" + referencedColumns: ["rbac_id"] + }, + { + foreignKeyName: "role_bindings_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "orgs" + referencedColumns: ["id"] + }, + { + foreignKeyName: "role_bindings_role_id_fkey" + columns: ["role_id"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["id"] + }, + ] + } + role_hierarchy: { + Row: { + child_role_id: string + parent_role_id: string + } + Insert: { + child_role_id: string + parent_role_id: string + } + Update: { + child_role_id?: string + parent_role_id?: string + } + Relationships: [ + { + foreignKeyName: "role_hierarchy_child_role_id_fkey" + columns: ["child_role_id"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "role_hierarchy_parent_role_id_fkey" + columns: ["parent_role_id"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["id"] + }, + ] + } + role_permissions: { + Row: { + permission_id: string + role_id: string + } + Insert: { + permission_id: string + role_id: string + } + Update: { + permission_id?: string + role_id?: string + } + Relationships: [ + { + foreignKeyName: "role_permissions_permission_id_fkey" + columns: ["permission_id"] + isOneToOne: false + referencedRelation: "permissions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "role_permissions_role_id_fkey" + columns: ["role_id"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["id"] + }, + ] + } + roles: { + Row: { + created_at: string + created_by: string | null + description: string | null + id: string + is_assignable: boolean + name: string + priority_rank: number + scope_type: string + } + Insert: { + created_at?: string + created_by?: string | null + description?: string | null + id?: string + is_assignable?: boolean + name: string + priority_rank?: number + scope_type: string + } + Update: { + created_at?: string + created_by?: string | null + description?: string | null + id?: string + is_assignable?: boolean + name?: string + priority_rank?: number + scope_type?: string + } + Relationships: [] + } + sso_providers: { + Row: { + attribute_mapping: Json | null + created_at: string + dns_verification_token: string + dns_verified_at: string | null + domain: string + enforce_sso: boolean + id: string + metadata_url: string | null + org_id: string + provider_id: string | null + status: string + updated_at: string + } + Insert: { + attribute_mapping?: Json | null + created_at?: string + dns_verification_token: string + dns_verified_at?: string | null + domain: string + enforce_sso?: boolean + id?: string + metadata_url?: string | null + org_id: string + provider_id?: string | null + status?: string + updated_at?: string + } + Update: { + attribute_mapping?: Json | null + created_at?: string + dns_verification_token?: string + dns_verified_at?: string | null + domain?: string + enforce_sso?: boolean + id?: string + metadata_url?: string | null + org_id?: string + provider_id?: string | null + status?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "sso_providers_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "orgs" + referencedColumns: ["id"] + }, + ] + } + stats: { + Row: { + action: Database["public"]["Enums"]["stats_action"] + app_id: string + created_at: string + device_id: string + id: number + version_name: string + } + Insert: { + action: Database["public"]["Enums"]["stats_action"] + app_id: string + created_at: string + device_id: string + id?: never + version_name?: string + } + Update: { + action?: Database["public"]["Enums"]["stats_action"] + app_id?: string + created_at?: string + device_id?: string + id?: never + version_name?: string + } + Relationships: [] + } + storage_usage: { + Row: { + app_id: string + device_id: string + file_size: number + id: number + timestamp: string + } + Insert: { + app_id: string + device_id: string + file_size: number + id?: number + timestamp?: string + } + Update: { app_id?: string device_id?: string file_size?: number @@ -1529,10 +2158,13 @@ export type Database = { build_time_exceeded: boolean | null canceled_at: string | null created_at: string + customer_country: string | null customer_id: string id: number is_good_plan: boolean | null + last_stripe_event_at: string | null mau_exceeded: boolean | null + paid_at: string | null plan_calculated_at: string | null plan_usage: number | null price_id: string | null @@ -1542,19 +2174,22 @@ export type Database = { subscription_anchor_end: string subscription_anchor_start: string subscription_id: string | null - subscription_metered: Json trial_at: string updated_at: string + upgraded_at: string | null } Insert: { bandwidth_exceeded?: boolean | null build_time_exceeded?: boolean | null canceled_at?: string | null created_at?: string + customer_country?: string | null customer_id: string id?: number is_good_plan?: boolean | null + last_stripe_event_at?: string | null mau_exceeded?: boolean | null + paid_at?: string | null plan_calculated_at?: string | null plan_usage?: number | null price_id?: string | null @@ -1564,19 +2199,22 @@ export type Database = { subscription_anchor_end?: string subscription_anchor_start?: string subscription_id?: string | null - subscription_metered?: Json trial_at?: string updated_at?: string + upgraded_at?: string | null } Update: { bandwidth_exceeded?: boolean | null build_time_exceeded?: boolean | null canceled_at?: string | null created_at?: string + customer_country?: string | null customer_id?: string id?: number is_good_plan?: boolean | null + last_stripe_event_at?: string | null mau_exceeded?: boolean | null + paid_at?: string | null plan_calculated_at?: string | null plan_usage?: number | null price_id?: string | null @@ -1586,9 +2224,9 @@ export type Database = { subscription_anchor_end?: string subscription_anchor_start?: string subscription_id?: string | null - subscription_metered?: Json trial_at?: string updated_at?: string + upgraded_at?: string | null } Relationships: [ { @@ -1611,6 +2249,7 @@ export type Database = { invite_magic_string: string last_name: string org_id: string + rbac_role_name: string | null role: Database["public"]["Enums"]["user_min_right"] updated_at: string } @@ -1624,6 +2263,7 @@ export type Database = { invite_magic_string?: string last_name: string org_id: string + rbac_role_name?: string | null role: Database["public"]["Enums"]["user_min_right"] updated_at?: string } @@ -1637,6 +2277,7 @@ export type Database = { invite_magic_string?: string last_name?: string org_id?: string + rbac_role_name?: string | null role?: Database["public"]["Enums"]["user_min_right"] updated_at?: string } @@ -1924,11 +2565,33 @@ export type Database = { }, ] } + user_security: { + Row: { + created_at: string + email_otp_verified_at: string | null + updated_at: string + user_id: string + } + Insert: { + created_at?: string + email_otp_verified_at?: string | null + updated_at?: string + user_id: string + } + Update: { + created_at?: string + email_otp_verified_at?: string | null + updated_at?: string + user_id?: string + } + Relationships: [] + } users: { Row: { ban_time: string | null country: string | null created_at: string | null + created_via_invite: boolean email: string email_preferences: Json enable_notifications: boolean @@ -1943,6 +2606,7 @@ export type Database = { ban_time?: string | null country?: string | null created_at?: string | null + created_via_invite?: boolean email: string email_preferences?: Json enable_notifications?: boolean @@ -1957,6 +2621,7 @@ export type Database = { ban_time?: string | null country?: string | null created_at?: string | null + created_via_invite?: boolean email?: string email_preferences?: Json enable_notifications?: boolean @@ -1995,19 +2660,22 @@ export type Database = { action: Database["public"]["Enums"]["version_action"] app_id: string timestamp: string - version_id: number + version_id: number | null + version_name: string | null } Insert: { action: Database["public"]["Enums"]["version_action"] app_id: string timestamp?: string - version_id: number + version_id?: number | null + version_name?: string | null } Update: { action?: Database["public"]["Enums"]["version_action"] app_id?: string timestamp?: string - version_id?: number + version_id?: number | null + version_name?: string | null } Relationships: [] } @@ -2201,6 +2869,7 @@ export type Database = { overage_unpaid: number }[] } + audit_logs_allowed_orgs: { Args: never; Returns: string[] } calculate_credit_cost: { Args: { p_metric: Database["public"]["Enums"]["credit_metric_type"] @@ -2212,6 +2881,41 @@ export type Database = { credits_required: number }[] } + calculate_org_metrics_cache_entry: { + Args: { p_end_date: string; p_org_id: string; p_start_date: string } + Returns: { + bandwidth: number + build_time_unit: number + cached_at: string + end_date: string + fail: number + get: number + install: number + mau: number + org_id: string + start_date: string + storage: number + uninstall: number + } + SetofOptions: { + from: "*" + to: "org_metrics_cache" + isOneToOne: true + isSetofReturn: false + } + } + check_apikey_hashed_key_enforcement: { + Args: { apikey_row: Database["public"]["Tables"]["apikeys"]["Row"] } + Returns: boolean + } + check_domain_sso: { + Args: { p_domain: string } + Returns: { + has_sso: boolean + org_id: string + provider_id: string + }[] + } check_min_rights: | { Args: { @@ -2232,6 +2936,30 @@ export type Database = { } Returns: boolean } + check_min_rights_legacy: { + Args: { + app_id: string + channel_id: number + min_right: Database["public"]["Enums"]["user_min_right"] + org_id: string + user_id: string + } + Returns: boolean + } + check_min_rights_legacy_no_password_policy: { + Args: { + app_id: string + channel_id: number + min_right: Database["public"]["Enums"]["user_min_right"] + org_id: string + user_id: string + } + Returns: boolean + } + check_org_encrypted_bundle_enforcement: { + Args: { org_id: string; session_key: string } + Returns: boolean + } check_org_hashed_key_enforcement: { Args: { apikey_row: Database["public"]["Tables"]["apikeys"]["Row"] @@ -2261,11 +2989,28 @@ export type Database = { Returns: number } cleanup_expired_apikeys: { Args: never; Returns: undefined } + cleanup_expired_demo_apps: { Args: never; Returns: undefined } cleanup_frequent_job_details: { Args: never; Returns: undefined } cleanup_job_run_details_7days: { Args: never; Returns: undefined } cleanup_old_audit_logs: { Args: never; Returns: undefined } + cleanup_old_channel_devices: { Args: never; Returns: undefined } cleanup_queue_messages: { Args: never; Returns: undefined } + cleanup_tmp_users: { Args: never; Returns: undefined } cleanup_webhook_deliveries: { Args: never; Returns: undefined } + clear_onboarding_app_data: { + Args: { p_app_uuid: string } + Returns: undefined + } + cli_check_permission: { + Args: { + apikey: string + app_id?: string + channel_id?: number + org_id?: string + permission_key: string + } + Returns: boolean + } convert_bytes_to_gb: { Args: { bytes_value: number }; Returns: number } convert_bytes_to_mb: { Args: { bytes_value: number }; Returns: number } convert_gb_to_bytes: { Args: { gb: number }; Returns: number } @@ -2284,6 +3029,74 @@ export type Database = { plan_name: string }[] } + count_non_compliant_bundles: { + Args: { org_id: string; required_key?: string } + Returns: { + non_encrypted_count: number + total_non_compliant: number + wrong_key_count: number + }[] + } + create_hashed_apikey: { + Args: { + p_expires_at?: string + p_limited_to_apps?: string[] + p_limited_to_orgs?: string[] + p_mode?: Database["public"]["Enums"]["key_mode"] + p_name?: string + } + Returns: { + created_at: string | null + expires_at: string | null + id: number + key: string | null + key_hash: string | null + limited_to_apps: string[] | null + limited_to_orgs: string[] | null + mode: Database["public"]["Enums"]["key_mode"] | null + name: string + rbac_id: string + updated_at: string | null + user_id: string + } + SetofOptions: { + from: "*" + to: "apikeys" + isOneToOne: true + isSetofReturn: false + } + } + create_hashed_apikey_for_user: { + Args: { + p_expires_at?: string + p_limited_to_apps?: string[] + p_limited_to_orgs?: string[] + p_mode?: Database["public"]["Enums"]["key_mode"] + p_name?: string + p_user_id: string + } + Returns: { + created_at: string | null + expires_at: string | null + id: number + key: string | null + key_hash: string | null + limited_to_apps: string[] | null + limited_to_orgs: string[] | null + mode: Database["public"]["Enums"]["key_mode"] | null + name: string + rbac_id: string + updated_at: string | null + user_id: string + } + SetofOptions: { + from: "*" + to: "apikeys" + isOneToOne: true + isSetofReturn: false + } + } + current_request_role: { Args: never; Returns: string } delete_accounts_marked_for_deletion: { Args: never Returns: { @@ -2291,8 +3104,21 @@ export type Database = { deleted_user_ids: string[] }[] } + delete_group_with_bindings: { + Args: { group_id: string } + Returns: undefined + } delete_http_response: { Args: { request_id: number }; Returns: undefined } + delete_non_compliant_bundles: { + Args: { org_id: string; required_key?: string } + Returns: number + } delete_old_deleted_apps: { Args: never; Returns: undefined } + delete_old_deleted_versions: { Args: never; Returns: undefined } + delete_org_member_role: { + Args: { p_org_id: string; p_user_id: string } + Returns: string + } delete_user: { Args: never; Returns: undefined } exist_app_v2: { Args: { appid: string }; Returns: boolean } exist_app_versions: @@ -2312,8 +3138,9 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string + rbac_id: string updated_at: string | null user_id: string }[] @@ -2344,9 +3171,60 @@ export type Database = { name: string }[] } - get_account_removal_date: { Args: { user_id: string }; Returns: string } + get_accessible_apps_for_apikey_v2: { + Args: { apikey: string } + Returns: { + allow_device_custom_id: boolean + allow_preview: boolean + android_store_url: string | null + app_id: string + channel_device_count: number + created_at: string | null + default_upload_channel: string + existing_app: boolean + expose_metadata: boolean + icon_url: string + id: string | null + ios_store_url: string | null + last_version: string | null + manifest_bundle_count: number + name: string | null + need_onboarding: boolean + owner_org: string + retention: number + stats_refresh_requested_at: string | null + stats_updated_at: string | null + transfer_history: Json[] | null + updated_at: string | null + user_id: string | null + }[] + SetofOptions: { + from: "*" + to: "apps" + isOneToOne: false + isSetofReturn: true + } + } + get_account_removal_date: { Args: never; Returns: string } get_apikey: { Args: never; Returns: string } get_apikey_header: { Args: never; Returns: string } + get_app_access_rbac: { + Args: { p_app_id: string } + Returns: { + expires_at: string + granted_at: string + granted_by: string + id: string + is_direct: boolean + principal_id: string + principal_name: string + principal_type: string + reason: string + role_description: string + role_id: string + role_name: string + }[] + } get_app_metrics: | { Args: { org_id: string } @@ -2378,6 +3256,26 @@ export type Database = { uninstall: number }[] } + | { + Args: { + p_app_id: string + p_end_date: string + p_org_id: string + p_start_date: string + } + Returns: { + app_id: string + bandwidth: number + build_time_unit: number + date: string + fail: number + get: number + install: number + mau: number + storage: number + uninstall: number + }[] + } get_app_versions: { Args: { apikey: string; appid: string; name_version: string } Returns: number @@ -2452,6 +3350,13 @@ export type Database = { } Returns: string } + get_identity_org_allowed_apikey_only: { + Args: { + keymode: Database["public"]["Enums"]["key_mode"][] + org_id: string + } + Returns: string + } get_identity_org_appid: { Args: { app_id: string @@ -2465,30 +3370,10 @@ export type Database = { Returns: { org_logo: string org_name: string - role: Database["public"]["Enums"]["user_min_right"] + role: string }[] } - get_metered_usage: - | { - Args: never - Returns: Database["public"]["CompositeTypes"]["stats_table"] - SetofOptions: { - from: "*" - to: "stats_table" - isOneToOne: true - isSetofReturn: false - } - } - | { - Args: { orgid: string } - Returns: Database["public"]["CompositeTypes"]["stats_table"] - SetofOptions: { - from: "*" - to: "stats_table" - isOneToOne: true - isSetofReturn: false - } - } + get_mfa_email_otp_enforced_at: { Args: never; Returns: string } get_next_cron_time: { Args: { p_schedule: string; p_timestamp: string } Returns: string @@ -2497,7 +3382,22 @@ export type Database = { Args: { current_val: number; max_val: number; pattern: string } Returns: number } - get_next_stats_update_date: { Args: { org: string }; Returns: string } + get_next_stats_update_date: { Args: { org: string }; Returns: string } + get_org_apikeys: { + Args: { p_org_id: string } + Returns: { + created_at: string + expires_at: string + id: number + limited_to_apps: string[] + limited_to_orgs: string[] + mode: Database["public"]["Enums"]["key_mode"] + name: string + owner_email: string + rbac_id: string + user_id: string + }[] + } get_org_build_time_unit: { Args: { p_end_date: string; p_org_id: string; p_start_date: string } Returns: { @@ -2528,6 +3428,21 @@ export type Database = { uid: string }[] } + get_org_members_rbac: { + Args: { p_org_id: string } + Returns: { + binding_id: string + email: string + granted_at: string + image_url: string + is_invite: boolean + is_tmp: boolean + org_user_id: number + role_id: string + role_name: string + user_id: string + }[] + } get_org_owner_id: { Args: { apikey: string; app_id: string } Returns: string @@ -2536,6 +3451,33 @@ export type Database = { Args: { apikey: string; app_id: string } Returns: string } + get_org_perm_for_apikey_v2: { + Args: { apikey: string; app_id: string } + Returns: string + } + get_org_user_access_rbac: { + Args: { p_org_id: string; p_user_id: string } + Returns: { + app_id: string + channel_id: string + expires_at: string + granted_at: string + granted_by: string + group_name: string + id: string + is_direct: boolean + org_id: string + principal_id: string + principal_name: string + principal_type: string + reason: string + role_description: string + role_id: string + role_name: string + scope_type: string + user_email: string + }[] + } get_organization_cli_warnings: { Args: { cli_version: string; orgid: string } Returns: Json[] @@ -2600,10 +3542,12 @@ export type Database = { "2fa_has_access": boolean app_count: number can_use_more: boolean + created_at: string created_by: string credit_available: number credit_next_expiration: string credit_total: number + enforce_encrypted_bundles: boolean enforce_hashed_api_keys: boolean enforcing_2fa: boolean gid: string @@ -2611,16 +3555,22 @@ export type Database = { is_yearly: boolean logo: string management_email: string + max_apikey_expiration_days: number name: string next_stats_update_at: string password_has_access: boolean password_policy_config: Json paying: boolean + require_apikey_expiration: boolean + required_encryption_key: string role: string + stats_refresh_requested_at: string stats_updated_at: string subscription_end: string subscription_start: string trial_left: number + use_new_rbac: boolean + website: string }[] } | { @@ -2629,10 +3579,12 @@ export type Database = { "2fa_has_access": boolean app_count: number can_use_more: boolean + created_at: string created_by: string credit_available: number credit_next_expiration: string credit_total: number + enforce_encrypted_bundles: boolean enforce_hashed_api_keys: boolean enforcing_2fa: boolean gid: string @@ -2640,22 +3592,50 @@ export type Database = { is_yearly: boolean logo: string management_email: string + max_apikey_expiration_days: number name: string next_stats_update_at: string password_has_access: boolean password_policy_config: Json paying: boolean + require_apikey_expiration: boolean + required_encryption_key: string role: string + stats_refresh_requested_at: string stats_updated_at: string subscription_end: string subscription_start: string trial_left: number + use_new_rbac: boolean + website: string }[] } get_password_policy_hash: { Args: { policy_config: Json } Returns: string } + get_plan_usage_and_fit: { + Args: { orgid: string } + Returns: { + bandwidth_percent: number + build_time_percent: number + is_good_plan: boolean + mau_percent: number + storage_percent: number + total_percent: number + }[] + } + get_plan_usage_and_fit_uncached: { + Args: { orgid: string } + Returns: { + bandwidth_percent: number + build_time_percent: number + is_good_plan: boolean + mau_percent: number + storage_percent: number + total_percent: number + }[] + } get_plan_usage_percent_detailed: | { Args: { orgid: string } @@ -2677,11 +3657,31 @@ export type Database = { total_percent: number }[] } + get_sso_enforcement_by_domain: { + Args: { p_domain: string } + Returns: { + enforce_sso: boolean + org_id: string + }[] + } get_total_app_storage_size_orgs: { Args: { app_id: string; org_id: string } Returns: number } get_total_metrics: + | { + Args: never + Returns: { + bandwidth: number + build_time_unit: number + fail: number + get: number + install: number + mau: number + storage: number + uninstall: number + }[] + } | { Args: { org_id: string } Returns: { @@ -2728,6 +3728,12 @@ export type Database = { Args: { app_id: string } Returns: string } + get_user_org_ids: { + Args: never + Returns: { + org_id: string + }[] + } get_versions_with_no_metadata: { Args: never Returns: { @@ -2737,6 +3743,7 @@ export type Database = { comment: string | null created_at: string | null deleted: boolean + deleted_at: string | null external_url: string | null id: number key_id: string | null @@ -2797,6 +3804,9 @@ export type Database = { } Returns: boolean } + has_seeded_demo_data: { Args: { p_app_id: string }; Returns: boolean } + internal_request_db_user_names: { Args: never; Returns: string[] } + internal_request_role_names: { Args: never; Returns: string[] } invite_user_to_org: { Args: { email: string @@ -2805,10 +3815,11 @@ export type Database = { } Returns: string } + invite_user_to_org_rbac: { + Args: { email: string; org_id: string; role_name: string } + Returns: string + } is_account_disabled: { Args: { user_id: string }; Returns: boolean } - is_admin: - | { Args: never; Returns: boolean } - | { Args: { userid: string }; Returns: boolean } is_allowed_action: { Args: { apikey: string; appid: string } Returns: boolean @@ -2850,8 +3861,13 @@ export type Database = { Args: { org_id: string } Returns: boolean } + is_bundle_encrypted: { Args: { session_key: string }; Returns: boolean } is_canceled_org: { Args: { orgid: string }; Returns: boolean } is_good_plan_v5_org: { Args: { orgid: string }; Returns: boolean } + is_internal_request_role: { + Args: { caller_role: string } + Returns: boolean + } is_mau_exceeded_by_org: { Args: { org_id: string }; Returns: boolean } is_member_of_org: { Args: { org_id: string; user_id: string } @@ -2871,8 +3887,25 @@ export type Database = { Returns: boolean } is_paying_org: { Args: { orgid: string }; Returns: boolean } + is_platform_admin: + | { Args: never; Returns: boolean } + | { Args: { userid: string }; Returns: boolean } + is_rbac_enabled_globally: { Args: never; Returns: boolean } + is_recent_email_otp_verified: { + Args: { p_user_id: string } + Returns: boolean + } is_storage_exceeded_by_org: { Args: { org_id: string }; Returns: boolean } is_trial_org: { Args: { orgid: string }; Returns: number } + is_user_app_admin: { + Args: { p_app_id: string; p_user_id: string } + Returns: boolean + } + is_user_org_admin: { + Args: { p_org_id: string; p_user_id: string } + Returns: boolean + } + mark_app_stats_refreshed: { Args: { p_app_id: string }; Returns: string } mass_edit_queue_messages_cf_ids: { Args: { updates: Database["public"]["CompositeTypes"]["message_update"][] @@ -2903,6 +3936,7 @@ export type Database = { } process_cron_stats_jobs: { Args: never; Returns: undefined } process_cron_sync_sub_jobs: { Args: never; Returns: undefined } + process_daily_fail_ratio_email: { Args: never; Returns: undefined } process_deploy_install_stats_email: { Args: never; Returns: undefined } process_failed_uploads: { Args: never; Returns: undefined } process_free_trial_expired: { Args: never; Returns: undefined } @@ -2918,10 +3952,224 @@ export type Database = { process_stats_email_monthly: { Args: never; Returns: undefined } process_stats_email_weekly: { Args: never; Returns: undefined } process_subscribed_orgs: { Args: never; Returns: undefined } + queue_cron_stat_app_for_app: { + Args: { p_app_id: string; p_org_id?: string } + Returns: undefined + } queue_cron_stat_org_for_org: { Args: { customer_id: string; org_id: string } Returns: undefined } + rbac_check_permission: { + Args: { + p_app_id?: string + p_channel_id?: number + p_org_id?: string + p_permission_key: string + } + Returns: boolean + } + rbac_check_permission_direct: { + Args: { + p_apikey?: string + p_app_id: string + p_channel_id: number + p_org_id: string + p_permission_key: string + p_user_id: string + } + Returns: boolean + } + rbac_check_permission_direct_no_password_policy: { + Args: { + p_apikey?: string + p_app_id: string + p_channel_id: number + p_org_id: string + p_permission_key: string + p_user_id: string + } + Returns: boolean + } + rbac_check_permission_no_password_policy: { + Args: { + p_app_id?: string + p_channel_id?: number + p_org_id?: string + p_permission_key: string + } + Returns: boolean + } + rbac_check_permission_request: { + Args: { + p_app_id?: string + p_channel_id?: number + p_org_id?: string + p_permission_key: string + } + Returns: boolean + } + rbac_enable_for_org: { + Args: { p_granted_by?: string; p_org_id: string } + Returns: Json + } + rbac_has_permission: { + Args: { + p_app_id: string + p_channel_id: number + p_org_id: string + p_permission_key: string + p_principal_id: string + p_principal_type: string + } + Returns: boolean + } + rbac_is_enabled_for_org: { Args: { p_org_id: string }; Returns: boolean } + rbac_legacy_right_for_org_role: { + Args: { p_role_name: string } + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_legacy_right_for_permission: { + Args: { p_permission_key: string } + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_legacy_role_hint: { + Args: { + p_app_id: string + p_channel_id: number + p_user_right: Database["public"]["Enums"]["user_min_right"] + } + Returns: string + } + rbac_migrate_org_users_to_bindings: { + Args: { p_granted_by?: string; p_org_id: string } + Returns: Json + } + rbac_perm_app_build_native: { Args: never; Returns: string } + rbac_perm_app_create_channel: { Args: never; Returns: string } + rbac_perm_app_delete: { Args: never; Returns: string } + rbac_perm_app_manage_devices: { Args: never; Returns: string } + rbac_perm_app_read: { Args: never; Returns: string } + rbac_perm_app_read_audit: { Args: never; Returns: string } + rbac_perm_app_read_bundles: { Args: never; Returns: string } + rbac_perm_app_read_channels: { Args: never; Returns: string } + rbac_perm_app_read_devices: { Args: never; Returns: string } + rbac_perm_app_read_logs: { Args: never; Returns: string } + rbac_perm_app_transfer: { Args: never; Returns: string } + rbac_perm_app_update_settings: { Args: never; Returns: string } + rbac_perm_app_update_user_roles: { Args: never; Returns: string } + rbac_perm_app_upload_bundle: { Args: never; Returns: string } + rbac_perm_bundle_delete: { Args: never; Returns: string } + rbac_perm_bundle_read: { Args: never; Returns: string } + rbac_perm_bundle_update: { Args: never; Returns: string } + rbac_perm_channel_delete: { Args: never; Returns: string } + rbac_perm_channel_manage_forced_devices: { Args: never; Returns: string } + rbac_perm_channel_promote_bundle: { Args: never; Returns: string } + rbac_perm_channel_read: { Args: never; Returns: string } + rbac_perm_channel_read_audit: { Args: never; Returns: string } + rbac_perm_channel_read_forced_devices: { Args: never; Returns: string } + rbac_perm_channel_read_history: { Args: never; Returns: string } + rbac_perm_channel_rollback_bundle: { Args: never; Returns: string } + rbac_perm_channel_update_settings: { Args: never; Returns: string } + rbac_perm_org_create_app: { Args: never; Returns: string } + rbac_perm_org_delete: { Args: never; Returns: string } + rbac_perm_org_invite_user: { Args: never; Returns: string } + rbac_perm_org_read: { Args: never; Returns: string } + rbac_perm_org_read_audit: { Args: never; Returns: string } + rbac_perm_org_read_billing: { Args: never; Returns: string } + rbac_perm_org_read_billing_audit: { Args: never; Returns: string } + rbac_perm_org_read_invoices: { Args: never; Returns: string } + rbac_perm_org_read_members: { Args: never; Returns: string } + rbac_perm_org_update_billing: { Args: never; Returns: string } + rbac_perm_org_update_settings: { Args: never; Returns: string } + rbac_perm_org_update_user_roles: { Args: never; Returns: string } + rbac_perm_platform_db_break_glass: { Args: never; Returns: string } + rbac_perm_platform_delete_orphan_users: { Args: never; Returns: string } + rbac_perm_platform_impersonate_user: { Args: never; Returns: string } + rbac_perm_platform_manage_apps_any: { Args: never; Returns: string } + rbac_perm_platform_manage_channels_any: { Args: never; Returns: string } + rbac_perm_platform_manage_orgs_any: { Args: never; Returns: string } + rbac_perm_platform_read_all_audit: { Args: never; Returns: string } + rbac_perm_platform_run_maintenance_jobs: { Args: never; Returns: string } + rbac_permission_for_legacy: { + Args: { + p_min_right: Database["public"]["Enums"]["user_min_right"] + p_scope: string + } + Returns: string + } + rbac_preview_migration: { + Args: { p_org_id: string } + Returns: { + app_id: string + channel_id: number + org_user_id: number + scope_type: string + skip_reason: string + suggested_role: string + user_id: string + user_right: string + will_migrate: boolean + }[] + } + rbac_principal_apikey: { Args: never; Returns: string } + rbac_principal_group: { Args: never; Returns: string } + rbac_principal_user: { Args: never; Returns: string } + rbac_right_admin: { + Args: never + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_right_invite_admin: { + Args: never + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_right_invite_super_admin: { + Args: never + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_right_invite_upload: { + Args: never + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_right_invite_write: { + Args: never + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_right_read: { + Args: never + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_right_super_admin: { + Args: never + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_right_upload: { + Args: never + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_right_write: { + Args: never + Returns: Database["public"]["Enums"]["user_min_right"] + } + rbac_role_app_admin: { Args: never; Returns: string } + rbac_role_app_developer: { Args: never; Returns: string } + rbac_role_app_reader: { Args: never; Returns: string } + rbac_role_app_uploader: { Args: never; Returns: string } + rbac_role_bundle_admin: { Args: never; Returns: string } + rbac_role_bundle_reader: { Args: never; Returns: string } + rbac_role_channel_admin: { Args: never; Returns: string } + rbac_role_channel_reader: { Args: never; Returns: string } + rbac_role_org_admin: { Args: never; Returns: string } + rbac_role_org_billing_admin: { Args: never; Returns: string } + rbac_role_org_member: { Args: never; Returns: string } + rbac_role_org_super_admin: { Args: never; Returns: string } + rbac_role_platform_super_admin: { Args: never; Returns: string } + rbac_rollback_org: { Args: { p_org_id: string }; Returns: Json } + rbac_scope_app: { Args: never; Returns: string } + rbac_scope_bundle: { Args: never; Returns: string } + rbac_scope_channel: { Args: never; Returns: string } + rbac_scope_org: { Args: never; Returns: string } + rbac_scope_platform: { Args: never; Returns: string } read_bandwidth_usage: { Args: { p_app_id: string; p_period_end: string; p_period_start: string } Returns: { @@ -2955,7 +4203,7 @@ export type Database = { get: number install: number uninstall: number - version_id: number + version_name: string }[] } record_build_time: { @@ -2968,6 +4216,57 @@ export type Database = { } Returns: string } + record_email_otp_verified: { + Args: { p_user_id: string } + Returns: string + } + refresh_orgs_has_usage_credits: { Args: never; Returns: undefined } + regenerate_hashed_apikey: { + Args: { p_apikey_id: number } + Returns: { + created_at: string | null + expires_at: string | null + id: number + key: string | null + key_hash: string | null + limited_to_apps: string[] | null + limited_to_orgs: string[] | null + mode: Database["public"]["Enums"]["key_mode"] | null + name: string + rbac_id: string + updated_at: string | null + user_id: string + } + SetofOptions: { + from: "*" + to: "apikeys" + isOneToOne: true + isSetofReturn: false + } + } + regenerate_hashed_apikey_for_user: { + Args: { p_apikey_id: number; p_user_id: string } + Returns: { + created_at: string | null + expires_at: string | null + id: number + key: string | null + key_hash: string | null + limited_to_apps: string[] | null + limited_to_orgs: string[] | null + mode: Database["public"]["Enums"]["key_mode"] | null + name: string + rbac_id: string + updated_at: string | null + user_id: string + } + SetofOptions: { + from: "*" + to: "apikeys" + isOneToOne: true + isSetofReturn: false + } + } reject_access_due_to_2fa: { Args: { org_id: string; user_id: string } Returns: boolean @@ -2985,10 +4284,42 @@ export type Database = { Returns: boolean } remove_old_jobs: { Args: never; Returns: undefined } + request_app_chart_refresh: { + Args: { app_id: string } + Returns: { + queued_app_ids: string[] + queued_count: number + requested_at: string + skipped_count: number + }[] + } + request_has_app_read_access: { + Args: { appid: string; orgid: string } + Returns: boolean + } + request_has_org_read_access: { Args: { orgid: string }; Returns: boolean } + request_org_chart_refresh: { + Args: { org_id: string } + Returns: { + queued_app_ids: string[] + queued_count: number + requested_at: string + skipped_count: number + }[] + } + request_read_key_modes: { + Args: never + Returns: Database["public"]["Enums"]["key_mode"][] + } rescind_invitation: { Args: { email: string; org_id: string } Returns: string } + restore_deleted_account: { Args: never; Returns: undefined } + resync_org_user_role_bindings: { + Args: { p_org_id: string; p_user_id: string } + Returns: undefined + } seed_get_app_metrics_caches: { Args: { p_end_date: string; p_org_id: string; p_start_date: string } Returns: { @@ -3006,22 +4337,34 @@ export type Database = { isSetofReturn: false } } - set_bandwidth_exceeded_by_org: { - Args: { disabled: boolean; org_id: string } - Returns: undefined + seed_org_metrics_cache: { + Args: { p_end_date: string; p_org_id: string; p_start_date: string } + Returns: { + bandwidth: number + build_time_unit: number + cached_at: string + end_date: string + fail: number + get: number + install: number + mau: number + org_id: string + start_date: string + storage: number + uninstall: number + } + SetofOptions: { + from: "*" + to: "org_metrics_cache" + isOneToOne: true + isSetofReturn: false + } } set_build_time_exceeded_by_org: { Args: { disabled: boolean; org_id: string } Returns: undefined } - set_mau_exceeded_by_org: { - Args: { disabled: boolean; org_id: string } - Returns: undefined - } - set_storage_exceeded_by_org: { - Args: { disabled: boolean; org_id: string } - Returns: undefined - } + strip_html: { Args: { input: string }; Returns: string } top_up_usage_credits: { Args: { p_amount: number @@ -3053,10 +4396,30 @@ export type Database = { Returns: Database["public"]["Enums"]["user_min_right"] } update_app_versions_retention: { Args: never; Returns: undefined } + update_org_invite_role_rbac: { + Args: { p_new_role_name: string; p_org_id: string; p_user_id: string } + Returns: string + } + update_org_member_role: { + Args: { p_new_role_name: string; p_org_id: string; p_user_id: string } + Returns: string + } + update_tmp_invite_role_rbac: { + Args: { p_email: string; p_new_role_name: string; p_org_id: string } + Returns: string + } upsert_version_meta: { Args: { p_app_id: string; p_size: number; p_version_id: number } Returns: boolean } + user_has_app_update_user_roles: { + Args: { p_app_id: string; p_user_id: string } + Returns: boolean + } + user_has_role_in_app: { + Args: { p_app_id: string; p_user_id: string } + Returns: boolean + } user_meets_password_policy: { Args: { org_id: string; user_id: string } Returns: boolean @@ -3080,7 +4443,7 @@ export type Database = { cron_task_type: "function" | "queue" | "function_queue" disable_update: "major" | "minor" | "patch" | "version_number" | "none" key_mode: "read" | "write" | "all" | "upload" - platform_os: "ios" | "android" + platform_os: "ios" | "android" | "electron" stats_action: | "delete" | "reset" @@ -3121,7 +4484,9 @@ export type Database = { | "disableAutoUpdateMetadata" | "disableAutoUpdateUnderNative" | "disableDevBuild" + | "disableProdBuild" | "disableEmulator" + | "disableDevice" | "cannotGetBundle" | "checksum_fail" | "NoChannelOrOverride" @@ -3142,8 +4507,8 @@ export type Database = { | "download_manifest_brotli_fail" | "backend_refusal" | "download_0" - | "disableProdBuild" - | "disableDevice" + | "disablePlatformElectron" + | "customIdBlocked" stripe_status: | "created" | "succeeded" @@ -3318,6 +4683,9 @@ export type CompositeTypes< : never export const Constants = { + graphql_public: { + Enums: {}, + }, public: { Enums: { action_type: ["mau", "storage", "bandwidth", "build_time"], @@ -3333,7 +4701,7 @@ export const Constants = { cron_task_type: ["function", "queue", "function_queue"], disable_update: ["major", "minor", "patch", "version_number", "none"], key_mode: ["read", "write", "all", "upload"], - platform_os: ["ios", "android"], + platform_os: ["ios", "android", "electron"], stats_action: [ "delete", "reset", @@ -3374,7 +4742,9 @@ export const Constants = { "disableAutoUpdateMetadata", "disableAutoUpdateUnderNative", "disableDevBuild", + "disableProdBuild", "disableEmulator", + "disableDevice", "cannotGetBundle", "checksum_fail", "NoChannelOrOverride", @@ -3395,8 +4765,8 @@ export const Constants = { "download_manifest_brotli_fail", "backend_refusal", "download_0", - "disableProdBuild", - "disableDevice", + "disablePlatformElectron", + "customIdBlocked", ], stripe_status: [ "created", @@ -3423,3 +4793,4 @@ export const Constants = { }, }, } as const + diff --git a/messages/en.json b/messages/en.json index 328f2ef85c..60532fce28 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1590,6 +1590,7 @@ "select-user-perms": "Select user's permissions", "select-user-perms-expanded": "Select which permission should the invited user have", "select-user-role": "Select a role", + "select-at-least-one-role": "Select at least one org role or app role", "select-role-for-each-app": "Select a role for each app", "select-user-role-expanded": "Choose the RBAC role to assign. Legacy roles remain visible during migration.", "select_all": "select all", diff --git a/src/components/dashboard/Usage.vue b/src/components/dashboard/Usage.vue index 3c7d1993ce..cceead8718 100644 --- a/src/components/dashboard/Usage.vue +++ b/src/components/dashboard/Usage.vue @@ -248,8 +248,8 @@ function syncLocalOrgRefreshState(state: { stats_refresh_requested_at: string | localOrgStatsRefreshRequestedAt.value = state.stats_refresh_requested_at ?? null if (effectiveOrganization.value) { - effectiveOrganization.value.stats_updated_at = state.stats_updated_at - effectiveOrganization.value.stats_refresh_requested_at = state.stats_refresh_requested_at + effectiveOrganization.value.stats_updated_at = state.stats_updated_at ?? effectiveOrganization.value.stats_updated_at + effectiveOrganization.value.stats_refresh_requested_at = state.stats_refresh_requested_at ?? effectiveOrganization.value.stats_refresh_requested_at } } diff --git a/src/pages/ApiKeys.vue b/src/pages/ApiKeys.vue index ace840a18b..68bef1b707 100644 --- a/src/pages/ApiKeys.vue +++ b/src/pages/ApiKeys.vue @@ -229,7 +229,7 @@ columns.value = [ label: t('type'), sortable: true, displayFunction: (row: Database['public']['Tables']['apikeys']['Row']) => { - return row.mode.toUpperCase() + return row.mode ? row.mode.toUpperCase() : 'RBAC' }, }, { diff --git a/src/pages/settings/organization/ApiKeys.[id].vue b/src/pages/settings/organization/ApiKeys.[id].vue index aed3d14955..ce21f1b623 100644 --- a/src/pages/settings/organization/ApiKeys.[id].vue +++ b/src/pages/settings/organization/ApiKeys.[id].vue @@ -21,7 +21,7 @@ interface OrgApiKey { id: number rbac_id: string name: string - mode: string + mode: string | null limited_to_orgs: string[] | null limited_to_apps: string[] | null user_id: string @@ -404,38 +404,6 @@ async function copyCreatedKey() { } } -async function showPartialFailureKeyModal(plainKey: string, isHashed: boolean) { - createdKeyDialogMode.value = isHashed ? 'partial-failure-hashed' : 'partial-failure-plain' - createdPlainKey.value = plainKey - dialogStore.openDialog({ - id: 'org-apikey-created', - title: t('api-key-create-partial-failure-title'), - size: 'lg', - preventAccidentalClose: true, - buttons: [ - { - text: t('ok'), - role: 'primary', - }, - ], - }) - - await dialogStore.onDialogDismiss() - createdPlainKey.value = '' - createdKeyDialogMode.value = 'success' -} - -async function rollbackCreatedApiKey(apikeyId: number | string | null) { - if (!apikeyId) - return null - - const { error } = await supabase.functions.invoke(`apikey/${apikeyId}`, { - method: 'DELETE', - }) - - return error ?? null -} - function validateApiKeyForm() { if (!editName.value.trim()) { toast.error(t('please-enter-api-key-name')) @@ -447,6 +415,16 @@ function validateApiKeyForm() { return false } + // In create mode, at least one binding (org role or app binding) is required + if (isCreateMode.value) { + const hasOrgRole = !!selectedOrgRole.value + const hasAppBindings = configuredAppIds.value.length > 0 + if (!hasOrgRole && !hasAppBindings) { + toast.error(t('select-at-least-one-role')) + return false + } + } + return true } @@ -521,15 +499,42 @@ async function createAppRoleBinding(principalId: string, orgId: string, appId: s } async function createApiKeyRecord(orgId: string) { + // Build bindings array for the atomic API call + const bindings: Array<{ + role_name: string + scope_type: 'org' | 'app' + org_id: string + app_id?: string + }> = [] + + if (selectedOrgRole.value) { + bindings.push({ + role_name: selectedOrgRole.value, + scope_type: 'org', + org_id: orgId, + }) + } + + for (const [appId, roleName] of Object.entries(pendingAppBindings.value)) { + if (!roleName) + continue + bindings.push({ + role_name: roleName, + scope_type: 'app', + org_id: orgId, + app_id: appId, + }) + } + const { data, error } = await supabase.functions.invoke('apikey', { method: 'POST', body: { - mode: 'all', name: editName.value.trim(), limited_to_orgs: [orgId], limited_to_apps: configuredLimitedAppIds.value, expires_at: getApiKeyExpirationValue(), hashed: createAsHashed.value, + bindings, }, }) @@ -543,30 +548,6 @@ async function createApiKeyRecord(orgId: string) { return createdApiKey } -async function assignBindingsForNewApiKey(orgId: string, principalId: string) { - if (selectedOrgRole.value) - await createOrgRoleBinding(principalId, orgId, selectedOrgRole.value) - - for (const [appId, roleName] of Object.entries(pendingAppBindings.value)) { - if (!roleName) - continue - await createAppRoleBinding(principalId, orgId, appId, roleName) - } -} - -async function rollbackCreatedApiKeyAfterBindingFailure( - bindingError: unknown, - createdApiKey: CreatedApiKeyResult, -) { - const rollbackError = await rollbackCreatedApiKey(createdApiKey.id) - if (rollbackError) { - console.error('Failed to rollback API key after binding error:', rollbackError) - if (createdApiKey.key) - await showPartialFailureKeyModal(createdApiKey.key, createAsHashed.value) - } - throw bindingError -} - async function finalizeCreatedApiKey(createdPlainKey: string | null) { if (createdPlainKey) await showOneTimeKeyModal(createdPlainKey) @@ -641,15 +622,8 @@ async function createKey() { isSubmitting.value = true try { + // Single atomic call: creates key + bindings in one request const createdApiKey = await createApiKeyRecord(orgId) - - try { - await assignBindingsForNewApiKey(orgId, createdApiKey.rbacId) - } - catch (bindingError) { - await rollbackCreatedApiKeyAfterBindingFailure(bindingError, createdApiKey) - } - await finalizeCreatedApiKey(createdApiKey.key) } catch (err) { diff --git a/src/services/apikeys.ts b/src/services/apikeys.ts index bca653aec4..db4680fa18 100644 --- a/src/services/apikeys.ts +++ b/src/services/apikeys.ts @@ -18,7 +18,7 @@ export async function createDefaultApiKey( interface ApiKeyListRow { name?: string | null - mode: string + mode: string | null created_at: string | null } @@ -80,8 +80,8 @@ export function sortApiKeyRows( bValue = b.name?.toLowerCase() || '' break case 'mode': - aValue = a.mode.toLowerCase() - bValue = b.mode.toLowerCase() + aValue = (a.mode ?? '').toLowerCase() + bValue = (b.mode ?? '').toLowerCase() break case 'created_at': aValue = a.created_at ? new Date(a.created_at).getTime() : 0 diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 656944ed09..f42d66ba78 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -7,10 +7,30 @@ export type Json = | Json[] export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "14.1" + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } } public: { Tables: { @@ -23,7 +43,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -37,7 +57,7 @@ export type Database = { key_hash?: string | null limited_to_apps?: string[] | null limited_to_orgs?: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode?: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id?: string updated_at?: string | null @@ -51,7 +71,7 @@ export type Database = { key_hash?: string | null limited_to_apps?: string[] | null limited_to_orgs?: string[] | null - mode?: Database["public"]["Enums"]["key_mode"] + mode?: Database["public"]["Enums"]["key_mode"] | null name?: string rbac_id?: string updated_at?: string | null @@ -452,15 +472,7 @@ export type Database = { platform?: string user_id?: string | null } - Relationships: [ - { - foreignKeyName: "build_logs_org_id_fkey" - columns: ["org_id"] - isOneToOne: false - referencedRelation: "orgs" - referencedColumns: ["id"] - }, - ] + Relationships: [] } build_requests: { Row: { @@ -1218,7 +1230,7 @@ export type Database = { paying: number | null paying_monthly: number | null paying_yearly: number | null - plan_enterprise: number | null + plan_enterprise: number plan_enterprise_monthly: number plan_enterprise_yearly: number plan_maker: number | null @@ -1287,7 +1299,7 @@ export type Database = { paying?: number | null paying_monthly?: number | null paying_yearly?: number | null - plan_enterprise?: number | null + plan_enterprise?: number plan_enterprise_monthly?: number plan_enterprise_yearly?: number plan_maker?: number | null @@ -1356,7 +1368,7 @@ export type Database = { paying?: number | null paying_monthly?: number | null paying_yearly?: number | null - plan_enterprise?: number | null + plan_enterprise?: number plan_enterprise_monthly?: number plan_enterprise_yearly?: number plan_maker?: number | null @@ -3027,11 +3039,11 @@ export type Database = { } create_hashed_apikey: { Args: { - p_expires_at: string - p_limited_to_apps: string[] - p_limited_to_orgs: string[] - p_mode: Database["public"]["Enums"]["key_mode"] - p_name: string + p_expires_at?: string + p_limited_to_apps?: string[] + p_limited_to_orgs?: string[] + p_mode?: Database["public"]["Enums"]["key_mode"] + p_name?: string } Returns: { created_at: string | null @@ -3041,7 +3053,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -3056,11 +3068,11 @@ export type Database = { } create_hashed_apikey_for_user: { Args: { - p_expires_at: string - p_limited_to_apps: string[] - p_limited_to_orgs: string[] - p_mode: Database["public"]["Enums"]["key_mode"] - p_name: string + p_expires_at?: string + p_limited_to_apps?: string[] + p_limited_to_orgs?: string[] + p_mode?: Database["public"]["Enums"]["key_mode"] + p_name?: string p_user_id: string } Returns: { @@ -3071,7 +3083,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -3126,7 +3138,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -3477,18 +3489,24 @@ export type Database = { app_count: number can_use_more: boolean created_by: string + credit_available: number + credit_next_expiration: string + credit_total: number gid: string is_canceled: boolean is_yearly: boolean logo: string management_email: string + max_apikey_expiration_days: number name: string + next_stats_update_at: string paying: boolean + require_apikey_expiration: boolean role: string + stats_updated_at: string subscription_end: string subscription_start: string trial_left: number - use_new_rbac: boolean }[] } | { @@ -3497,18 +3515,24 @@ export type Database = { app_count: number can_use_more: boolean created_by: string + credit_available: number + credit_next_expiration: string + credit_total: number gid: string is_canceled: boolean is_yearly: boolean logo: string management_email: string + max_apikey_expiration_days: number name: string + next_stats_update_at: string paying: boolean + require_apikey_expiration: boolean role: string + stats_updated_at: string subscription_end: string subscription_start: string trial_left: number - use_new_rbac: boolean }[] } get_orgs_v7: @@ -3868,7 +3892,7 @@ export type Database = { | { Args: { userid: string }; Returns: boolean } is_rbac_enabled_globally: { Args: never; Returns: boolean } is_recent_email_otp_verified: { - Args: { user_id: string } + Args: { p_user_id: string } Returns: boolean } is_storage_exceeded_by_org: { Args: { org_id: string }; Returns: boolean } @@ -4207,7 +4231,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -4230,7 +4254,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -4460,7 +4484,9 @@ export type Database = { | "disableAutoUpdateMetadata" | "disableAutoUpdateUnderNative" | "disableDevBuild" + | "disableProdBuild" | "disableEmulator" + | "disableDevice" | "cannotGetBundle" | "checksum_fail" | "NoChannelOrOverride" @@ -4481,8 +4507,6 @@ export type Database = { | "download_manifest_brotli_fail" | "backend_refusal" | "download_0" - | "disableProdBuild" - | "disableDevice" | "disablePlatformElectron" | "customIdBlocked" stripe_status: @@ -4659,6 +4683,9 @@ export type CompositeTypes< : never export const Constants = { + graphql_public: { + Enums: {}, + }, public: { Enums: { action_type: ["mau", "storage", "bandwidth", "build_time"], @@ -4715,7 +4742,9 @@ export const Constants = { "disableAutoUpdateMetadata", "disableAutoUpdateUnderNative", "disableDevBuild", + "disableProdBuild", "disableEmulator", + "disableDevice", "cannotGetBundle", "checksum_fail", "NoChannelOrOverride", @@ -4736,8 +4765,6 @@ export const Constants = { "download_manifest_brotli_fail", "backend_refusal", "download_0", - "disableProdBuild", - "disableDevice", "disablePlatformElectron", "customIdBlocked", ], @@ -4766,3 +4793,4 @@ export const Constants = { }, }, } as const + diff --git a/supabase/functions/_backend/private/role_bindings.ts b/supabase/functions/_backend/private/role_bindings.ts index 9af4cfc974..0f51fdb58a 100644 --- a/supabase/functions/_backend/private/role_bindings.ts +++ b/supabase/functions/_backend/private/role_bindings.ts @@ -1,8 +1,10 @@ import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import type { Database } from '../utils/supabase.types.ts' import { sValidator } from '@hono/standard-validator' import { and, eq, sql } from 'drizzle-orm' -import { createHono, middlewareAuth, useCors } from '../utils/hono.ts' +import { createHono, useCors } from '../utils/hono.ts' +import { middlewareV2 } from '../utils/hono_middleware.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' import { schema } from '../utils/postgres_schema.ts' @@ -42,13 +44,23 @@ const INVALID_APIKEY_ACCESS_ERROR = 'Invalid API key or access' export const app = createHono('', version) app.use('*', useCors) -app.use('*', middlewareAuth) +app.use('*', middlewareV2(['all'])) -async function requireUserAuth(c: Context, next: () => Promise) { - if (!c.get('auth')?.userId) { +async function requireAuthAndGuardLimitedKeys(c: Context, next: () => Promise) { + const auth = c.get('auth') + if (!auth?.userId) { return c.json({ error: 'Unauthorized' }, 401) } + // Prevent limited-scope API keys from managing role bindings + if (auth.authType === 'apikey') { + const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] | undefined + const hasLimitedScope = (apikey?.limited_to_orgs?.length ?? 0) > 0 || (apikey?.limited_to_apps?.length ?? 0) > 0 + if (hasLimitedScope) { + return c.json({ error: 'Limited-scope API keys cannot manage role bindings' }, 403) + } + } + await next() } @@ -268,21 +280,46 @@ async function validateApiKeyPrincipalAccess( return { ok: false, status: 400, error: INVALID_APIKEY_ACCESS_ERROR } } - const [membership] = await drizzle + // Mirror the user-principal checks: only accept active (non-invite) memberships + const [activeMembership] = await drizzle .select({ id: schema.org_users.id }) .from(schema.org_users) .where( and( eq(schema.org_users.user_id, apiKey.user_id), eq(schema.org_users.org_id, orgId), + sql`(${schema.org_users.user_right} IS NULL OR ${schema.org_users.user_right}::text NOT LIKE 'invite_%')`, ), ) .limit(1) - if (membership) { + if (activeMembership) { return { ok: true, data: null } } + // Check if the owner has a pending invite (same as user-principal validation) + const [pendingInvite] = await drizzle + .select({ id: schema.org_users.id }) + .from(schema.org_users) + .where( + and( + eq(schema.org_users.user_id, apiKey.user_id), + eq(schema.org_users.org_id, orgId), + sql`${schema.org_users.user_right}::text LIKE 'invite_%'`, + ), + ) + .limit(1) + + if (pendingInvite) { + cloudlogErr({ + message: 'validatePrincipalAccess: apiKey owner has pending invite, not active member', + principalId, + orgId, + apiKeyUserId: apiKey.user_id, + }) + return { ok: false, status: 400, error: INVALID_APIKEY_ACCESS_ERROR } + } + cloudlogErr({ message: 'validatePrincipalAccess: apiKey owner legacy membership not found', principalId, @@ -358,6 +395,15 @@ async function loadManagedBinding( return { ok: true, data: binding } } +// Maps legacy org_users.user_right values to their equivalent RBAC role names. +// Only admin-level rights are mapped because lower rights (write/upload/read) +// cannot pass the checkPermission('org.update_user_roles') gate that precedes +// every anti-escalation check. +const LEGACY_RIGHT_TO_ROLE_NAME: Record = { + super_admin: 'org_super_admin', + admin: 'org_admin', +} + async function getCallerMaxPriorityRank( drizzle: ReturnType, authType: 'apikey' | 'jwt', @@ -381,11 +427,151 @@ async function getCallerMaxPriorityRank( ) .limit(1) - return result[0]?.max_rank ?? 0 + let maxRank = result[0]?.max_rank ?? 0 + + // For JWT callers, also consider legacy org_users.user_right so that admins + // who passed checkPermission via check_min_rights (legacy path) are not + // blocked by the anti-escalation check when they have no RBAC bindings yet. + if (authType === 'jwt') { + const [membership] = await drizzle + .select({ user_right: schema.org_users.user_right }) + .from(schema.org_users) + .where( + and( + eq(schema.org_users.user_id, principalId), + eq(schema.org_users.org_id, orgId), + sql`${schema.org_users.user_right}::text NOT LIKE 'invite_%'`, + ), + ) + .limit(1) + + const mappedRoleName = membership?.user_right + ? LEGACY_RIGHT_TO_ROLE_NAME[membership.user_right] + : undefined + + if (mappedRoleName) { + const [role] = await drizzle + .select({ priority_rank: schema.roles.priority_rank }) + .from(schema.roles) + .where(eq(schema.roles.name, mappedRoleName)) + .limit(1) + + if (role && role.priority_rank > maxRank) { + maxRank = role.priority_rank + } + } + } + + return maxRank +} + +// Reusable binding creation logic - used by both the POST route and apikey/post.ts +export interface CreateBindingParams { + principal_type: PrincipalType + principal_id: string + role_name: string + scope_type: ScopeType + org_id: string + app_id?: string | null + channel_id?: string | number | null + reason?: string +} + +export type CreateBindingResult = { + ok: true + data: typeof schema.role_bindings.$inferSelect +} | { + ok: false + status: number + error: string +} + +export async function createRoleBindingForPrincipal( + drizzle: ReturnType, + params: CreateBindingParams, + grantedBy: string, + authType: 'jwt' | 'apikey', + callerPrincipalId: string, +): Promise { + const { + principal_type, + principal_id, + role_name, + scope_type, + org_id, + app_id, + channel_id, + reason, + } = params + + // 1. Resolve role by name + const [role] = await drizzle + .select() + .from(schema.roles) + .where(eq(schema.roles.name, role_name)) + .limit(1) + + if (!role) { + return { ok: false, status: 404, error: 'Role not found' } + } + + if (!role.is_assignable) { + return { ok: false, status: 403, error: 'Role is not assignable' } + } + + // 2. Role scope must match binding scope + const roleScopeValidation = validateRoleScope(role.scope_type, scope_type) + if (!roleScopeValidation.ok) { + return { ok: false, status: roleScopeValidation.status, error: roleScopeValidation.error } + } + + // 3. Anti-escalation: caller's max priority rank must be >= role.priority_rank + const callerMaxRank = await getCallerMaxPriorityRank(drizzle, authType, callerPrincipalId, org_id) + if (role.priority_rank > callerMaxRank) { + return { ok: false, status: 403, error: 'Cannot assign a role with higher privileges than your own' } + } + + // 4. Scope field validation (app_id / channel_id required when scope demands it) + const scopeValidation = validateScope(scope_type, app_id, channel_id) + if (!scopeValidation.ok) { + return { ok: false, status: scopeValidation.status, error: scopeValidation.error } + } + + // 5. App/channel ownership check; also normalises channel_id -> rbac_id + const scopedAppValidation = await validateScopedAppOwnership(drizzle, scope_type, org_id, app_id, channel_id) + if (!scopedAppValidation.ok) { + return { ok: false, status: scopedAppValidation.status, error: scopedAppValidation.error } + } + const normalizedChannelId = scopedAppValidation.data.channelRbacId + + // 6. Principal existence & org-membership check + const principalValidation = await validatePrincipalAccess(drizzle, principal_type, principal_id, org_id) + if (!principalValidation.ok) { + return { ok: false, status: principalValidation.status, error: principalValidation.error } + } + + // 7. Create the binding + const [binding] = await drizzle + .insert(schema.role_bindings) + .values({ + principal_type, + principal_id, + role_id: role.id, + scope_type, + org_id, + app_id: app_id || null, + channel_id: normalizedChannelId, + granted_by: grantedBy, + reason: reason || null, + is_direct: true, + }) + .returning() + + return { ok: true, data: binding } } // GET /private/role_bindings/:org_id - List role bindings for an org -app.get('/:org_id', requireUserAuth, sValidator('param', orgIdParamSchema, invalidOrgIdHook), async (c) => { +app.get('/:org_id', requireAuthAndGuardLimitedKeys, sValidator('param', orgIdParamSchema, invalidOrgIdHook), async (c) => { const { org_id: orgId } = c.req.valid('param') let pgClient @@ -447,7 +633,7 @@ app.get('/:org_id', requireUserAuth, sValidator('param', orgIdParamSchema, inval }) // POST /private/role_bindings - Assign a role -app.post('/', requireUserAuth, async (c) => { +app.post('/', requireAuthAndGuardLimitedKeys, async (c) => { const auth = c.get('auth')! const userId = auth.userId @@ -591,7 +777,7 @@ app.post('/', requireUserAuth, async (c) => { // PATCH /private/role_bindings/:binding_id - Update a role binding app.patch( '/:binding_id', - requireUserAuth, + requireAuthAndGuardLimitedKeys, sValidator('param', bindingIdParamSchema, invalidBindingIdHook), async (c) => { const { binding_id: bindingId } = c.req.valid('param') @@ -686,7 +872,7 @@ app.patch( ) // DELETE /private/role_bindings/:binding_id - Remove a role -app.delete('/:binding_id', requireUserAuth, sValidator('param', bindingIdParamSchema, invalidBindingIdHook), async (c) => { +app.delete('/:binding_id', requireAuthAndGuardLimitedKeys, sValidator('param', bindingIdParamSchema, invalidBindingIdHook), async (c) => { const { binding_id: bindingId } = c.req.valid('param') let pgClient diff --git a/supabase/functions/_backend/public/apikey/post.ts b/supabase/functions/_backend/public/apikey/post.ts index 2da3fab170..337bc0b69c 100644 --- a/supabase/functions/_backend/public/apikey/post.ts +++ b/supabase/functions/_backend/public/apikey/post.ts @@ -1,10 +1,24 @@ +import type { CreateBindingParams } from '../../private/role_bindings.ts' import type { AuthInfo } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' +import { createRoleBindingForPrincipal } from '../../private/role_bindings.ts' import { honoFactory, parseBody, quickError, simpleError } from '../../utils/hono.ts' import { middlewareV2 } from '../../utils/hono_middleware.ts' +import { cloudlog, cloudlogErr } from '../../utils/logging.ts' +import { closeClient, getDrizzleClient, getPgClient } from '../../utils/pg.ts' +import { checkPermission } from '../../utils/rbac.ts' import { resolveApikeyPolicyOrgIds, supabaseAdmin, supabaseWithAuth, validateExpirationAgainstOrgPolicies, validateExpirationDate } from '../../utils/supabase.ts' import { Constants } from '../../utils/supabase.types.ts' +interface BindingInput { + role_name: string + scope_type: 'org' | 'app' | 'channel' + org_id: string + app_id?: string | null + channel_id?: string | number | null + reason?: string +} + const app = honoFactory.createApp() app.post('/', middlewareV2(['all']), async (c) => { @@ -39,16 +53,41 @@ app.post('/', middlewareV2(['all']), async (c) => { const expiresAt = body.expires_at ?? null const isHashed = body.hashed === true - const mode = body.mode ?? 'all' + // Validate and parse bindings array + const bindings: BindingInput[] = Array.isArray(body.bindings) ? body.bindings : [] + if (body.bindings !== undefined && !Array.isArray(body.bindings)) { + throw simpleError('invalid_bindings', 'bindings must be an array') + } + for (const binding of bindings) { + if (!binding || typeof binding !== 'object') { + throw simpleError('invalid_bindings', 'Each binding must be an object') + } + if (typeof binding.role_name !== 'string' || !binding.role_name) { + throw simpleError('invalid_bindings', 'Each binding must have a role_name') + } + if (!['org', 'app', 'channel'].includes(binding.scope_type)) { + throw simpleError('invalid_bindings', 'Each binding must have a valid scope_type (org, app, channel)') + } + if (typeof binding.org_id !== 'string' || !binding.org_id) { + throw simpleError('invalid_bindings', 'Each binding must have an org_id') + } + } + + const hasBindings = bindings.length > 0 + + // mode is required when no bindings are provided, optional (null) otherwise + const mode = body.mode ?? null if (!name) { throw simpleError('name_is_required', 'Name is required') } - if (!mode) { - throw simpleError('mode_is_required', 'Mode is required') + if (!hasBindings && !mode) { + throw simpleError('mode_is_required', 'Mode is required when no bindings are provided') } - const validModes = Constants.public.Enums.key_mode - if (!validModes.includes(mode)) { - throw simpleError('invalid_mode', 'Invalid mode') + if (mode !== null) { + const validModes = Constants.public.Enums.key_mode + if (!validModes.includes(mode)) { + throw simpleError('invalid_mode', 'Invalid mode') + } } // Validate expiration date format (throws if invalid) @@ -117,6 +156,99 @@ app.post('/', middlewareV2(['all']), async (c) => { throw simpleError('failed_to_create_apikey', 'Failed to create API key', { supabaseError: apikeyError }) } + // If bindings are provided, create them using the new key's rbac_id + if (hasBindings && apikeyData.rbac_id) { + let pgClient: ReturnType | undefined + try { + pgClient = getPgClient(c) + const drizzle = getDrizzleClient(pgClient) + + // Check RBAC permission for each unique org in the bindings + const bindingOrgIds = [...new Set(bindings.map(b => b.org_id))] + for (const bindingOrgId of bindingOrgIds) { + if (!(await checkPermission(c, 'org.update_user_roles', { orgId: bindingOrgId }))) { + // Rollback the created key + const { error: rollbackError } = await supabase.from('apikeys').delete().eq('id', apikeyData.id) + if (rollbackError) + cloudlogErr({ requestId: c.get('requestId'), message: 'apikey_rollback_failed', rollbackError }) + throw quickError(403, 'forbidden_binding', `Forbidden - Admin rights required for org ${bindingOrgId}`) + } + } + + // Guard: an API key caller cannot create bindings for keys it doesn't own + // Note: since we just created the key with auth.userId, this is inherently safe. + // This guard is a defense-in-depth measure for future code changes. + + const callerPrincipalId = auth.authType === 'apikey' ? auth.apikey!.rbac_id : auth.userId + const createdBindings = [] + + for (const binding of bindings) { + const bindingParams: CreateBindingParams = { + principal_type: 'apikey', + principal_id: apikeyData.rbac_id, + role_name: binding.role_name, + scope_type: binding.scope_type, + org_id: binding.org_id, + app_id: binding.app_id, + channel_id: binding.channel_id, + reason: binding.reason, + } + + const result = await createRoleBindingForPrincipal( + drizzle, + bindingParams, + auth.userId, + auth.authType as 'jwt' | 'apikey', + callerPrincipalId, + ) + + if (!result.ok) { + // Rollback: delete the created key and any bindings created so far + cloudlogErr({ + requestId: c.get('requestId'), + message: 'apikey_binding_failed', + binding, + error: result.error, + }) + const { error: rollbackError } = await supabase.from('apikeys').delete().eq('id', apikeyData.id) + if (rollbackError) + cloudlogErr({ requestId: c.get('requestId'), message: 'apikey_rollback_failed', rollbackError }) + throw quickError(result.status as any, 'binding_failed', result.error) + } + + createdBindings.push(result.data) + } + + cloudlog({ + requestId: c.get('requestId'), + message: 'apikey_bindings_created', + apikeyId: apikeyData.id, + bindingsCount: createdBindings.length, + }) + } + catch (error: any) { + // Re-throw our own quickError/simpleError (HTTP errors thrown above) + if (error?.status) { + throw error + } + // Unexpected error: rollback the key + cloudlogErr({ + requestId: c.get('requestId'), + message: 'apikey_bindings_unexpected_error', + error, + }) + const { error: rollbackError } = await supabase.from('apikeys').delete().eq('id', apikeyData.id) + if (rollbackError) + cloudlogErr({ requestId: c.get('requestId'), message: 'apikey_rollback_failed', rollbackError }) + throw simpleError('binding_creation_failed', 'Failed to create role bindings for the API key') + } + finally { + if (pgClient) { + await closeClient(c, pgClient) + } + } + } + return c.json(apikeyData) }) diff --git a/supabase/functions/_backend/utils/hono_middleware.ts b/supabase/functions/_backend/utils/hono_middleware.ts index c0b8f16333..e601882054 100644 --- a/supabase/functions/_backend/utils/hono_middleware.ts +++ b/supabase/functions/_backend/utils/hono_middleware.ts @@ -170,7 +170,7 @@ type FindApikeyByValueResult = { user_id: string key: string | null key_hash: string | null - mode: Database['public']['Enums']['key_mode'] + mode: Database['public']['Enums']['key_mode'] | null updated_at: string | null name: string limited_to_orgs: string[] | null @@ -200,8 +200,8 @@ async function checkKeyPg( return null } - // Check if mode is allowed - if (!rights.includes(apiKey.mode)) { + // Check if mode is allowed (NULL mode = RBAC-managed, always passes mode check) + if (apiKey.mode !== null && !rights.includes(apiKey.mode)) { cloudlog({ requestId: _c.get('requestId'), message: 'Invalid apikey mode (pg)', keyStringPrefix: keyString?.substring(0, 8), rights, mode: apiKey.mode }) return null } @@ -246,7 +246,7 @@ async function checkKeyByIdPg( try { const conditions = [ eq(schema.apikeys.id, id), - inArray(schema.apikeys.mode, rights), + or(isNull(schema.apikeys.mode), inArray(schema.apikeys.mode, rights)), notExpiredCondition, ] if (expectedUserId) { diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index c229f4a1a7..86b3faa76a 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -1468,8 +1468,8 @@ export async function checkKey(c: Context, authorization: string | undefined, su return null } - // Check if mode is allowed - if (!allowed.includes(data.mode)) { + // Check if mode is allowed (NULL mode = RBAC-managed, always passes mode check) + if (data.mode !== null && !allowed.includes(data.mode)) { cloudlog({ requestId: c.get('requestId'), message: 'Invalid apikey mode', authorizationPrefix: authorization?.substring(0, 8), allowed, mode: data.mode }) return null } @@ -1507,7 +1507,7 @@ export async function checkKeyById( .from('apikeys') .select('*') .eq('id', id) - .in('mode', allowed) + .or(`mode.is.null,mode.in.(${allowed.join(',')})`) .or('expires_at.is.null,expires_at.gt.now()') if (userId) { query = query.eq('user_id', userId) diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 656944ed09..f42d66ba78 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -7,10 +7,30 @@ export type Json = | Json[] export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "14.1" + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } } public: { Tables: { @@ -23,7 +43,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -37,7 +57,7 @@ export type Database = { key_hash?: string | null limited_to_apps?: string[] | null limited_to_orgs?: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode?: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id?: string updated_at?: string | null @@ -51,7 +71,7 @@ export type Database = { key_hash?: string | null limited_to_apps?: string[] | null limited_to_orgs?: string[] | null - mode?: Database["public"]["Enums"]["key_mode"] + mode?: Database["public"]["Enums"]["key_mode"] | null name?: string rbac_id?: string updated_at?: string | null @@ -452,15 +472,7 @@ export type Database = { platform?: string user_id?: string | null } - Relationships: [ - { - foreignKeyName: "build_logs_org_id_fkey" - columns: ["org_id"] - isOneToOne: false - referencedRelation: "orgs" - referencedColumns: ["id"] - }, - ] + Relationships: [] } build_requests: { Row: { @@ -1218,7 +1230,7 @@ export type Database = { paying: number | null paying_monthly: number | null paying_yearly: number | null - plan_enterprise: number | null + plan_enterprise: number plan_enterprise_monthly: number plan_enterprise_yearly: number plan_maker: number | null @@ -1287,7 +1299,7 @@ export type Database = { paying?: number | null paying_monthly?: number | null paying_yearly?: number | null - plan_enterprise?: number | null + plan_enterprise?: number plan_enterprise_monthly?: number plan_enterprise_yearly?: number plan_maker?: number | null @@ -1356,7 +1368,7 @@ export type Database = { paying?: number | null paying_monthly?: number | null paying_yearly?: number | null - plan_enterprise?: number | null + plan_enterprise?: number plan_enterprise_monthly?: number plan_enterprise_yearly?: number plan_maker?: number | null @@ -3027,11 +3039,11 @@ export type Database = { } create_hashed_apikey: { Args: { - p_expires_at: string - p_limited_to_apps: string[] - p_limited_to_orgs: string[] - p_mode: Database["public"]["Enums"]["key_mode"] - p_name: string + p_expires_at?: string + p_limited_to_apps?: string[] + p_limited_to_orgs?: string[] + p_mode?: Database["public"]["Enums"]["key_mode"] + p_name?: string } Returns: { created_at: string | null @@ -3041,7 +3053,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -3056,11 +3068,11 @@ export type Database = { } create_hashed_apikey_for_user: { Args: { - p_expires_at: string - p_limited_to_apps: string[] - p_limited_to_orgs: string[] - p_mode: Database["public"]["Enums"]["key_mode"] - p_name: string + p_expires_at?: string + p_limited_to_apps?: string[] + p_limited_to_orgs?: string[] + p_mode?: Database["public"]["Enums"]["key_mode"] + p_name?: string p_user_id: string } Returns: { @@ -3071,7 +3083,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -3126,7 +3138,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -3477,18 +3489,24 @@ export type Database = { app_count: number can_use_more: boolean created_by: string + credit_available: number + credit_next_expiration: string + credit_total: number gid: string is_canceled: boolean is_yearly: boolean logo: string management_email: string + max_apikey_expiration_days: number name: string + next_stats_update_at: string paying: boolean + require_apikey_expiration: boolean role: string + stats_updated_at: string subscription_end: string subscription_start: string trial_left: number - use_new_rbac: boolean }[] } | { @@ -3497,18 +3515,24 @@ export type Database = { app_count: number can_use_more: boolean created_by: string + credit_available: number + credit_next_expiration: string + credit_total: number gid: string is_canceled: boolean is_yearly: boolean logo: string management_email: string + max_apikey_expiration_days: number name: string + next_stats_update_at: string paying: boolean + require_apikey_expiration: boolean role: string + stats_updated_at: string subscription_end: string subscription_start: string trial_left: number - use_new_rbac: boolean }[] } get_orgs_v7: @@ -3868,7 +3892,7 @@ export type Database = { | { Args: { userid: string }; Returns: boolean } is_rbac_enabled_globally: { Args: never; Returns: boolean } is_recent_email_otp_verified: { - Args: { user_id: string } + Args: { p_user_id: string } Returns: boolean } is_storage_exceeded_by_org: { Args: { org_id: string }; Returns: boolean } @@ -4207,7 +4231,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -4230,7 +4254,7 @@ export type Database = { key_hash: string | null limited_to_apps: string[] | null limited_to_orgs: string[] | null - mode: Database["public"]["Enums"]["key_mode"] + mode: Database["public"]["Enums"]["key_mode"] | null name: string rbac_id: string updated_at: string | null @@ -4460,7 +4484,9 @@ export type Database = { | "disableAutoUpdateMetadata" | "disableAutoUpdateUnderNative" | "disableDevBuild" + | "disableProdBuild" | "disableEmulator" + | "disableDevice" | "cannotGetBundle" | "checksum_fail" | "NoChannelOrOverride" @@ -4481,8 +4507,6 @@ export type Database = { | "download_manifest_brotli_fail" | "backend_refusal" | "download_0" - | "disableProdBuild" - | "disableDevice" | "disablePlatformElectron" | "customIdBlocked" stripe_status: @@ -4659,6 +4683,9 @@ export type CompositeTypes< : never export const Constants = { + graphql_public: { + Enums: {}, + }, public: { Enums: { action_type: ["mau", "storage", "bandwidth", "build_time"], @@ -4715,7 +4742,9 @@ export const Constants = { "disableAutoUpdateMetadata", "disableAutoUpdateUnderNative", "disableDevBuild", + "disableProdBuild", "disableEmulator", + "disableDevice", "cannotGetBundle", "checksum_fail", "NoChannelOrOverride", @@ -4736,8 +4765,6 @@ export const Constants = { "download_manifest_brotli_fail", "backend_refusal", "download_0", - "disableProdBuild", - "disableDevice", "disablePlatformElectron", "customIdBlocked", ], @@ -4766,3 +4793,4 @@ export const Constants = { }, }, } as const + diff --git a/supabase/migrations/20260502153300_apikey_nullable_mode_with_bindings.sql b/supabase/migrations/20260502153300_apikey_nullable_mode_with_bindings.sql new file mode 100644 index 0000000000..f7500aa128 --- /dev/null +++ b/supabase/migrations/20260502153300_apikey_nullable_mode_with_bindings.sql @@ -0,0 +1,93 @@ +-- Make apikeys.mode nullable for RBAC v2 API keys that use role_bindings +-- instead of the legacy mode-based permission system. +-- When mode IS NULL, the key's permissions are determined solely by its role_bindings. + +ALTER TABLE "public"."apikeys" + ALTER COLUMN "mode" DROP NOT NULL; + +COMMENT ON COLUMN "public"."apikeys"."mode" IS + 'Legacy permission mode. NULL means permissions are managed via RBAC role_bindings.'; + +-- Use CREATE OR REPLACE (not DROP + CREATE) to preserve existing grants. +-- The original migration (20260206120000) set SECURITY INVOKER and relied on the +-- default PUBLIC execute grant for create_hashed_apikey_for_user. Dropping and +-- recreating would reset that grant and break the call chain from +-- create_hashed_apikey (authenticated) → create_hashed_apikey_for_user. + +CREATE OR REPLACE FUNCTION "public"."create_hashed_apikey"( + "p_mode" "public"."key_mode" DEFAULT NULL, + "p_name" "text" DEFAULT '', + "p_limited_to_orgs" "uuid"[] DEFAULT '{}'::uuid[], + "p_limited_to_apps" "text"[] DEFAULT '{}'::text[], + "p_expires_at" timestamp with time zone DEFAULT NULL +) RETURNS "public"."apikeys" + LANGUAGE "plpgsql" + SECURITY INVOKER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; +BEGIN + SELECT public.get_identity('{write,all}'::public.key_mode[]) INTO v_user_id; + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'No authentication provided'; + END IF; + + RETURN public.create_hashed_apikey_for_user( + v_user_id, + p_mode, + p_name, + COALESCE(p_limited_to_orgs, '{}'::uuid[]), + COALESCE(p_limited_to_apps, '{}'::text[]), + p_expires_at + ); +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."create_hashed_apikey_for_user"( + "p_user_id" "uuid", + "p_mode" "public"."key_mode" DEFAULT NULL, + "p_name" "text" DEFAULT '', + "p_limited_to_orgs" "uuid"[] DEFAULT '{}'::uuid[], + "p_limited_to_apps" "text"[] DEFAULT '{}'::text[], + "p_expires_at" timestamp with time zone DEFAULT NULL +) RETURNS "public"."apikeys" + LANGUAGE "plpgsql" + SECURITY INVOKER + SET "search_path" TO '' + AS $$ +DECLARE + v_plain_key text; + v_apikey public.apikeys; +BEGIN + v_plain_key := gen_random_uuid()::text; + + PERFORM set_config('capgo.skip_apikey_trigger', 'true', true); + + INSERT INTO public.apikeys ( + user_id, + key, + key_hash, + mode, + name, + limited_to_orgs, + limited_to_apps, + expires_at + ) + VALUES ( + p_user_id, + NULL, + encode(extensions.digest(v_plain_key, 'sha256'), 'hex'), + p_mode, + p_name, + COALESCE(p_limited_to_orgs, '{}'::uuid[]), + COALESCE(p_limited_to_apps, '{}'::text[]), + p_expires_at + ) + RETURNING * INTO v_apikey; + + v_apikey.key := v_plain_key; + + RETURN v_apikey; +END; +$$; diff --git a/tests/apikey-atomic-bindings.test.ts b/tests/apikey-atomic-bindings.test.ts new file mode 100644 index 0000000000..b8e803b040 --- /dev/null +++ b/tests/apikey-atomic-bindings.test.ts @@ -0,0 +1,342 @@ +import { randomUUID } from 'node:crypto' +import { env } from 'node:process' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { getAuthHeaders, getEndpointUrl, getSupabaseClient, USER_ID } from './test-utils.ts' + +const USE_CLOUDFLARE = env.USE_CLOUDFLARE_WORKERS === 'true' + +let authHeaders: Record + +// Dedicated seed data for this test file (isolated from other parallel test files) +const TEST_ID = randomUUID() +const TEST_ORG_ID = randomUUID() +const TEST_ORG_NAME = `Atomic Bindings Test Org ${TEST_ID.slice(0, 8)}` +const TEST_ORG_EMAIL = `atomic-bindings-${TEST_ID.slice(0, 8)}@capgo.app` +const STRIPE_CUSTOMER_ID = `cus_atomic_bindings_${TEST_ID.slice(0, 8)}` + +const createdKeyIds: number[] = [] + +async function setupTestOrg() { + const supabase = getSupabaseClient() + + // Create a dedicated org with RBAC enabled + const { error: orgError } = await supabase.from('orgs').insert({ + id: TEST_ORG_ID, + created_by: USER_ID, + name: TEST_ORG_NAME, + management_email: TEST_ORG_EMAIL, + use_new_rbac: true, + }) + if (orgError) + throw orgError + + // The org needs a stripe_info entry for the apikey creation path + const { error: stripeError } = await supabase.from('stripe_info').insert({ + customer_id: STRIPE_CUSTOMER_ID, + product_id: 'prod_LQIregjtNduh4q', + subscription_id: null, + is_good_plan: true, + }) + if (stripeError) + throw stripeError + + const { error: orgToStripeError } = await supabase.from('orgs').update({ + customer_id: STRIPE_CUSTOMER_ID, + }).eq('id', TEST_ORG_ID) + if (orgToStripeError) + throw orgToStripeError + + // Add the test user as super_admin in the org (legacy membership for checkPermission fallback) + const { error: ouError } = await supabase.from('org_users').insert({ + org_id: TEST_ORG_ID, + user_id: USER_ID, + user_right: 'super_admin', + }) + if (ouError) + throw ouError +} + +async function cleanupTestData() { + const supabase = getSupabaseClient() + // Delete created API keys + for (const keyId of createdKeyIds) { + await supabase.from('apikeys').delete().eq('id', keyId) + } + // Delete role bindings for the test org + await supabase.from('role_bindings').delete().eq('org_id', TEST_ORG_ID) + // Delete org membership + await supabase.from('org_users').delete().eq('org_id', TEST_ORG_ID) + // Delete org + await supabase.from('orgs').delete().eq('id', TEST_ORG_ID) + // Delete stripe info + await supabase.from('stripe_info').delete().eq('customer_id', STRIPE_CUSTOMER_ID) +} + +beforeAll(async () => { + authHeaders = await getAuthHeaders() + await setupTestOrg() +}) + +afterAll(async () => { + await cleanupTestData() +}) + +// Atomic API key + bindings tests use /private/ route which is Supabase-only +describe.skipIf(USE_CLOUDFLARE)('[POST] /apikey with atomic bindings', () => { + it('creates an API key with bindings and no mode', async () => { + const response = await fetch(getEndpointUrl('/apikey'), { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + name: `atomic-bindings-key-${TEST_ID.slice(0, 8)}`, + limited_to_orgs: [TEST_ORG_ID], + bindings: [ + { + role_name: 'org_member', + scope_type: 'org', + org_id: TEST_ORG_ID, + reason: 'atomic creation test', + }, + ], + }), + }) + + const data = await response.json() as { id: number, key: string | null, mode: string | null, rbac_id: string } + expect(response.status).toBe(200) + expect(data).toHaveProperty('id') + expect(data.mode).toBeNull() + expect(data.rbac_id).toBeTruthy() + createdKeyIds.push(data.id) + + // Verify the binding was created in the database + const supabase = getSupabaseClient() + const { data: bindings, error: bindingsError } = await supabase + .from('role_bindings') + .select('*') + .eq('principal_type', 'apikey') + .eq('principal_id', data.rbac_id) + .eq('org_id', TEST_ORG_ID) + + expect(bindingsError).toBeNull() + expect(bindings).toHaveLength(1) + expect(bindings![0].scope_type).toBe('org') + expect(bindings![0].reason).toBe('atomic creation test') + }) + + it('creates an API key with mode and no bindings (backward compat)', async () => { + const response = await fetch(getEndpointUrl('/apikey'), { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + name: `legacy-mode-key-${TEST_ID.slice(0, 8)}`, + mode: 'all', + limited_to_orgs: [TEST_ORG_ID], + }), + }) + + const data = await response.json() as { id: number, mode: string | null } + expect(response.status).toBe(200) + expect(data).toHaveProperty('id') + expect(data.mode).toBe('all') + createdKeyIds.push(data.id) + }) + + it('rejects creating an API key without mode and without bindings', async () => { + const response = await fetch(getEndpointUrl('/apikey'), { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + name: `no-mode-no-bindings-${TEST_ID.slice(0, 8)}`, + limited_to_orgs: [TEST_ORG_ID], + }), + }) + + const data = await response.json() as { error: string } + expect(response.status).toBe(400) + expect(data.error).toBe('mode_is_required') + }) + + it('rolls back the API key when a binding fails', async () => { + const uniqueKeyName = `rollback-test-key-${TEST_ID.slice(0, 8)}-${Date.now()}` + + const response = await fetch(getEndpointUrl('/apikey'), { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + name: uniqueKeyName, + limited_to_orgs: [TEST_ORG_ID], + bindings: [ + { + role_name: 'nonexistent_role_that_should_fail', + scope_type: 'org', + org_id: TEST_ORG_ID, + }, + ], + }), + }) + + expect(response.status).not.toBe(200) + + // Verify the specific key was rolled back (not present in DB) + const supabase = getSupabaseClient() + const { data: matchingKeys } = await supabase + .from('apikeys') + .select('id') + .eq('user_id', USER_ID) + .eq('name', uniqueKeyName) + + expect(matchingKeys).toHaveLength(0) + }) + + it('creates multiple bindings in a single call', async () => { + const response = await fetch(getEndpointUrl('/apikey'), { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + name: `multi-binding-key-${TEST_ID.slice(0, 8)}`, + limited_to_orgs: [TEST_ORG_ID], + bindings: [ + { + role_name: 'org_member', + scope_type: 'org', + org_id: TEST_ORG_ID, + reason: 'multi-binding test 1', + }, + { + role_name: 'org_member', + scope_type: 'org', + org_id: TEST_ORG_ID, + reason: 'multi-binding test 2', + }, + ], + }), + }) + + // This may fail with a duplicate constraint if org_member can only be assigned once per scope. + // In that case, the key should be rolled back. + if (response.status === 200) { + const data = await response.json() as { id: number, rbac_id: string } + createdKeyIds.push(data.id) + + const supabase = getSupabaseClient() + const { data: bindings } = await supabase + .from('role_bindings') + .select('*') + .eq('principal_type', 'apikey') + .eq('principal_id', data.rbac_id) + .eq('org_id', TEST_ORG_ID) + + expect(bindings!.length).toBeGreaterThanOrEqual(2) + } + else { + // Duplicate binding caused rollback - that's also valid behavior + const data = await response.json() as { error: string } + expect(data).toHaveProperty('error') + } + }) + + it('validates binding shape before creation', async () => { + const response = await fetch(getEndpointUrl('/apikey'), { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + name: `invalid-binding-shape-${TEST_ID.slice(0, 8)}`, + limited_to_orgs: [TEST_ORG_ID], + bindings: [ + { + // missing role_name + scope_type: 'org', + org_id: TEST_ORG_ID, + }, + ], + }), + }) + + const data = await response.json() as { error: string } + expect(response.status).toBe(400) + expect(data.error).toBe('invalid_bindings') + }) + + it('RBAC-only key (mode=NULL) can authenticate', async () => { + // First create an RBAC-only key (no limited_to_orgs so it's not a limited-scope key) + const createResponse = await fetch(getEndpointUrl('/apikey'), { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + name: `rbac-auth-test-key-${TEST_ID.slice(0, 8)}`, + bindings: [ + { + role_name: 'org_member', + scope_type: 'org', + org_id: TEST_ORG_ID, + }, + ], + }), + }) + + expect(createResponse.status).toBe(200) + const createData = await createResponse.json() as { id: number, key: string, mode: string | null } + expect(createData.key).toBeTruthy() + expect(createData.mode).toBeNull() + createdKeyIds.push(createData.id) + + // Use the RBAC-only key to authenticate (GET /apikey should work) + const apiKeyHeaders = { + 'Content-Type': 'application/json', + 'Authorization': createData.key, + } + const getResponse = await fetch(getEndpointUrl('/apikey'), { + method: 'GET', + headers: apiKeyHeaders, + }) + + const getBody = await getResponse.json() + + // The key should authenticate successfully (mode=NULL is now allowed) + expect(getResponse.status, `Auth failed with body: ${JSON.stringify(getBody)}`).toBe(200) + }) +}) + +// Role bindings endpoint now supports API key auth +describe.skipIf(USE_CLOUDFLARE)('[GET] /private/role_bindings with API key auth', () => { + it('accepts JWT auth on role_bindings endpoint', async () => { + const response = await fetch(getEndpointUrl(`/private/role_bindings/${TEST_ORG_ID}`), { + method: 'GET', + headers: authHeaders, + }) + + expect(response.status).toBe(200) + const data = await response.json() as any[] + expect(Array.isArray(data)).toBe(true) + }) + + it('rejects limited-scope API key on role_bindings endpoint', async () => { + // Create a limited-scope key first + const createResponse = await fetch(getEndpointUrl('/apikey'), { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + name: `limited-scope-test-${TEST_ID.slice(0, 8)}`, + mode: 'all', + limited_to_orgs: [TEST_ORG_ID], + }), + }) + + expect(createResponse.status).toBe(200) + const createData = await createResponse.json() as { id: number, key: string } + createdKeyIds.push(createData.id) + + // Try using this limited-scope key on role_bindings endpoint + const response = await fetch(getEndpointUrl(`/private/role_bindings/${TEST_ORG_ID}`), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': createData.key, + }, + }) + + expect(response.status).toBe(403) + const data = await response.json() as { error: string } + expect(data.error).toBe('Limited-scope API keys cannot manage role bindings') + }) +}) diff --git a/tests/apikeys-expiration.test.ts b/tests/apikeys-expiration.test.ts index 9679577fc6..bb1d366c70 100644 --- a/tests/apikeys-expiration.test.ts +++ b/tests/apikeys-expiration.test.ts @@ -143,6 +143,7 @@ describe('[POST] /apikey with expiration', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-with-expiration', + mode: 'all', expires_at: futureDate, }), }) @@ -160,6 +161,7 @@ describe('[POST] /apikey with expiration', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-no-expiration', + mode: 'all', }), }) const data = await response.json<{ key: string, id: number, expires_at: string | null }>() @@ -175,6 +177,7 @@ describe('[POST] /apikey with expiration', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-past-expiration', + mode: 'all', expires_at: pastDate, }), }) @@ -189,6 +192,7 @@ describe('[POST] /apikey with expiration', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-invalid-date', + mode: 'all', expires_at: 'not-a-date', }), }) @@ -208,6 +212,7 @@ describe('[PUT] /apikey/:id with expiration', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-for-update-expiration', + mode: 'all', }), }) expect(response.status).toBe(200) @@ -283,6 +288,7 @@ describe('[GET] /apikey with expiration info', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-with-exp-get-test', + mode: 'all', expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }), }) @@ -295,6 +301,7 @@ describe('[GET] /apikey with expiration info', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-without-exp-get-test', + mode: 'all', }), }) expect(response2.status).toBe(200) @@ -351,6 +358,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-policy-test', + mode: 'all', limited_to_orgs: [POLICY_ORG_ID], }), }) @@ -367,6 +375,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-policy-exceeds-max', + mode: 'all', limited_to_orgs: [POLICY_ORG_ID], expires_at: tooFarDate, }), @@ -384,6 +393,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-policy-valid', + mode: 'all', limited_to_orgs: [POLICY_ORG_ID], expires_at: validDate, }), @@ -401,6 +411,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-no-policy-org', + mode: 'all', limited_to_orgs: [BASE_ORG_ID], }), }) @@ -416,6 +427,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-policy-app-scope', + mode: 'all', app_id: policyAppUuid, }), }) @@ -431,6 +443,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-policy-app-scope-too-far', + mode: 'all', limited_to_apps: [POLICY_APPNAME], expires_at: tooFarDate, }), @@ -446,6 +459,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-policy-app-scope-missing-app', + mode: 'all', limited_to_apps: [`com.app.expiration.missing.${id}`], }), }) @@ -461,6 +475,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-policy-app-scope-update', + mode: 'all', }), }) expect(createResponse.status).toBe(200) @@ -486,6 +501,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-policy-limited-updater', + mode: 'all', limited_to_apps: [POLICY_APPNAME], expires_at: validDate, }), @@ -498,6 +514,7 @@ describe('organization API key expiration policy', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-policy-sibling-target', + mode: 'all', }), }) expect(siblingKeyResponse.status).toBe(200) @@ -700,6 +717,7 @@ describe('expired API key rejection', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-to-be-expired', + mode: 'all', expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }), }) @@ -720,6 +738,7 @@ describe('expired API key rejection', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-valid-for-test', + mode: 'all', expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }), }) @@ -760,6 +779,7 @@ describe('api key expiration boundary conditions', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-boundary-test', + mode: 'all', expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }), }) @@ -794,6 +814,7 @@ describe('api key expiration boundary conditions', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-near-expiration', + mode: 'all', expires_at: futureDate, }), }) @@ -824,6 +845,7 @@ describe('api key expiration boundary conditions', () => { headers: authHeaders, body: JSON.stringify({ name: 'key-no-expiration-test', + mode: 'all', }), }) const data = await response.json<{ id: number, key: string, expires_at: string | null }>() diff --git a/tests/apikeys.test.ts b/tests/apikeys.test.ts index 100e27579f..d6d40d3f9e 100644 --- a/tests/apikeys.test.ts +++ b/tests/apikeys.test.ts @@ -57,6 +57,7 @@ describe('[POST] /apikey operations', () => { headers, body: JSON.stringify({ name: keyName, + mode: 'all', }), }) const data = await response.json<{ key: string, id: number }>() @@ -78,6 +79,7 @@ describe('[POST] /apikey operations', () => { headers, body: JSON.stringify({ name: 'limited-app-key-creator', + mode: 'all', limited_to_apps: [APPNAME], }), }) @@ -94,6 +96,7 @@ describe('[POST] /apikey operations', () => { headers: limitedHeaders, body: JSON.stringify({ name: 'blocked-limited-creation', + mode: 'all', limited_to_apps: [APPNAME], }), }) @@ -230,6 +233,7 @@ describe('[POST] /apikey operations', () => { headers, body: JSON.stringify({ name: 'test-key', + mode: 'all', org_id: nonExistentOrgId, }), }) @@ -245,6 +249,7 @@ describe('[POST] /apikey operations', () => { headers, body: JSON.stringify({ name: 'test-key', + mode: 'all', app_id: nonExistentAppId, }), }) @@ -329,7 +334,7 @@ describe('[PUT] /apikey/:id operations', () => { const createResponse = await fetch(`${BASE_URL}/apikey`, { method: 'POST', headers, - body: JSON.stringify({ name: 'temp-test-key' }), + body: JSON.stringify({ name: 'temp-test-key', mode: 'all' }), }) const createData = await createResponse.json<{ id: number }>() @@ -350,7 +355,7 @@ describe('[PUT] /apikey/:id operations', () => { const createResponse = await fetch(`${BASE_URL}/apikey`, { method: 'POST', headers, - body: JSON.stringify({ name: 'temp-test-key-2' }), + body: JSON.stringify({ name: 'temp-test-key-2', mode: 'all' }), }) const createData = await createResponse.json<{ id: number }>() @@ -368,7 +373,7 @@ describe('[PUT] /apikey/:id operations', () => { const createResponse = await fetch(`${BASE_URL}/apikey`, { method: 'POST', headers, - body: JSON.stringify({ name: 'temp-plain-key-regenerate', hashed: false }), + body: JSON.stringify({ name: 'temp-plain-key-regenerate', mode: 'all', hashed: false }), }) const createData = await createResponse.json<{ id: number, key: string }>() expect(createResponse.status).toBe(200) @@ -404,7 +409,7 @@ describe('[PUT] /apikey/:id operations', () => { const createResponse = await fetch(`${BASE_URL}/apikey`, { method: 'POST', headers, - body: JSON.stringify({ name: 'temp-hashed-key-regenerate', hashed: true }), + body: JSON.stringify({ name: 'temp-hashed-key-regenerate', mode: 'all', hashed: true }), }) const createData = await createResponse.json<{ id: number, key: string, key_hash: string }>() expect(createResponse.status).toBe(200) @@ -446,7 +451,7 @@ describe('[PUT] /apikey/:id operations', () => { const createResponse = await fetch(`${BASE_URL}/apikey`, { method: 'POST', headers, - body: JSON.stringify({ name: 'temp-key-regenerate-and-rename', hashed: false }), + body: JSON.stringify({ name: 'temp-key-regenerate-and-rename', mode: 'all', hashed: false }), }) const createData = await createResponse.json<{ id: number, key: string }>() expect(createResponse.status).toBe(200) @@ -481,7 +486,7 @@ describe('[DELETE] /apikey/:id operations', () => { const createResponse = await fetch(`${BASE_URL}/apikey`, { method: 'POST', headers, - body: JSON.stringify({ name: 'key-to-delete' }), + body: JSON.stringify({ name: 'key-to-delete', mode: 'all' }), }) const createData = await createResponse.json<{ id: number }>() @@ -514,7 +519,7 @@ describe('[DELETE] /apikey/:id operations', () => { const createResponse = await fetch(`${BASE_URL}/apikey`, { method: 'POST', headers, - body: JSON.stringify({ name: 'key-to-double-delete' }), + body: JSON.stringify({ name: 'key-to-double-delete', mode: 'all' }), }) const createData = await createResponse.json<{ id: number }>() @@ -543,6 +548,7 @@ describe('[POST] /apikey hashed key operations', () => { headers, body: JSON.stringify({ name: keyName, + mode: 'all', hashed: true, }), }) @@ -580,6 +586,7 @@ describe('[POST] /apikey hashed key operations', () => { headers, body: JSON.stringify({ name: keyName, + mode: 'all', hashed: false, }), }) @@ -633,6 +640,7 @@ describe('[POST] /apikey hashed key operations', () => { headers, body: JSON.stringify({ name: 'hashed-key-for-auth-test', + mode: 'all', hashed: true, }), }) @@ -670,6 +678,7 @@ describe('[POST] /apikey hashed key with expiration', () => { headers, body: JSON.stringify({ name: 'hashed-key-with-expiration', + mode: 'all', hashed: true, expires_at: futureDate, }), @@ -707,6 +716,7 @@ describe('[POST] /apikey hashed key with expiration', () => { headers, body: JSON.stringify({ name: 'hashed-key-expiration-auth-test', + mode: 'all', hashed: true, expires_at: futureDate, }), @@ -742,6 +752,7 @@ describe('[POST] /apikey hashed key with expiration', () => { headers, body: JSON.stringify({ name: 'hashed-key-to-expire', + mode: 'all', hashed: true, expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }), @@ -776,6 +787,7 @@ describe('[RLS] hashed API key with direct Supabase SDK', () => { headers, body: JSON.stringify({ name: 'hashed-key-rls-test', + mode: 'all', hashed: true, }), }) @@ -826,6 +838,7 @@ describe('[RLS] hashed API key with direct Supabase SDK', () => { headers, body: JSON.stringify({ name: 'plain-key-rls-test', + mode: 'all', hashed: false, }), }) diff --git a/tests/cli-sdk-utils.ts b/tests/cli-sdk-utils.ts index f69b62e1d8..0ca541cdef 100644 --- a/tests/cli-sdk-utils.ts +++ b/tests/cli-sdk-utils.ts @@ -233,7 +233,7 @@ async function getAuthorizedApp( allowedModes: Database['public']['Enums']['key_mode'][], ) { const apiKey = await getApiKeyRecord(apikey) - if (!apiKey || !hasModeAccess(apiKey.mode, allowedModes)) + if (!apiKey || !apiKey.mode || !hasModeAccess(apiKey.mode, allowedModes)) return { error: 'Invalid API key or insufficient permissions.' as const } const app = await getAppRecord(appId) @@ -251,7 +251,7 @@ async function getAuthorizedApp( async function getAppsForApiKey(apikey: string, allowedModes: Database['public']['Enums']['key_mode'][]) { const apiKey = await getApiKeyRecord(apikey) - if (!apiKey || !hasModeAccess(apiKey.mode, allowedModes)) + if (!apiKey || !apiKey.mode || !hasModeAccess(apiKey.mode, allowedModes)) return { error: 'Invalid API key or insufficient permissions.' as const } const { data, error } = await getSupabaseClient() diff --git a/tests/organization-api.test.ts b/tests/organization-api.test.ts index fb34fad5cf..2cdeb19ef5 100644 --- a/tests/organization-api.test.ts +++ b/tests/organization-api.test.ts @@ -1653,6 +1653,7 @@ describe('hashed API key enforcement integration', () => { method: 'POST', body: JSON.stringify({ name: 'test-hashed-key-for-find', + mode: 'all', hashed: true, }), }) @@ -1704,6 +1705,7 @@ describe('hashed API key enforcement integration', () => { method: 'POST', body: JSON.stringify({ name: 'test-verify-hash-key', + mode: 'all', hashed: true, }), })