From b5da889c943b5cd75a52af80c3b8e01d0e0d2875 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 8 Jun 2026 15:39:37 +0200 Subject: [PATCH] chore(db): squash supabase migrations --- .github/workflows/build_and_deploy.yml | 4 + scripts/check-supabase-migration-order.sh | 65 +- scripts/repair-supabase-squashed-baseline.sh | 204 + scripts/supabase-worktree.ts | 2 +- supabase/migration_guide.md | 39 + supabase/migrations/20250530233128_base.sql | 7220 ----- .../20250601115144_better_queue_logs.sql | 60 - .../migrations/20250605151648_credits.sql | 47 - .../20250608130257_fix_version_meta.sql | 238 - .../migrations/20250612131646_exist_app.sql | 33 - .../20250613034031_tmp_users_table.sql | 403 - .../20250619221552_global_stats.sql | 2 - .../20250714021423_manifest_perf.sql | 9 - ...822_consolidated_org_apikey_migrations.sql | 329 - .../20250908120000_pg_log_and_rls_logging.sql | 764 - .../20250909094709_better_account_delete.sql | 178 - ...0913161225_lint_warning_fixes_followup.sql | 360 - .../20250916032824_fix_retention.sql | 43 - ...legal_and_update_notification_defaults.sql | 34 - ...20250920120001_remove_old_version_meta.sql | 8 - .../20250921120000_device_version_name.sql | 86 - .../20250927082020_better_app_metrics.sql | 239 - ...20250928145642_orgs_last_stats_updated.sql | 211 - ...7132214_global_stats_registers_storage.sql | 63 - ...007134349_cron_plan_from_stats_backend.sql | 61 - .../20251014105957_rename_plan_cron.sql | 81 - ...d_batch_size_to_process_function_queue.sql | 48 - .../20251014135440_add_cron_sync_sub.sql | 134 - .../migrations/20251019123107_fix_stats.sql | 74 - ...20251021141631_add_usage_credit_system.sql | 633 - ...4153920_update_capgo_credits_steps_org.sql | 13 - .../20251024230753_fix_org_delete_cascade.sql | 19 - ...1026165357_add_missing_queue_cron_jobs.sql | 22 - .../20251031202034_fix_usage_credit_rls.sql | 133 - ...51103134045_add_download_stats_actions.sql | 28 - ...6024103_add_default_channel_to_devices.sql | 11 - .../20251107001223_channel_device_counts.sql | 132 - .../20251107153019_manifest_bundle_counts.sql | 170 - ...ransfer_ownership_before_user_deletion.sql | 158 - .../20251113140646_consolidate_cron_job.sql | 474 - ...001844_add_missing_foreign_key_indexes.sql | 13 - ...20251119001847_add_native_build_system.sql | 750 - ...150750_simplify_manifest_bundle_counts.sql | 212 - ...51204163538_drop_plans_overage_columns.sql | 11 - ...51208175306_fix_user_delete_old_record.sql | 51 - ...251209184322_add_top_up_credits_system.sql | 886 - ...1212112948_add_expose_metadata_to_apps.sql | 8 - ...41_add_revenue_metrics_to_global_stats.sql | 99 - ...000_add_encryption_tracking_to_devices.sql | 22 - ...192610_add_cli_version_to_app_versions.sql | 6 - ...220011455_optimize_is_good_plan_v5_org.sql | 521 - .../20251221091510_fix_lint_indexes.sql | 5 - .../migrations/20251222140030_rbac_system.sql | 3840 --- ...3234326_fix_duplicate_overage_tracking.sql | 290 - .../20251224103713_2fa_enforcement.sql | 615 - ...26120000_add_channel_allow_device_prod.sql | 5 - ...251226121000_add_channel_stats_actions.sql | 2 - .../migrations/20251226125240_audit_log.sql | 556 - ..._production_deploy_install_stats_email.sql | 287 - .../migrations/20251228033417_webhooks.sql | 552 - .../20251228063320_fix_audit_log_apikey.sql | 102 - .../20251228065406_user_email_preferences.sql | 50 - .../20251228080032_hashed_api_keys.sql | 390 - .../20251228080037_apikey_expiration.sql | 349 - ...28082157_add_apikey_policy_to_get_orgs.sql | 231 - ...1228100000_password_policy_enforcement.sql | 811 - ...50000_reject_access_due_to_2fa_for_app.sql | 71 - ...8160000_get_org_members_apikey_support.sql | 34 - ...251228215402_add_orphan_images_cleanup.sql | 342 - ...251229030503_add_cron_tasks_rls_policy.sql | 6 - ...g_members_password_policy_service_role.sql | 61 - ..._uuid_generate_v4_with_gen_random_uuid.sql | 37 - ...14041_reject_access_due_to_2fa_for_org.sql | 62 - ...1060433_add_billing_period_stats_email.sql | 324 - ...260101042511_enforce_encrypted_bundles.sql | 665 - ..._fix_get_org_members_include_tmp_users.sql | 42 - ...140000_fix_get_identity_hashed_apikeys.sql | 222 - ...260103030451_add_advisory_lock_to_cron.sql | 125 - ...260104100000_add_allow_preview_to_apps.sql | 7 - ...10000_add_apikey_policy_to_get_orgs_v7.sql | 273 - ...e_process_function_queue_public_access.sql | 369 - .../20260105014309_remove_metered.sql | 5 - ..._fix_is_allowed_capgkey_hashed_apikeys.sql | 120 - ...07000000_add_anon_role_to_webhooks_rls.sql | 176 - .../20260108000000_add_electron_platform.sql | 10 - ...108024031_add_devices_platform_columns.sql | 4 - ...00000_fix_build_system_rls_consistency.sql | 96 - ...0109000001_remove_both_platform_option.sql | 16 - ...0260110044840_improve_usage_credit_rls.sql | 117 - ...0112140000_cleanup_old_channel_devices.sql | 101 - ...0_add_plugin_breakdown_to_global_stats.sql | 13 - .../20260113132114_missing_index.sql | 5 - ...0113160650_delete_old_deleted_versions.sql | 100 - .../20260114214731_add_deleted_at_column.sql | 96 - ...60115025158_add_daily_fail_ratio_email.sql | 147 - ...5051444_sync_stripe_info_on_org_create.sql | 62 - ...000000_add_build_stats_to_global_stats.sql | 19 - ...8005052_version_usage_use_version_name.sql | 107 - ...182934_add_use_new_rbac_to_get_orgs_v7.sql | 283 - .../20260120165047_rbac_invites.sql | 1196 - .../20260121000000_add_demo_app_support.sql | 65 - .../20260123140712_fix_rbac_perf_security.sql | 659 - ...31940_fix_multiple_permissive_policies.sql | 722 - ...0125151000_mau_first_seen_device_usage.sql | 34 - ...20000_enforce_2fa_in_permission_checks.sql | 308 - ...60127121000_allow_credits_without_plan.sql | 297 - ..._require_recent_reauth_for_delete_user.sql | 71 - .../20260127232000_sanitize_text_fields.sql | 106 - ...0_fix_reject_access_due_to_2fa_for_app.sql | 68 - ...29123000_fix_is_bundle_encrypted_empty.sql | 21 - .../20260130032543_allow_org_logo_images.sql | 261 - .../20260130033703_private_images_bucket.sql | 230 - .../20260130040811_allow_org_logo_upload.sql | 277 - ...0190800_update_invite_expiry_on_resend.sql | 177 - .../20260201015640_add_upgrade_org_stats.sql | 10 - ...2609_fix_password_policy_org_read_gate.sql | 248 - ...60202090000_add_cli_realtime_feed_pref.sql | 30 - ...20260203010025_add_build_success_stats.sql | 8 - ...60203120000_optimize_org_metrics_cache.sql | 556 - .../20260203140000_security_hardening.sql | 281 - ...ix_get_user_main_org_id_by_app_id_seed.sql | 53 - ...20260203160000_optimize_audit_logs_rls.sql | 21 - ...03173000_get_account_removal_date_auth.sql | 46 - ...03190000_check_min_rights_apikey_scope.sql | 124 - ...03201308_rbac_org_member_no_app_access.sql | 320 - ...260204100000_restore_audit_logs_apikey.sql | 21 - .../20260204103000_mfa_email_otp_guard.sql | 184 - ...204103001_enable_security_settings_rls.sql | 9 - ...81424_add_channel_permission_overrides.sql | 360 - ...20260205031305_mfa_email_otp_hardening.sql | 41 - ...260205120000_fix_audit_logs_select_rls.sql | 155 - ...0260206120000_apikey_server_generation.sql | 261 - ...60206213247_org_has_usage_credits_flag.sql | 135 - ...0260207180640_tmp_users_cleanup_7_days.sql | 49 - ...20260209014020_user_created_via_invite.sql | 7 - ...024134_remove_exceeded_flags_functions.sql | 6 - .../20260210132811_stats_customid_guard.sql | 12 - ..._add_demo_apps_created_to_global_stats.sql | 4 - ..._top_up_usage_credits_for_service_role.sql | 33 - ...0_add_build_status_reconciliation_cron.sql | 75 - ...07_fix_role_bindings_rls_update_insert.sql | 168 - .../20260223000001_add_sso_providers.sql | 187 - ...091500_fix_get_orgs_v6_access_controls.sql | 9 - ...60224093000_fix_get_total_metrics_auth.sql | 237 - ...dd_org_conversion_rate_to_global_stats.sql | 4 - ...260224153100_fix_org_member_rpc_access.sql | 257 - ...0224153200_fix_webhook_rls_org_scoping.sql | 184 - ...ke_record_email_otp_verified_auth_role.sql | 47 - ...24153300_add_created_at_to_get_orgs_v7.sql | 329 - ...260224153401_fix_transfer_app_security.sql | 126 - ...224153500_restrict_rpc_api_key_oracles.sql | 8 - ...160000_fix_find_apikey_rpc_permissions.sql | 16 - ...000000_image_metadata_cleanup_triggers.sql | 51 - ...0260225000100_atomic_demo_app_creation.sql | 162 - ...0260225105000_exist_app_v2_apikey_auth.sql | 33 - ...estrict_webhooks_select_for_admin_only.sql | 29 - ...000000_org_rls_require_self_2fa_update.sql | 32 - ...0_fix_org_rls_2fa_function_permissions.sql | 37 - ...require_verified_email_for_delete_user.sql | 85 - ...0226153000_restrict_apikey_oracle_rpcs.sql | 10 - ...0000_fix_rescind_invitation_rpc_access.sql | 43 - ...227000001_secure_record_build_time_rpc.sql | 90 - ...0000_restrict_upsert_version_meta_exec.sql | 13 - ...150000_fix_invite_user_to_org_security.sql | 141 - ...228000000_role_bindings_rls_assignable.sql | 23 - ...8000100_delete_member_cascade_bindings.sql | 61 - ...000200_prevent_last_super_admin_delete.sql | 61 - ...0260228000300_fix_apikey_hashed_lookup.sql | 200 - ...28154639_fix_check_domain_sso_security.sql | 20 - ...8_fix_prevent_last_super_admin_cascade.sql | 65 - ...0228172309_fix_rbac_test_compatibility.sql | 60 - ...260302000000_rbac_default_for_new_orgs.sql | 3 - ...02185011_fix_rbac_check_effective_user.sql | 304 - ...0260303150634_sso_per_org_feature_flag.sql | 354 - ...121758_fix_get_app_global_metrics_rbac.sql | 240 - ...308121933_restrict_global_stats_access.sql | 14 - ...8203352_restrict-org-status-rpc-access.sql | 69 - ...60311120000_allow_shared_public_images.sql | 78 - ...as_permission_preserve_org_for_new_app.sql | 115 - ...24500_fix_get_org_perm_for_apikey_rbac.sql | 57 - ...11150453_secure_sso_enforcement_lookup.sql | 21 - ...400_sync_org_user_delete_role_bindings.sql | 188 - ..._split_is_admin_platform_admin_and_rls.sql | 1086 - ...move_rbac_security_settings_singletons.sql | 168 - ...ormalize_sso_provider_domain_lowercase.sql | 92 - ...ardening_get_identity_apikey_only_rpcs.sql | 23 - ...escind_invitation_rpc_access_hardening.sql | 61 - ...ix_rbac_org_user_access_null_auth_gate.sql | 68 - ...ed_record_build_time_public_revoke_fix.sql | 9 - ...ix_get_current_plan_max_org_access_cli.sql | 63 - ...13104427_webhook-api-key-org-scope-cli.sql | 242 - ..._fix-onboarding-needed-org-nonexistent.sql | 17 - ...30044_harden_upsert_version_meta_authz.sql | 111 - ...1_move_mfa_email_otp_trigger_to_public.sql | 121 - ...20423_harden_plan_usage_org_rpc_access.sql | 337 - ...317020451_secure_remaining_helper_rpcs.sql | 567 - ..._cleanup_expired_demo_apps_public_exec.sql | 6 - ...715_fix_get_user_org_ids_apikey_expiry.sql | 121 - ...17040310_restrict_manifest_read_access.sql | 24 - ...260317090000_fix_get_app_versions_rbac.sql | 68 - ...ix_encrypted_bundle_update_enforcement.sql | 9 - ...60317160518_sso_skip_org_on_sso_domain.sql | 46 - ...ix_get_orgs_v7_private_overload_grants.sql | 9 - ...7_optimize-org-metrics-cache-read-only.sql | 309 - ...19090430_password_policy_max_length_72.sql | 102 - ...4649_add_build_minutes_to_global_stats.sql | 13 - ..._fix_subkey_header_and_plan_usage_rpcs.sql | 362 - ...tats_build_seconds_and_conversion_rate.sql | 116 - ...20260319164053_fix_manifest_select_rls.sql | 26 - .../20260319221428_onboarding_app_flags.sql | 108 - ...235626_disable_auto_org_on_user_create.sql | 4 - .../20260320044548_add_org_website.sql | 258 - .../20260320133752_app_demo_flag_cleanup.sql | 195 - ...5628_fix_rbac_admin_rpc_execute_grants.sql | 51 - ...181219_fix_process_cron_stats_activity.sql | 116 - ..._add_paid_at_for_admin_revenue_metrics.sql | 20 - ...032835_optimize_webhooks_rls_auth_eval.sql | 180 - ...43000_harden_cron_stats_queue_followup.sql | 139 - ...el_permission_overrides_write_policies.sql | 98 - ...044102_fix_cron_sync_sub_queue_payload.sql | 40 - ...20260327210500_app_scoped_metrics_rbac.sql | 444 - ...20305_add_webhook_queues_to_cron_tasks.sql | 48 - ...20260330141128_stripe_customer_country.sql | 4 - ...34842_adjust_build_time_credit_pricing.sql | 49 - ...5_fix_org_metrics_cache_delete_cascade.sql | 8 - ...260422104849_stale_chart_refresh_state.sql | 924 - ...0422203355_add_admin_retention_metrics.sql | 69 - ...111_fix_rbac_scope_mismatch_escalation.sql | 182 - ...090125_protect_owner_org_transfer_path.sql | 168 - ...424090727_block_apikey_channel_updates.sql | 27 - ...0854_enforce_public_channel_uniqueness.sql | 81 - ..._transfer_app_deploy_history_owner_org.sql | 181 - ...e_hashed_api_keys_on_rls_identity_path.sql | 219 - ...101_enforce_apikey_scope_in_rbac_check.sql | 436 - ...5_harden_role_bindings_cross_org_scope.sql | 167 - ...92702_fix_transfer_app_guard_allowlist.sql | 180 - ...harden_security_definer_execute_grants.sql | 569 - ...paying_and_good_plan_org_action_access.sql | 84 - ...5834_restrict_manifest_mutation_access.sql | 14 - ...05838_enforce_apikey_expiration_policy.sql | 66 - ...9_fix_apikey_helper_rpc_public_execute.sql | 251 - ...612_retention_metrics_service_role_rls.sql | 24 - ...quire_recent_email_otp_for_delete_user.sql | 77 - ...27144300_rbac_apikey_bindings_priority.sql | 515 - ...427144323_cli_rbac_permission_wrappers.sql | 127 - ...27144324_add_org_create_app_permission.sql | 190 - ...lper_rpc_request_role_and_admin_grants.sql | 533 - ..._apikey_mismatch_and_bindings_priority.sql | 625 - ...orary_cli_apps_list_anon_helper_grants.sql | 50 - ...94653_restore_deleted_account_recovery.sql | 52 - .../20260429135552_enable_rbac_all_orgs.sql | 19 - ...0145247_validate_org_security_settings.sql | 32 - ...enforce_check_min_rights_app_org_scope.sql | 143 - ...60501162433_fix_storage_cleanup_counts.sql | 52 - ...20260501200000_remove_sso_enabled_flag.sql | 425 - ...20260502134045_fix_audit_logs_anon_dos.sql | 18 - ...4234_prevent_last_super_admin_demotion.sql | 81 - ..._rbac_role_binding_demoted_super_admin.sql | 160 - ...74812_fix_build_time_daily_aggregation.sql | 231 - ...356_apikey_nullable_mode_with_bindings.sql | 213 - ...rden_encrypted_bundle_update_invariant.sql | 161 - ...01503_add_churn_revenue_plan_breakdown.sql | 53 - ..._plugin_version_ladder_to_global_stats.sql | 2 - ...60506152006_native_version_usage_chart.sql | 136 - ...260507082135_active_usage_credits_flag.sql | 103 - ...260507090047_fix_app_versions_anon_dos.sql | 102 - ...y_rbac_rpc_oracle_and_expiration_scope.sql | 235 - ...07091347_secure_exist_app_versions_rpc.sql | 313 - ...153639_fast_app_versions_select_policy.sql | 196 - ...7165636_fast_usage_credit_rls_policies.sql | 305 - ...137_fix_app_versions_trigger_owner_org.sql | 47 - ...8_enforce_channel_promotion_permission.sql | 44 - ...510103516_stats_health_events_metadata.sql | 16 - .../20260510161104_build_timeout_seconds.sql | 43 - ...14_native_build_concurrency_plan_limit.sql | 85 - ...10183000_add_build_runner_wait_seconds.sql | 4 - ...2_fix_apikey_rbac_password_policy_gate.sql | 157 - ..._paid_product_activity_to_global_stats.sql | 6 - ...214140_org_initial_plan_solo_mau_limit.sql | 3 - ..._plan_conversion_rates_to_global_stats.sql | 10 - ...0235542_add_plan_total_conversion_rate.sql | 4 - .../20260511101826_add_ltv_global_stats.sql | 11 - ...fix_get_organization_cli_warnings_rbac.sql | 45 - ...513000348_add_audit_log_retention_cron.sql | 71 - ...3152636_replace_manifest_cleanup_index.sql | 10 - ...60514093535_app_versions_r2_path_index.sql | 36 - ...nforce_90_day_deleted_versions_cleanup.sql | 78 - ...dant_channel_devices_unique_constraint.sql | 4 - ...07_fix_cli_warnings_app_scoped_apikeys.sql | 90 - ...60517102815_enforce_webhook_created_by.sql | 105 - ...0000_add_ai_analyzed_to_build_requests.sql | 5 - ...0260518121000_standard_webhook_secrets.sql | 170 - ...518130000_plan_check_passthrough_appid.sql | 143 - ...complete_onboarding_after_first_upload.sql | 254 - ..._onboarding_after_first_upload_trigger.sql | 199 - .../20260519123613_safe_demo_data_reset.sql | 907 - ...50_remove_builtin_unknown_app_versions.sql | 76 - ...0521210531_cron_hyperping_healthchecks.sql | 216 - ...5_drop_channel_devices_owner_org_index.sql | 3 - ...00_migrate_apikeys_to_v2_timestamp_fix.sql | 3116 -- ...13_fix_apikey_v2_app_versions_rls_perf.sql | 180 - ...013304_fix_app_version_upload_policies.sql | 342 - ...934_optimize_apikey_hashed_enforcement.sql | 81 - .../20260528090340_manifest_index.sql | 3 - .../20260528113224_missing_indexs.sql | 7 - ...29075127_storage_hourly_shadow_billing.sql | 60 - ...0530083657_add_builder_onboarding_pref.sql | 14 - ...530114525_add_bundle_incompatible_pref.sql | 16 - ...31063221_get_org_apps_with_last_upload.sql | 142 - ...601101710_allow_apikey_org_status_rpcs.sql | 13 - .../20260603102048_add_orgs_onboarding.sql | 17 - ...3951_suppress_stats_refresh_audit_logs.sql | 120 - ...0603174942_restore_apikey_org_creation.sql | 309 - .../20260605104908_compatibility_events.sql | 82 - ...emove_stale_device_replication_trigger.sql | 4 - ...05_compatibility_events_per_occurrence.sql | 25 - ...8143906_fix_manifest_update_rls_policy.sql | 25597 +++++++++++++++- .../20260608143906_pre_squash_repair.sql | 37 + .../20260608143906_reverted_versions.txt | 311 + 319 files changed, 26238 insertions(+), 61194 deletions(-) create mode 100755 scripts/repair-supabase-squashed-baseline.sh delete mode 100644 supabase/migrations/20250530233128_base.sql delete mode 100644 supabase/migrations/20250601115144_better_queue_logs.sql delete mode 100644 supabase/migrations/20250605151648_credits.sql delete mode 100644 supabase/migrations/20250608130257_fix_version_meta.sql delete mode 100644 supabase/migrations/20250612131646_exist_app.sql delete mode 100644 supabase/migrations/20250613034031_tmp_users_table.sql delete mode 100644 supabase/migrations/20250619221552_global_stats.sql delete mode 100644 supabase/migrations/20250714021423_manifest_perf.sql delete mode 100644 supabase/migrations/20250903010822_consolidated_org_apikey_migrations.sql delete mode 100644 supabase/migrations/20250908120000_pg_log_and_rls_logging.sql delete mode 100644 supabase/migrations/20250909094709_better_account_delete.sql delete mode 100644 supabase/migrations/20250913161225_lint_warning_fixes_followup.sql delete mode 100644 supabase/migrations/20250916032824_fix_retention.sql delete mode 100644 supabase/migrations/20250920120000_remove_legal_and_update_notification_defaults.sql delete mode 100644 supabase/migrations/20250920120001_remove_old_version_meta.sql delete mode 100644 supabase/migrations/20250921120000_device_version_name.sql delete mode 100644 supabase/migrations/20250927082020_better_app_metrics.sql delete mode 100644 supabase/migrations/20250928145642_orgs_last_stats_updated.sql delete mode 100644 supabase/migrations/20251007132214_global_stats_registers_storage.sql delete mode 100644 supabase/migrations/20251007134349_cron_plan_from_stats_backend.sql delete mode 100644 supabase/migrations/20251014105957_rename_plan_cron.sql delete mode 100644 supabase/migrations/20251014120000_add_batch_size_to_process_function_queue.sql delete mode 100644 supabase/migrations/20251014135440_add_cron_sync_sub.sql delete mode 100644 supabase/migrations/20251019123107_fix_stats.sql delete mode 100644 supabase/migrations/20251021141631_add_usage_credit_system.sql delete mode 100644 supabase/migrations/20251024153920_update_capgo_credits_steps_org.sql delete mode 100644 supabase/migrations/20251024230753_fix_org_delete_cascade.sql delete mode 100644 supabase/migrations/20251026165357_add_missing_queue_cron_jobs.sql delete mode 100644 supabase/migrations/20251031202034_fix_usage_credit_rls.sql delete mode 100644 supabase/migrations/20251103134045_add_download_stats_actions.sql delete mode 100644 supabase/migrations/20251106024103_add_default_channel_to_devices.sql delete mode 100644 supabase/migrations/20251107001223_channel_device_counts.sql delete mode 100644 supabase/migrations/20251107153019_manifest_bundle_counts.sql delete mode 100644 supabase/migrations/20251113041643_transfer_ownership_before_user_deletion.sql delete mode 100644 supabase/migrations/20251113140646_consolidate_cron_job.sql delete mode 100644 supabase/migrations/20251119001844_add_missing_foreign_key_indexes.sql delete mode 100644 supabase/migrations/20251119001847_add_native_build_system.sql delete mode 100644 supabase/migrations/20251120150750_simplify_manifest_bundle_counts.sql delete mode 100644 supabase/migrations/20251204163538_drop_plans_overage_columns.sql delete mode 100644 supabase/migrations/20251208175306_fix_user_delete_old_record.sql delete mode 100644 supabase/migrations/20251209184322_add_top_up_credits_system.sql delete mode 100644 supabase/migrations/20251212112948_add_expose_metadata_to_apps.sql delete mode 100644 supabase/migrations/20251213114641_add_revenue_metrics_to_global_stats.sql delete mode 100644 supabase/migrations/20251213140000_add_encryption_tracking_to_devices.sql delete mode 100644 supabase/migrations/20251219192610_add_cli_version_to_app_versions.sql delete mode 100644 supabase/migrations/20251220011455_optimize_is_good_plan_v5_org.sql delete mode 100644 supabase/migrations/20251221091510_fix_lint_indexes.sql delete mode 100644 supabase/migrations/20251222140030_rbac_system.sql delete mode 100644 supabase/migrations/20251223234326_fix_duplicate_overage_tracking.sql delete mode 100644 supabase/migrations/20251224103713_2fa_enforcement.sql delete mode 100644 supabase/migrations/20251226120000_add_channel_allow_device_prod.sql delete mode 100644 supabase/migrations/20251226121000_add_channel_stats_actions.sql delete mode 100644 supabase/migrations/20251226125240_audit_log.sql delete mode 100644 supabase/migrations/20251227040840_add_production_deploy_install_stats_email.sql delete mode 100644 supabase/migrations/20251228033417_webhooks.sql delete mode 100644 supabase/migrations/20251228063320_fix_audit_log_apikey.sql delete mode 100644 supabase/migrations/20251228065406_user_email_preferences.sql delete mode 100644 supabase/migrations/20251228080032_hashed_api_keys.sql delete mode 100644 supabase/migrations/20251228080037_apikey_expiration.sql delete mode 100644 supabase/migrations/20251228082157_add_apikey_policy_to_get_orgs.sql delete mode 100644 supabase/migrations/20251228100000_password_policy_enforcement.sql delete mode 100644 supabase/migrations/20251228150000_reject_access_due_to_2fa_for_app.sql delete mode 100644 supabase/migrations/20251228160000_get_org_members_apikey_support.sql delete mode 100644 supabase/migrations/20251228215402_add_orphan_images_cleanup.sql delete mode 100644 supabase/migrations/20251229030503_add_cron_tasks_rls_policy.sql delete mode 100644 supabase/migrations/20251229100000_fix_check_org_members_password_policy_service_role.sql delete mode 100644 supabase/migrations/20251229233706_replace_uuid_generate_v4_with_gen_random_uuid.sql delete mode 100644 supabase/migrations/20251230114041_reject_access_due_to_2fa_for_org.sql delete mode 100644 supabase/migrations/20251231060433_add_billing_period_stats_email.sql delete mode 100644 supabase/migrations/20260101042511_enforce_encrypted_bundles.sql delete mode 100644 supabase/migrations/20260102120000_fix_get_org_members_include_tmp_users.sql delete mode 100644 supabase/migrations/20260102140000_fix_get_identity_hashed_apikeys.sql delete mode 100644 supabase/migrations/20260103030451_add_advisory_lock_to_cron.sql delete mode 100644 supabase/migrations/20260104100000_add_allow_preview_to_apps.sql delete mode 100644 supabase/migrations/20260104110000_add_apikey_policy_to_get_orgs_v7.sql delete mode 100644 supabase/migrations/20260104120000_revoke_process_function_queue_public_access.sql delete mode 100644 supabase/migrations/20260105014309_remove_metered.sql delete mode 100644 supabase/migrations/20260105150626_fix_is_allowed_capgkey_hashed_apikeys.sql delete mode 100644 supabase/migrations/20260107000000_add_anon_role_to_webhooks_rls.sql delete mode 100644 supabase/migrations/20260108000000_add_electron_platform.sql delete mode 100644 supabase/migrations/20260108024031_add_devices_platform_columns.sql delete mode 100644 supabase/migrations/20260109000000_fix_build_system_rls_consistency.sql delete mode 100644 supabase/migrations/20260109000001_remove_both_platform_option.sql delete mode 100644 supabase/migrations/20260110044840_improve_usage_credit_rls.sql delete mode 100644 supabase/migrations/20260112140000_cleanup_old_channel_devices.sql delete mode 100644 supabase/migrations/20260113000000_add_plugin_breakdown_to_global_stats.sql delete mode 100644 supabase/migrations/20260113132114_missing_index.sql delete mode 100644 supabase/migrations/20260113160650_delete_old_deleted_versions.sql delete mode 100644 supabase/migrations/20260114214731_add_deleted_at_column.sql delete mode 100644 supabase/migrations/20260115025158_add_daily_fail_ratio_email.sql delete mode 100644 supabase/migrations/20260115051444_sync_stripe_info_on_org_create.sql delete mode 100644 supabase/migrations/20260118000000_add_build_stats_to_global_stats.sql delete mode 100644 supabase/migrations/20260118005052_version_usage_use_version_name.sql delete mode 100644 supabase/migrations/20260119182934_add_use_new_rbac_to_get_orgs_v7.sql delete mode 100644 supabase/migrations/20260120165047_rbac_invites.sql delete mode 100644 supabase/migrations/20260121000000_add_demo_app_support.sql delete mode 100644 supabase/migrations/20260123140712_fix_rbac_perf_security.sql delete mode 100644 supabase/migrations/20260124231940_fix_multiple_permissive_policies.sql delete mode 100644 supabase/migrations/20260125151000_mau_first_seen_device_usage.sql delete mode 100644 supabase/migrations/20260127120000_enforce_2fa_in_permission_checks.sql delete mode 100644 supabase/migrations/20260127121000_allow_credits_without_plan.sql delete mode 100644 supabase/migrations/20260127153000_require_recent_reauth_for_delete_user.sql delete mode 100644 supabase/migrations/20260127232000_sanitize_text_fields.sql delete mode 100644 supabase/migrations/20260129120000_fix_reject_access_due_to_2fa_for_app.sql delete mode 100644 supabase/migrations/20260129123000_fix_is_bundle_encrypted_empty.sql delete mode 100644 supabase/migrations/20260130032543_allow_org_logo_images.sql delete mode 100644 supabase/migrations/20260130033703_private_images_bucket.sql delete mode 100644 supabase/migrations/20260130040811_allow_org_logo_upload.sql delete mode 100644 supabase/migrations/20260130190800_update_invite_expiry_on_resend.sql delete mode 100644 supabase/migrations/20260201015640_add_upgrade_org_stats.sql delete mode 100644 supabase/migrations/20260201042609_fix_password_policy_org_read_gate.sql delete mode 100644 supabase/migrations/20260202090000_add_cli_realtime_feed_pref.sql delete mode 100644 supabase/migrations/20260203010025_add_build_success_stats.sql delete mode 100644 supabase/migrations/20260203120000_optimize_org_metrics_cache.sql delete mode 100644 supabase/migrations/20260203140000_security_hardening.sql delete mode 100644 supabase/migrations/20260203150000_fix_get_user_main_org_id_by_app_id_seed.sql delete mode 100644 supabase/migrations/20260203160000_optimize_audit_logs_rls.sql delete mode 100644 supabase/migrations/20260203173000_get_account_removal_date_auth.sql delete mode 100644 supabase/migrations/20260203190000_check_min_rights_apikey_scope.sql delete mode 100644 supabase/migrations/20260203201308_rbac_org_member_no_app_access.sql delete mode 100644 supabase/migrations/20260204100000_restore_audit_logs_apikey.sql delete mode 100644 supabase/migrations/20260204103000_mfa_email_otp_guard.sql delete mode 100644 supabase/migrations/20260204103001_enable_security_settings_rls.sql delete mode 100644 supabase/migrations/20260204181424_add_channel_permission_overrides.sql delete mode 100644 supabase/migrations/20260205031305_mfa_email_otp_hardening.sql delete mode 100644 supabase/migrations/20260205120000_fix_audit_logs_select_rls.sql delete mode 100644 supabase/migrations/20260206120000_apikey_server_generation.sql delete mode 100644 supabase/migrations/20260206213247_org_has_usage_credits_flag.sql delete mode 100644 supabase/migrations/20260207180640_tmp_users_cleanup_7_days.sql delete mode 100644 supabase/migrations/20260209014020_user_created_via_invite.sql delete mode 100644 supabase/migrations/20260209024134_remove_exceeded_flags_functions.sql delete mode 100644 supabase/migrations/20260210132811_stats_customid_guard.sql delete mode 100644 supabase/migrations/20260211034517_add_demo_apps_created_to_global_stats.sql delete mode 100644 supabase/migrations/20260214054927_restore_top_up_usage_credits_for_service_role.sql delete mode 100644 supabase/migrations/20260216102420_add_build_status_reconciliation_cron.sql delete mode 100644 supabase/migrations/20260221150207_fix_role_bindings_rls_update_insert.sql delete mode 100644 supabase/migrations/20260223000001_add_sso_providers.sql delete mode 100644 supabase/migrations/20260224091500_fix_get_orgs_v6_access_controls.sql delete mode 100644 supabase/migrations/20260224093000_fix_get_total_metrics_auth.sql delete mode 100644 supabase/migrations/20260224153000_add_org_conversion_rate_to_global_stats.sql delete mode 100644 supabase/migrations/20260224153100_fix_org_member_rpc_access.sql delete mode 100644 supabase/migrations/20260224153200_fix_webhook_rls_org_scoping.sql delete mode 100644 supabase/migrations/20260224153201_revoke_record_email_otp_verified_auth_role.sql delete mode 100644 supabase/migrations/20260224153300_add_created_at_to_get_orgs_v7.sql delete mode 100644 supabase/migrations/20260224153401_fix_transfer_app_security.sql delete mode 100644 supabase/migrations/20260224153500_restrict_rpc_api_key_oracles.sql delete mode 100644 supabase/migrations/20260224160000_fix_find_apikey_rpc_permissions.sql delete mode 100644 supabase/migrations/20260225000000_image_metadata_cleanup_triggers.sql delete mode 100644 supabase/migrations/20260225000100_atomic_demo_app_creation.sql delete mode 100644 supabase/migrations/20260225105000_exist_app_v2_apikey_auth.sql delete mode 100644 supabase/migrations/20260225120000_restrict_webhooks_select_for_admin_only.sql delete mode 100644 supabase/migrations/20260226000000_org_rls_require_self_2fa_update.sql delete mode 100644 supabase/migrations/20260226000100_fix_org_rls_2fa_function_permissions.sql delete mode 100644 supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql delete mode 100644 supabase/migrations/20260226153000_restrict_apikey_oracle_rpcs.sql delete mode 100644 supabase/migrations/20260227000000_fix_rescind_invitation_rpc_access.sql delete mode 100644 supabase/migrations/20260227000001_secure_record_build_time_rpc.sql delete mode 100644 supabase/migrations/20260227010000_restrict_upsert_version_meta_exec.sql delete mode 100644 supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql delete mode 100644 supabase/migrations/20260228000000_role_bindings_rls_assignable.sql delete mode 100644 supabase/migrations/20260228000100_delete_member_cascade_bindings.sql delete mode 100644 supabase/migrations/20260228000200_prevent_last_super_admin_delete.sql delete mode 100644 supabase/migrations/20260228000300_fix_apikey_hashed_lookup.sql delete mode 100644 supabase/migrations/20260228154639_fix_check_domain_sso_security.sql delete mode 100644 supabase/migrations/20260228172308_fix_prevent_last_super_admin_cascade.sql delete mode 100644 supabase/migrations/20260228172309_fix_rbac_test_compatibility.sql delete mode 100644 supabase/migrations/20260302000000_rbac_default_for_new_orgs.sql delete mode 100644 supabase/migrations/20260302185011_fix_rbac_check_effective_user.sql delete mode 100644 supabase/migrations/20260303150634_sso_per_org_feature_flag.sql delete mode 100644 supabase/migrations/20260308121758_fix_get_app_global_metrics_rbac.sql delete mode 100644 supabase/migrations/20260308121933_restrict_global_stats_access.sql delete mode 100644 supabase/migrations/20260308203352_restrict-org-status-rpc-access.sql delete mode 100644 supabase/migrations/20260311120000_allow_shared_public_images.sql delete mode 100644 supabase/migrations/20260311123000_fix_rbac_has_permission_preserve_org_for_new_app.sql delete mode 100644 supabase/migrations/20260311124500_fix_get_org_perm_for_apikey_rbac.sql delete mode 100644 supabase/migrations/20260311150453_secure_sso_enforcement_lookup.sql delete mode 100644 supabase/migrations/20260311162400_sync_org_user_delete_role_bindings.sql delete mode 100644 supabase/migrations/20260311164503_split_is_admin_platform_admin_and_rls.sql delete mode 100644 supabase/migrations/20260312000000_remove_rbac_security_settings_singletons.sql delete mode 100644 supabase/migrations/20260312183000_normalize_sso_provider_domain_lowercase.sql delete mode 100644 supabase/migrations/20260312202155_hardening_get_identity_apikey_only_rpcs.sql delete mode 100644 supabase/migrations/20260312202212_fix_rescind_invitation_rpc_access_hardening.sql delete mode 100644 supabase/migrations/20260312202227_fix_rbac_org_user_access_null_auth_gate.sql delete mode 100644 supabase/migrations/20260312202250_cli_created_record_build_time_public_revoke_fix.sql delete mode 100644 supabase/migrations/20260313104400_fix_get_current_plan_max_org_access_cli.sql delete mode 100644 supabase/migrations/20260313104427_webhook-api-key-org-scope-cli.sql delete mode 100644 supabase/migrations/20260313121928_fix-onboarding-needed-org-nonexistent.sql delete mode 100644 supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql delete mode 100644 supabase/migrations/20260316132841_move_mfa_email_otp_trigger_to_public.sql delete mode 100644 supabase/migrations/20260316220423_harden_plan_usage_org_rpc_access.sql delete mode 100644 supabase/migrations/20260317020451_secure_remaining_helper_rpcs.sql delete mode 100644 supabase/migrations/20260317020500_revoke_cleanup_expired_demo_apps_public_exec.sql delete mode 100644 supabase/migrations/20260317021715_fix_get_user_org_ids_apikey_expiry.sql delete mode 100644 supabase/migrations/20260317040310_restrict_manifest_read_access.sql delete mode 100644 supabase/migrations/20260317090000_fix_get_app_versions_rbac.sql delete mode 100644 supabase/migrations/20260317100429_fix_encrypted_bundle_update_enforcement.sql delete mode 100644 supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql delete mode 100644 supabase/migrations/20260318210857_fix_get_orgs_v7_private_overload_grants.sql delete mode 100644 supabase/migrations/20260318220337_optimize-org-metrics-cache-read-only.sql delete mode 100644 supabase/migrations/20260319090430_password_policy_max_length_72.sql delete mode 100644 supabase/migrations/20260319094649_add_build_minutes_to_global_stats.sql delete mode 100644 supabase/migrations/20260319103952_fix_subkey_header_and_plan_usage_rpcs.sql delete mode 100644 supabase/migrations/20260319155734_fix_global_stats_build_seconds_and_conversion_rate.sql delete mode 100644 supabase/migrations/20260319164053_fix_manifest_select_rls.sql delete mode 100644 supabase/migrations/20260319221428_onboarding_app_flags.sql delete mode 100644 supabase/migrations/20260319235626_disable_auto_org_on_user_create.sql delete mode 100644 supabase/migrations/20260320044548_add_org_website.sql delete mode 100644 supabase/migrations/20260320133752_app_demo_flag_cleanup.sql delete mode 100644 supabase/migrations/20260323075628_fix_rbac_admin_rpc_execute_grants.sql delete mode 100644 supabase/migrations/20260324181219_fix_process_cron_stats_activity.sql delete mode 100644 supabase/migrations/20260324181246_add_paid_at_for_admin_revenue_metrics.sql delete mode 100644 supabase/migrations/20260325032835_optimize_webhooks_rls_auth_eval.sql delete mode 100644 supabase/migrations/20260325043000_harden_cron_stats_queue_followup.sql delete mode 100644 supabase/migrations/20260325045835_split_channel_permission_overrides_write_policies.sql delete mode 100644 supabase/migrations/20260327044102_fix_cron_sync_sub_queue_payload.sql delete mode 100644 supabase/migrations/20260327210500_app_scoped_metrics_rbac.sql delete mode 100644 supabase/migrations/20260327220305_add_webhook_queues_to_cron_tasks.sql delete mode 100644 supabase/migrations/20260330141128_stripe_customer_country.sql delete mode 100644 supabase/migrations/20260408134842_adjust_build_time_credit_pricing.sql delete mode 100644 supabase/migrations/20260408140215_fix_org_metrics_cache_delete_cascade.sql delete mode 100644 supabase/migrations/20260422104849_stale_chart_refresh_state.sql delete mode 100644 supabase/migrations/20260422203355_add_admin_retention_metrics.sql delete mode 100644 supabase/migrations/20260424090111_fix_rbac_scope_mismatch_escalation.sql delete mode 100644 supabase/migrations/20260424090125_protect_owner_org_transfer_path.sql delete mode 100644 supabase/migrations/20260424090727_block_apikey_channel_updates.sql delete mode 100644 supabase/migrations/20260424090854_enforce_public_channel_uniqueness.sql delete mode 100644 supabase/migrations/20260424090941_fix_transfer_app_deploy_history_owner_org.sql delete mode 100644 supabase/migrations/20260424091645_enforce_hashed_api_keys_on_rls_identity_path.sql delete mode 100644 supabase/migrations/20260424094101_enforce_apikey_scope_in_rbac_check.sql delete mode 100644 supabase/migrations/20260424094225_harden_role_bindings_cross_org_scope.sql delete mode 100644 supabase/migrations/20260427092702_fix_transfer_app_guard_allowlist.sql delete mode 100644 supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql delete mode 100644 supabase/migrations/20260427105817_restrict_is_paying_and_good_plan_org_action_access.sql delete mode 100644 supabase/migrations/20260427105834_restrict_manifest_mutation_access.sql delete mode 100644 supabase/migrations/20260427105838_enforce_apikey_expiration_policy.sql delete mode 100644 supabase/migrations/20260427105909_fix_apikey_helper_rpc_public_execute.sql delete mode 100644 supabase/migrations/20260427110612_retention_metrics_service_role_rls.sql delete mode 100644 supabase/migrations/20260427142358_require_recent_email_otp_for_delete_user.sql delete mode 100644 supabase/migrations/20260427144300_rbac_apikey_bindings_priority.sql delete mode 100644 supabase/migrations/20260427144323_cli_rbac_permission_wrappers.sql delete mode 100644 supabase/migrations/20260427144324_add_org_create_app_permission.sql delete mode 100644 supabase/migrations/20260427144325_fix_helper_rpc_request_role_and_admin_grants.sql delete mode 100644 supabase/migrations/20260427144331_restore_rbac_apikey_mismatch_and_bindings_priority.sql delete mode 100644 supabase/migrations/20260427175506_temporary_cli_apps_list_anon_helper_grants.sql delete mode 100644 supabase/migrations/20260429094653_restore_deleted_account_recovery.sql delete mode 100644 supabase/migrations/20260429135552_enable_rbac_all_orgs.sql delete mode 100644 supabase/migrations/20260430145247_validate_org_security_settings.sql delete mode 100644 supabase/migrations/20260430145518_enforce_check_min_rights_app_org_scope.sql delete mode 100644 supabase/migrations/20260501162433_fix_storage_cleanup_counts.sql delete mode 100644 supabase/migrations/20260501200000_remove_sso_enabled_flag.sql delete mode 100644 supabase/migrations/20260502134045_fix_audit_logs_anon_dos.sql delete mode 100644 supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql delete mode 100644 supabase/migrations/20260502134355_fix_rbac_role_binding_demoted_super_admin.sql delete mode 100644 supabase/migrations/20260504174812_fix_build_time_daily_aggregation.sql delete mode 100644 supabase/migrations/20260505163356_apikey_nullable_mode_with_bindings.sql delete mode 100644 supabase/migrations/20260505193449_harden_encrypted_bundle_update_invariant.sql delete mode 100644 supabase/migrations/20260506101503_add_churn_revenue_plan_breakdown.sql delete mode 100644 supabase/migrations/20260506103727_add_plugin_version_ladder_to_global_stats.sql delete mode 100644 supabase/migrations/20260506152006_native_version_usage_chart.sql delete mode 100644 supabase/migrations/20260507082135_active_usage_credits_flag.sql delete mode 100644 supabase/migrations/20260507090047_fix_app_versions_anon_dos.sql delete mode 100644 supabase/migrations/20260507090436_fix_apikey_rbac_rpc_oracle_and_expiration_scope.sql delete mode 100644 supabase/migrations/20260507091347_secure_exist_app_versions_rpc.sql delete mode 100644 supabase/migrations/20260507153639_fast_app_versions_select_policy.sql delete mode 100644 supabase/migrations/20260507165636_fast_usage_credit_rls_policies.sql delete mode 100644 supabase/migrations/20260508122137_fix_app_versions_trigger_owner_org.sql delete mode 100644 supabase/migrations/20260508135918_enforce_channel_promotion_permission.sql delete mode 100644 supabase/migrations/20260510103516_stats_health_events_metadata.sql delete mode 100644 supabase/migrations/20260510161104_build_timeout_seconds.sql delete mode 100644 supabase/migrations/20260510171814_native_build_concurrency_plan_limit.sql delete mode 100644 supabase/migrations/20260510183000_add_build_runner_wait_seconds.sql delete mode 100644 supabase/migrations/20260510190432_fix_apikey_rbac_password_policy_gate.sql delete mode 100644 supabase/migrations/20260510191550_add_paid_product_activity_to_global_stats.sql delete mode 100644 supabase/migrations/20260510214140_org_initial_plan_solo_mau_limit.sql delete mode 100644 supabase/migrations/20260510214806_add_plan_conversion_rates_to_global_stats.sql delete mode 100644 supabase/migrations/20260510235542_add_plan_total_conversion_rate.sql delete mode 100644 supabase/migrations/20260511101826_add_ltv_global_stats.sql delete mode 100644 supabase/migrations/20260511151503_fix_get_organization_cli_warnings_rbac.sql delete mode 100644 supabase/migrations/20260513000348_add_audit_log_retention_cron.sql delete mode 100644 supabase/migrations/20260513152636_replace_manifest_cleanup_index.sql delete mode 100644 supabase/migrations/20260514093535_app_versions_r2_path_index.sql delete mode 100644 supabase/migrations/20260514102952_enforce_90_day_deleted_versions_cleanup.sql delete mode 100644 supabase/migrations/20260515170516_drop_redundant_channel_devices_unique_constraint.sql delete mode 100644 supabase/migrations/20260516151507_fix_cli_warnings_app_scoped_apikeys.sql delete mode 100644 supabase/migrations/20260517102815_enforce_webhook_created_by.sql delete mode 100644 supabase/migrations/20260518120000_add_ai_analyzed_to_build_requests.sql delete mode 100644 supabase/migrations/20260518121000_standard_webhook_secrets.sql delete mode 100644 supabase/migrations/20260518130000_plan_check_passthrough_appid.sql delete mode 100644 supabase/migrations/20260518131054_complete_onboarding_after_first_upload.sql delete mode 100644 supabase/migrations/20260519065534_revert_complete_onboarding_after_first_upload_trigger.sql delete mode 100644 supabase/migrations/20260519123613_safe_demo_data_reset.sql delete mode 100644 supabase/migrations/20260519151250_remove_builtin_unknown_app_versions.sql delete mode 100644 supabase/migrations/20260521210531_cron_hyperping_healthchecks.sql delete mode 100644 supabase/migrations/20260524123635_drop_channel_devices_owner_org_index.sql delete mode 100644 supabase/migrations/20260526133000_migrate_apikeys_to_v2_timestamp_fix.sql delete mode 100644 supabase/migrations/20260528002613_fix_apikey_v2_app_versions_rls_perf.sql delete mode 100644 supabase/migrations/20260528013304_fix_app_version_upload_policies.sql delete mode 100644 supabase/migrations/20260528023934_optimize_apikey_hashed_enforcement.sql delete mode 100644 supabase/migrations/20260528090340_manifest_index.sql delete mode 100644 supabase/migrations/20260528113224_missing_indexs.sql delete mode 100644 supabase/migrations/20260529075127_storage_hourly_shadow_billing.sql delete mode 100644 supabase/migrations/20260530083657_add_builder_onboarding_pref.sql delete mode 100644 supabase/migrations/20260530114525_add_bundle_incompatible_pref.sql delete mode 100644 supabase/migrations/20260531063221_get_org_apps_with_last_upload.sql delete mode 100644 supabase/migrations/20260601101710_allow_apikey_org_status_rpcs.sql delete mode 100644 supabase/migrations/20260603102048_add_orgs_onboarding.sql delete mode 100644 supabase/migrations/20260603113951_suppress_stats_refresh_audit_logs.sql delete mode 100644 supabase/migrations/20260603174942_restore_apikey_org_creation.sql delete mode 100644 supabase/migrations/20260605104908_compatibility_events.sql delete mode 100644 supabase/migrations/20260608094944_remove_stale_device_replication_trigger.sql delete mode 100644 supabase/migrations/20260608111805_compatibility_events_per_occurrence.sql create mode 100644 supabase/repair/20260608143906_pre_squash_repair.sql create mode 100644 supabase/repair/20260608143906_reverted_versions.txt diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index d17eaf877c..56eb35b575 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -96,6 +96,10 @@ jobs: run: supabase link --project-ref ${{ env.SUPABASE_PROJECT_ID }} env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_TOKEN }} + - name: Repair squashed Supabase migration history + run: bash scripts/repair-supabase-squashed-baseline.sh --linked + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_TOKEN }} - name: Apply Supabase Migrations run: supabase db push - name: Update functions diff --git a/scripts/check-supabase-migration-order.sh b/scripts/check-supabase-migration-order.sh index c3a2d5d3f9..06665d5983 100755 --- a/scripts/check-supabase-migration-order.sh +++ b/scripts/check-supabase-migration-order.sh @@ -14,6 +14,17 @@ extract_timestamp() { fi } +count_nonempty_lines() { + local value="$1" + + if [[ -z "$value" ]]; then + echo 0 + return + fi + + printf '%s\n' "$value" | awk 'NF { count++ } END { print count + 0 }' +} + resolve_target_branch() { if [[ -n "${GITHUB_BASE_REF:-}" ]]; then echo "${GITHUB_BASE_REF}" @@ -86,6 +97,8 @@ if [[ -s "${base_timestamps_file}" ]]; then fi status=0 +current_migration_files="$(find supabase/migrations -maxdepth 1 -type f -name '*.sql' | sort)" +current_migration_count="$(count_nonempty_lines "$current_migration_files")" modified_files="$(git diff --name-only --diff-filter=MR "${base_ref}...HEAD" -- 'supabase/migrations/*.sql')" if [[ -n "$modified_files" ]]; then @@ -114,18 +127,54 @@ if [[ -n "$modified_files" ]]; then fi fi +added_files="$(git diff --name-only --diff-filter=A "${base_ref}...HEAD" -- 'supabase/migrations/*.sql')" deleted_files="$(git diff --name-only --diff-filter=D "${base_ref}...HEAD" -- 'supabase/migrations/*.sql')" if [[ -n "$deleted_files" ]]; then - echo '❌ Existing Supabase migrations were deleted in this change.' - echo ' Supabase migrations must remain append-only.' - while IFS= read -r file; do - [[ -z "$file" ]] && continue - echo " - $file" - done <<< "$deleted_files" - status=1 + allow_full_squash=0 + remaining_migration_file='' + remaining_migration_rewritten=0 + + if [[ -z "$added_files" && "$current_migration_count" == '1' ]]; then + remaining_migration_file="$current_migration_files" + remaining_timestamp="$(extract_timestamp "$remaining_migration_file" || true)" + if ! git diff --quiet "${base_ref}...HEAD" -- "$remaining_migration_file"; then + remaining_migration_rewritten=1 + fi + + if [[ "$remaining_migration_rewritten" == '1' && -n "$remaining_timestamp" && "$remaining_timestamp" == "$latest_base_timestamp" ]]; then + deleted_latest_or_newer_files='' + + while IFS= read -r file; do + [[ -z "$file" ]] && continue + + ts="$(extract_timestamp "$file" || true)" + if [[ -z "$ts" || "$ts" == "$latest_base_timestamp" || 10#$ts > 10#$latest_base_timestamp ]]; then + deleted_latest_or_newer_files+="${file}"$'\n' + fi + done <<< "$deleted_files" + + if [[ -z "$deleted_latest_or_newer_files" ]]; then + allow_full_squash=1 + fi + fi + fi + + if [[ "$allow_full_squash" == '1' ]]; then + echo "⚠️ Allowing intentional Supabase migration squash into baseline: ${remaining_migration_file}" + else + echo '❌ Existing Supabase migrations were deleted in this change.' + echo ' Supabase migrations must remain append-only except for a full baseline squash.' + if [[ -n "$remaining_migration_file" && "$remaining_migration_rewritten" != '1' ]]; then + echo " The remaining migration was not rewritten: ${remaining_migration_file}" + fi + while IFS= read -r file; do + [[ -z "$file" ]] && continue + echo " - $file" + done <<< "$deleted_files" + status=1 + fi fi -added_files="$(git diff --name-only --diff-filter=A "${base_ref}...HEAD" -- 'supabase/migrations/*.sql')" if [[ -n "$added_files" ]]; then : > "${added_timestamps_file}" diff --git a/scripts/repair-supabase-squashed-baseline.sh b/scripts/repair-supabase-squashed-baseline.sh new file mode 100755 index 0000000000..a1506b5d1a --- /dev/null +++ b/scripts/repair-supabase-squashed-baseline.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash + +set -euo pipefail + +baseline_version='20260608143906' +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +repair_sql="${repo_root}/supabase/repair/${baseline_version}_pre_squash_repair.sql" +reverted_versions_file="${repo_root}/supabase/repair/${baseline_version}_reverted_versions.txt" + +usage() { + cat <<'USAGE' +Usage: + bash scripts/repair-supabase-squashed-baseline.sh --linked + bash scripts/repair-supabase-squashed-baseline.sh --local + bash scripts/repair-supabase-squashed-baseline.sh --db-url "$SUPABASE_DB_URL" + +If needed, applies the final pre-squash migration, marks deleted historical +migration rows as reverted, and marks the squashed baseline version as applied. + +Set SUPABASE_WORKDIR to pass a custom Supabase --workdir, for example when +validating against this repo's worktree-isolated local Supabase stack. +USAGE +} + +if [[ $# -lt 1 ]]; then + usage + exit 1 +fi + +target_args=() +reverted_versions=() +pre_squash_history_output='' +baseline_applied=false +case "$1" in + --linked|--local) + target_args=("$1") + shift + ;; + --db-url) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + usage + exit 1 + fi + target_args=("--db-url" "$2") + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + exit 1 + ;; +esac + +if [[ $# -ne 0 ]]; then + usage + exit 1 +fi + +while IFS= read -r version || [[ -n "$version" ]]; do + [[ -z "$version" ]] && continue + [[ "$version" =~ ^# ]] && continue + + if [[ ! "$version" =~ ^[0-9]{14}$ ]]; then + echo "Invalid migration version in ${reverted_versions_file}: ${version}" >&2 + exit 1 + fi + + reverted_versions+=("$version") +done < "$reverted_versions_file" + +if [[ "${#reverted_versions[@]}" -eq 0 ]]; then + echo "No reverted migration versions found in ${reverted_versions_file}." >&2 + exit 1 +fi + +run_supabase() { + if command -v supabase >/dev/null 2>&1; then + if [[ -n "${SUPABASE_WORKDIR:-}" ]]; then + supabase "$@" --workdir "$SUPABASE_WORKDIR" + else + supabase "$@" + fi + return + fi + + if [[ -n "${SUPABASE_WORKDIR:-}" ]]; then + bunx supabase "$@" --workdir "$SUPABASE_WORKDIR" + else + bunx supabase "$@" + fi +} + +repair_versions() { + local status="$1" + shift + + if [[ "$#" -eq 0 ]]; then + return + fi + + run_supabase migration repair "${target_args[@]}" --status "$status" "$@" +} + +baseline_is_applied() { + local applied_output + if ! applied_output="$(run_supabase db query "${target_args[@]}" -o json "select exists(select 1 from supabase_migrations.schema_migrations where version = '${baseline_version}') as applied;")"; then + echo "Could not read Supabase migration history for ${baseline_version}." >&2 + exit 1 + fi + + grep -Eq '"applied"[[:space:]]*:[[:space:]]*true' <<< "$applied_output" +} + +has_migration_history_table() { + local history_table_output + if ! history_table_output="$(run_supabase db query "${target_args[@]}" -o json "select to_regclass('supabase_migrations.schema_migrations') is not null as has_history_table;")"; then + echo "Could not inspect Supabase migration history table." >&2 + exit 1 + fi + + grep -Eq '"has_history_table"[[:space:]]*:[[:space:]]*true' <<< "$history_table_output" +} + +has_existing_capgo_schema() { + local schema_output + if ! schema_output="$(run_supabase db query "${target_args[@]}" -o json "select to_regclass('public.apps') is not null as has_capgo_schema;")"; then + echo "Could not inspect existing Capgo schema." >&2 + exit 1 + fi + + grep -Eq '"has_capgo_schema"[[:space:]]*:[[:space:]]*true' <<< "$schema_output" +} + +has_pre_squash_history() { + grep -Eq '"has_old_history"[[:space:]]*:[[:space:]]*true' <<< "$pre_squash_history_output" +} + +has_complete_pre_squash_history() { + grep -Eq '"has_complete_old_history"[[:space:]]*:[[:space:]]*true' <<< "$pre_squash_history_output" +} + +load_pre_squash_history() { + local versions_sql + printf -v versions_sql "'%s'," "${reverted_versions[@]}" + versions_sql="${versions_sql%,}" + + if ! pre_squash_history_output="$(run_supabase db query "${target_args[@]}" -o json "select count(*) > 0 as has_old_history, count(*) = ${#reverted_versions[@]} as has_complete_old_history from supabase_migrations.schema_migrations where version in (${versions_sql});")"; then + echo "Could not inspect deleted Supabase migration history rows." >&2 + exit 1 + fi +} + +if ! has_migration_history_table; then + echo "Supabase migration history table does not exist; skipping squash repair for a fresh database." + exit 0 +fi + +load_pre_squash_history +if baseline_is_applied; then + baseline_applied=true +fi + +if ! has_pre_squash_history; then + if [[ "$baseline_applied" == true ]]; then + echo "No deleted pre-squash migration history found and squashed baseline is already applied; skipping squash repair." + exit 0 + fi + + if has_existing_capgo_schema; then + echo "Existing Capgo schema found, but deleted pre-squash migration history and squashed baseline marker are both missing. Aborting to avoid applying the squashed baseline to an existing database." >&2 + exit 1 + fi + + echo "No deleted pre-squash migration history found; skipping squash repair for a fresh database." + exit 0 +fi + +if [[ "$baseline_applied" != true ]] && ! has_complete_pre_squash_history; then + final_pre_squash_version="${reverted_versions[$((${#reverted_versions[@]} - 1))]}" + echo "Deleted pre-squash migration history is incomplete; expected ${#reverted_versions[@]} versions through ${final_pre_squash_version}. Aborting to avoid marking a partial database as squashed." >&2 + exit 1 +fi + +if [[ "$baseline_applied" == true ]]; then + echo "Squashed baseline ${baseline_version} is already marked applied; skipping schema repair SQL." +else + run_supabase db query "${target_args[@]}" --file "$repair_sql" +fi + +repair_versions applied "$baseline_version" + +chunk=() +for version in "${reverted_versions[@]}"; do + chunk+=("$version") + if [[ "${#chunk[@]}" -ge 50 ]]; then + repair_versions reverted "${chunk[@]}" + chunk=() + fi +done + +repair_versions reverted "${chunk[@]}" diff --git a/scripts/supabase-worktree.ts b/scripts/supabase-worktree.ts index 6a397032fc..f060d76d93 100644 --- a/scripts/supabase-worktree.ts +++ b/scripts/supabase-worktree.ts @@ -128,7 +128,7 @@ function ensureWorktreeSupabaseDir(repoRoot: string): { workdir: string, cfg: Re // Symlink everything except config.toml so we can safely rewrite ports + project_id per worktree. const repoSupaDir = resolve(cfg.repoRoot, 'supabase') const repoTemplatesDir = resolve(repoSupaDir, 'templates') - for (const entry of ['functions', 'migrations', 'schemas', 'tests', 'seed.sql', 'migration_guide.md', '.gitignore']) { + for (const entry of ['functions', 'migrations', 'repair', 'schemas', 'tests', 'seed.sql', 'migration_guide.md', '.gitignore']) { const src = resolve(repoSupaDir, entry) if (!existsSync(src)) continue diff --git a/supabase/migration_guide.md b/supabase/migration_guide.md index 32aeb493ea..a9e35c7153 100644 --- a/supabase/migration_guide.md +++ b/supabase/migration_guide.md @@ -71,3 +71,42 @@ This command will clear all data and revert schema changes made to the local dat By following these steps, you can safely add and deploy Supabase migration changes to your project's database schema. + +### Squashed Baseline Repair + +The `20260608143906` migration is a squashed schema baseline. Fresh databases +should apply it normally. Existing databases that already applied the deleted +historical migrations must not run that baseline against an existing schema. + +If an existing database has the old migration history, run this repair flow +before the normal deployment that contains the squashed migration. Run it even +when `20260608143906` is already marked as applied so the deleted historical +versions are removed from the migration history: + +```bash +bash scripts/repair-supabase-squashed-baseline.sh --linked +``` + +For a database reached by connection string instead of a linked project, replace +`--linked` with `--db-url "$SUPABASE_DB_URL"`. + +On a database that already has `20260608143906` marked as applied, this command +does not apply schema SQL. It only removes the deleted historical versions from +the migration history and keeps `20260608143906` marked as applied. + +On a fresh database, or on a database that no longer has any deleted pre-squash +migration history rows, the script exits without changing schema or migration +history. The normal `supabase db push` flow then applies the squashed baseline +for fresh projects. + +If the script finds only part of the deleted pre-squash migration history, it +aborts instead of marking the squashed baseline as applied. That protects +databases that are not yet at the final pre-squash schema from skipping the +baseline by mistake. + +The repair marks the squashed baseline as applied before deleting old migration +history rows. If a retry ever sees an existing Capgo schema with both the old +history rows and the squashed baseline marker missing, it aborts instead of +letting `supabase db push` apply the baseline to that existing schema. +If the baseline marker is present and some old rows remain, rerunning the script +continues removing the remaining old rows. diff --git a/supabase/migrations/20250530233128_base.sql b/supabase/migrations/20250530233128_base.sql deleted file mode 100644 index 406c078fb7..0000000000 --- a/supabase/migrations/20250530233128_base.sql +++ /dev/null @@ -1,7220 +0,0 @@ -SET - statement_timeout = 0; - -SET - lock_timeout = 0; - -SET - idle_in_transaction_session_timeout = 0; - -SET - client_encoding = 'UTF8'; - -SET - standard_conforming_strings = on; - -SELECT - pg_catalog.set_config ('search_path', '', false); - -SET - check_function_bodies = false; - -SET - xmloption = content; - -SET - client_min_messages = warning; - -SET - row_security = off; - -CREATE EXTENSION IF NOT EXISTS "pg_cron" -WITH - SCHEMA "pg_catalog"; - -CREATE EXTENSION IF NOT EXISTS "pg_net" -WITH - SCHEMA "extensions"; - -ALTER SCHEMA "public" OWNER TO "postgres"; - -COMMENT ON SCHEMA "public" IS 'standard public schema'; - -CREATE EXTENSION IF NOT EXISTS "http" -WITH - SCHEMA "extensions"; - -CREATE EXTENSION IF NOT EXISTS "moddatetime" -WITH - SCHEMA "extensions"; - -DROP EXTENSION IF EXISTS "pg_graphql"; - -DROP EXTENSION IF EXISTS "pg_stat_monitor"; - -CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" -WITH - SCHEMA "extensions"; - -CREATE EXTENSION IF NOT EXISTS "pgcrypto" -WITH - SCHEMA "extensions"; - -CREATE SCHEMA IF NOT EXISTS "pgmq"; - -CREATE EXTENSION IF NOT EXISTS "pgmq" -WITH - SCHEMA "pgmq"; - -CREATE EXTENSION IF NOT EXISTS "hypopg" -WITH - SCHEMA "extensions"; - -CREATE EXTENSION IF NOT EXISTS "plpgsql_check" -WITH - SCHEMA "extensions"; - -DROP EXTENSION IF EXISTS "postgres_fdw"; - -CREATE EXTENSION IF NOT EXISTS "supabase_vault" -WITH - SCHEMA "vault"; - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp" -WITH - SCHEMA "extensions"; - -CREATE TYPE "public"."action_type" AS ENUM('mau', 'storage', 'bandwidth'); - -ALTER TYPE "public"."action_type" OWNER TO "postgres"; - -CREATE TYPE "public"."disable_update" AS ENUM( - 'major', - 'minor', - 'patch', - 'version_number', - 'none' -); - -ALTER TYPE "public"."disable_update" OWNER TO "postgres"; - -CREATE TYPE "public"."key_mode" AS ENUM('read', 'write', 'all', 'upload'); - -ALTER TYPE "public"."key_mode" OWNER TO "postgres"; - -CREATE TYPE "public"."manifest_entry" AS ( - "file_name" character varying, - "s3_path" character varying, - "file_hash" character varying -); - -ALTER TYPE "public"."manifest_entry" OWNER TO "postgres"; - -CREATE TYPE "public"."orgs_table" AS ( - "id" "uuid", - "created_by" "uuid", - "created_at" timestamp with time zone, - "updated_at" timestamp with time zone, - "logo" "text", - "name" "text" -); - -ALTER TYPE "public"."orgs_table" OWNER TO "postgres"; - -CREATE TYPE "public"."owned_orgs" AS ( - "id" "uuid", - "created_by" "uuid", - "logo" "text", - "name" "text", - "role" character varying -); - -ALTER TYPE "public"."owned_orgs" OWNER TO "postgres"; - -CREATE TYPE "public"."platform_os" AS ENUM('ios', 'android'); - -ALTER TYPE "public"."platform_os" OWNER TO "postgres"; - -CREATE TYPE "public"."stats_action" AS ENUM( - 'delete', - 'reset', - 'set', - 'get', - 'set_fail', - 'update_fail', - 'download_fail', - 'windows_path_fail', - 'canonical_path_fail', - 'directory_path_fail', - 'unzip_fail', - 'low_mem_fail', - 'download_10', - 'download_20', - 'download_30', - 'download_40', - 'download_50', - 'download_60', - 'download_70', - 'download_80', - 'download_90', - 'download_complete', - 'decrypt_fail', - 'app_moved_to_foreground', - 'app_moved_to_background', - 'uninstall', - 'needPlanUpgrade', - 'missingBundle', - 'noNew', - 'disablePlatformIos', - 'disablePlatformAndroid', - 'disableAutoUpdateToMajor', - 'cannotUpdateViaPrivateChannel', - 'disableAutoUpdateToMinor', - 'disableAutoUpdateToPatch', - 'channelMisconfigured', - 'disableAutoUpdateMetadata', - 'disableAutoUpdateUnderNative', - 'disableDevBuild', - 'disableProdBuild', - 'disableEmulator', - 'disableDevice', - 'cannotGetBundle', - 'checksum_fail', - 'NoChannelOrOverride', - 'setChannel', - 'getChannel', - 'rateLimited', - 'disableAutoUpdate', - 'keyMismatch', - 'ping', - 'InvalidIp', - 'blocked_by_server_url', - 'download_manifest_start', - 'download_manifest_complete', - 'download_zip_start', - 'download_zip_complete', - 'download_manifest_file_fail', - 'download_manifest_checksum_fail', - 'download_manifest_brotli_fail', - 'backend_refusal', - 'download_0' -); - -ALTER TYPE "public"."stats_action" OWNER TO "postgres"; - -CREATE TYPE "public"."stats_table" AS ( - "mau" bigint, - "bandwidth" bigint, - "storage" bigint -); - -ALTER TYPE "public"."stats_table" OWNER TO "postgres"; - -CREATE TYPE "public"."stripe_status" AS ENUM( - 'created', - 'succeeded', - 'updated', - 'failed', - 'deleted', - 'canceled' -); - -ALTER TYPE "public"."stripe_status" OWNER TO "postgres"; - -CREATE TYPE "public"."user_min_right" AS ENUM( - 'invite_read', - 'invite_upload', - 'invite_write', - 'invite_admin', - 'invite_super_admin', - 'read', - 'upload', - 'write', - 'admin', - 'super_admin' -); - -ALTER TYPE "public"."user_min_right" OWNER TO "postgres"; - -CREATE TYPE "public"."user_role" AS ENUM('read', 'upload', 'write', 'admin'); - -ALTER TYPE "public"."user_role" OWNER TO "postgres"; - -CREATE TYPE "public"."version_action" AS ENUM('get', 'fail', 'install', 'uninstall'); - -ALTER TYPE "public"."version_action" OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."accept_invitation_to_org" ("org_id" "uuid") RETURNS character varying LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - invite record; -BEGIN - SELECT org_users.* FROM public.org_users - INTO invite - WHERE org_users.org_id=accept_invitation_to_org.org_id AND (SELECT auth.uid())=org_users.user_id; - - IF invite IS NULL THEN - RETURN 'NO_INVITE'; - else - IF NOT (invite.user_right::varchar ilike 'invite_'||'%') THEN - RETURN 'INVALID_ROLE'; - END IF; - - UPDATE public.org_users - SET user_right = REPLACE(invite.user_right::varchar, 'invite_', '')::"public"."user_min_right" - WHERE org_users.id=invite.id; - - RETURN 'OK'; - end if; -END; -$$; - -ALTER FUNCTION "public"."accept_invitation_to_org" ("org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."auto_apikey_name_by_id" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' AS $$BEGIN - - IF (NEW.name IS NOT DISTINCT FROM NULL) OR LENGTH(NEW.name) = 0 THEN - NEW.name = format('Apikey %s', NEW.id); - END IF; - - RETURN NEW; -END;$$; - -ALTER FUNCTION "public"."auto_apikey_name_by_id" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."auto_owner_org_by_app_id" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' AS $$BEGIN - IF NEW."app_id" IS DISTINCT FROM OLD."app_id" AND OLD."app_id" IS DISTINCT FROM NULL THEN - RAISE EXCEPTION 'changing the app_id is not allowed'; - END IF; - - NEW.owner_org = public.get_user_main_org_id_by_app_id(NEW."app_id"); - - RETURN NEW; -END;$$; - -ALTER FUNCTION "public"."auto_owner_org_by_app_id" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."check_if_org_can_exist" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - DELETE FROM public.orgs - WHERE - ( - ( - SELECT - count(*) - FROM - public.org_users - WHERE - org_users.user_right = 'super_admin' - AND org_users.user_id != OLD.user_id - AND org_users.org_id=orgs.id - ) = 0 - ) - AND orgs.id=OLD.org_id; - - RETURN OLD; -END;$$; - -ALTER FUNCTION "public"."check_if_org_can_exist" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN check_min_rights(min_right, (SELECT auth.uid()), org_id, app_id, channel_id); -END; -$$; - -ALTER FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - user_right_record RECORD; -BEGIN - IF user_id = NULL THEN - RETURN false; - END IF; - - FOR user_right_record IN - SELECT org_users.user_right, org_users.app_id, org_users.channel_id - FROM public.org_users - WHERE org_users.org_id = check_min_rights.org_id AND org_users.user_id = check_min_rights.user_id - LOOP - IF (user_right_record.user_right >= min_right AND user_right_record.app_id IS NULL AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights.app_id AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights.app_id AND user_right_record.channel_id = check_min_rights.channel_id) - THEN - RETURN true; - END IF; - END LOOP; - - RETURN false; -END; -$$; - -ALTER FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."check_org_user_privileges" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' AS $$BEGIN - IF (SELECT current_user) IS NOT DISTINCT FROM 'postgres' THEN - RETURN NEW; - END IF; - - IF ("public"."check_min_rights"('super_admin'::"public"."user_min_right", (SELECT auth.uid()), NEW.org_id, NULL::character varying, NULL::bigint)) - THEN - RETURN NEW; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'super_admin'::"public"."user_min_right" - THEN - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'invite_super_admin'::"public"."user_min_right" - THEN - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - RETURN NEW; -END;$$; - -ALTER FUNCTION "public"."check_org_user_privileges" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."check_revert_to_builtin_version" ("appid" character varying) RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - DECLARE - version_id INTEGER; - BEGIN - SELECT id - INTO version_id - FROM public.app_versions - WHERE name = 'builtin' - AND app_id = appid; - - IF NOT FOUND THEN - INSERT INTO app_versions(name, app_id, storage_provider) - VALUES ('builtin', appid, 'r2') - RETURNING id INTO version_id; - END IF; - - RETURN version_id; - END; -END; -$$; - -ALTER FUNCTION "public"."check_revert_to_builtin_version" ("appid" character varying) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."cleanup_frequent_job_details" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - DELETE FROM cron.job_run_details - WHERE job_pid IN ( - SELECT jobid - FROM cron.job - WHERE schedule = '5 seconds' OR schedule = '1 seconds' OR schedule = '10 seconds' - ) - AND end_time < NOW() - interval '1 hour'; -END; -$$; - -ALTER FUNCTION "public"."cleanup_frequent_job_details" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."cleanup_queue_messages" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $_$ -DECLARE - queue_name text; -BEGIN - -- Clean up messages older than 7 days FROM all queues - FOR queue_name IN ( - SELECT q.queue_name FROM pgmq.list_queues() q - ) LOOP - -- Delete archived messages older than 7 days - EXECUTE format('DELETE FROM pgmq.a_%I WHERE archived_at < $1', queue_name) - USING (NOW() - INTERVAL '7 days')::timestamptz; - - -- Delete failed messages that have been retried more than 5 times - EXECUTE format('DELETE FROM pgmq.q_%I WHERE read_ct > 5', queue_name); - END LOOP; -END; -$_$; - -ALTER FUNCTION "public"."cleanup_queue_messages" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."convert_bytes_to_gb" ("bytes_value" double precision) RETURNS double precision LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN bytes_value / 1024.0 / 1024.0 / 1024.0; -END; -$$; - -ALTER FUNCTION "public"."convert_bytes_to_gb" ("bytes_value" double precision) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."convert_bytes_to_mb" ("bytes_value" double precision) RETURNS double precision LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN bytes_value / 1024.0 / 1024.0; -END; -$$; - -ALTER FUNCTION "public"."convert_bytes_to_mb" ("bytes_value" double precision) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."convert_gb_to_bytes" ("gb" double precision) RETURNS double precision LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN gb * 1024 * 1024 * 1024; -END; -$$; - -ALTER FUNCTION "public"."convert_gb_to_bytes" ("gb" double precision) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."convert_mb_to_bytes" ("gb" double precision) RETURNS double precision LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN gb * 1024 * 1024; -END; -$$; - -ALTER FUNCTION "public"."convert_mb_to_bytes" ("gb" double precision) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."convert_number_to_percent" ( - "val" double precision, - "max_val" double precision -) RETURNS double precision LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - percentage numeric; -BEGIN - IF max_val = 0 THEN - RETURN 0; - ELSE - percentage := ((val * 100) / max_val)::numeric; - -- Add small epsilon for positive values to handle floating-point errors - -- Subtract epsilon for negative values - IF percentage >= 0 THEN - RETURN trunc(percentage + 0.0001, 0); - ELSE - RETURN trunc(percentage - 0.0001, 0); - END IF; - END IF; -END; -$$; - -ALTER FUNCTION "public"."convert_number_to_percent" ( - "val" double precision, - "max_val" double precision -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."count_active_users" ("app_ids" character varying[]) RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN ( - SELECT COUNT(DISTINCT user_id) - FROM public.apps - WHERE app_id = ANY(app_ids) - ); -END; -$$; - -ALTER FUNCTION "public"."count_active_users" ("app_ids" character varying[]) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."count_all_need_upgrade" () RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN (SELECT COUNT(*) FROM public.stripe_info WHERE is_good_plan = false AND status = 'succeeded'); -END; -$$; - -ALTER FUNCTION "public"."count_all_need_upgrade" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."count_all_onboarded" () RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN (SELECT COUNT(DISTINCT owner_org) FROM public.apps); -END; -$$; - -ALTER FUNCTION "public"."count_all_onboarded" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."count_all_plans_v2" () RETURNS TABLE ("plan_name" character varying, "count" bigint) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH ActiveSubscriptions AS ( - SELECT DISTINCT ON (si.customer_id) - p.name AS product_name, - si.customer_id - FROM public.stripe_info si - INNER JOIN public.plans p ON si.product_id = p.stripe_id - WHERE si.status = 'succeeded' - ORDER BY si.customer_id, si.created_at DESC - ), - TrialUsers AS ( - SELECT DISTINCT ON (si.customer_id) - 'Trial' AS product_name, - si.customer_id - FROM public.stripe_info si - WHERE si.trial_at > NOW() - AND si.status is NULL - AND NOT EXISTS ( - SELECT 1 FROM ActiveSubscriptions a - WHERE a.customer_id = si.customer_id - ) - ) - SELECT - product_name as plan_name, - COUNT(*) as count - FROM ( - SELECT product_name, customer_id FROM ActiveSubscriptions - UNION ALL - SELECT product_name, customer_id FROM TrialUsers - ) all_subs - GROUP BY product_name; -END; -$$; - -ALTER FUNCTION "public"."count_all_plans_v2" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."delete_http_response" ("request_id" bigint) RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - DELETE FROM net._http_response - WHERE id = request_id; -END; -$$; - -ALTER FUNCTION "public"."delete_http_response" ("request_id" bigint) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."delete_old_deleted_apps" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - DELETE FROM "public"."deleted_apps" - WHERE deleted_at < NOW() - INTERVAL '35 days'; -END; -$$; - -ALTER FUNCTION "public"."delete_old_deleted_apps" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - user_id uuid; - user_email text; - hashed_email text; -BEGIN - -- Get the current user ID and email - SELECT auth.uid() INTO user_id; - SELECT email INTO user_email FROM auth.users WHERE id = user_id; - - -- Hash the email and store it in deleted_account table - hashed_email := encode(extensions.digest(user_email::text, 'sha256'::text), 'hex'::text); - - INSERT INTO public.deleted_account (email) - VALUES (hashed_email); - - -- Trigger the queue-based deletion process - PERFORM pgmq.send( - 'on_user_delete'::text, - jsonb_build_object( - 'user_id', user_id, - 'email', user_email - ) - ); - - -- Delete the user from auth.users - -- This will cascade to other tables due to foreign key constraints - DELETE FROM auth.users WHERE id = user_id; -END; -$$; - -ALTER FUNCTION "public"."delete_user" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."exist_app_v2" ("appid" character varying) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.apps - WHERE app_id=appid)); -END; -$$; - -ALTER FUNCTION "public"."exist_app_v2" ("appid" character varying) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.app_versions - WHERE app_id=appid - AND name=name_version)); -END; -$$; - -ALTER FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."find_best_plan_v3" ( - "mau" bigint, - "bandwidth" double precision, - "storage" double precision -) RETURNS character varying LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT name - FROM public.plans - WHERE plans.mau>=find_best_plan_v3.mau - AND plans.storage>=find_best_plan_v3.storage - AND plans.bandwidth>=find_best_plan_v3.bandwidth - OR plans.name = 'Enterprise' - ORDER BY plans.mau - LIMIT 1); -END; -$$; - -ALTER FUNCTION "public"."find_best_plan_v3" ( - "mau" bigint, - "bandwidth" double precision, - "storage" double precision -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."find_fit_plan_v3" ( - "mau" bigint, - "bandwidth" bigint, - "storage" bigint -) RETURNS TABLE ("name" character varying) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - -RETURN QUERY ( - SELECT plans.name - FROM public.plans - WHERE plans.mau >= find_fit_plan_v3.mau - AND plans.storage >= find_fit_plan_v3.storage - AND plans.bandwidth >= find_fit_plan_v3.bandwidth - OR plans.name = 'Enterprise' - ORDER BY plans.mau -); -END; -$$; - -ALTER FUNCTION "public"."find_fit_plan_v3" ( - "mau" bigint, - "bandwidth" bigint, - "storage" bigint -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."force_valid_user_id_on_app" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' AS $$BEGIN - NEW.user_id = (SELECT created_by FROM public.orgs WHERE id = (NEW."owner_org")); - - RETURN NEW; -END;$$; - -ALTER FUNCTION "public"."force_valid_user_id_on_app" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."generate_org_on_user_create" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_record record; -BEGIN - -- Add management_email compared to old fn - INSERT INTO public.orgs (created_by, name, management_email) values (NEW.id, format('%s organization', NEW.first_name), NEW.email) RETURNING * INTO org_record; - -- we no longer insert INTO org_users here. There is a new trigger on "orgs" - -- INSERT INTO public.org_users (user_id, org_id, user_right) values (NEW.id, org_record.id, 'super_admin'::"user_min_right"); - - RETURN NEW; -END $$; - -ALTER FUNCTION "public"."generate_org_on_user_create" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."generate_org_user_on_org_create" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_record record; -BEGIN - INSERT INTO public.org_users (user_id, org_id, user_right) values (NEW.created_by, NEW.id, 'super_admin'::"public"."user_min_right"); - RETURN NEW; -END $$; - -ALTER FUNCTION "public"."generate_org_user_on_org_create" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_apikey" () RETURNS "text" LANGUAGE "plpgsql" -SET - search_path = '' STABLE SECURITY DEFINER PARALLEL SAFE AS $$ -BEGIN - RETURN (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name='apikey'); -END; -$$; - -ALTER FUNCTION "public"."get_apikey" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_app_metrics" ("org_id" "uuid") RETURNS TABLE ( - "app_id" character varying, - "date" "date", - "mau" bigint, - "storage" bigint, - "bandwidth" bigint, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - cycle_start timestamp with time zone; - cycle_end timestamp with time zone; -BEGIN - SELECT subscription_anchor_start, subscription_anchor_end - INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - - RETURN QUERY - SELECT * FROM public.get_app_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$$; - -ALTER FUNCTION "public"."get_app_metrics" ("org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_app_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) RETURNS TABLE ( - "app_id" character varying, - "date" "date", - "mau" bigint, - "storage" bigint, - "bandwidth" bigint, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN QUERY - WITH DateSeries AS ( - SELECT generate_series(start_date, end_date, '1 day'::interval)::date AS "date" - ), - all_apps AS ( - -- Get active apps - SELECT apps.app_id, apps.owner_org - FROM public.apps - WHERE apps.owner_org = org_id - UNION - -- Get deleted apps - SELECT deleted_apps.app_id, deleted_apps.owner_org - FROM public.deleted_apps - WHERE deleted_apps.owner_org = org_id - ), - deleted_metrics AS ( - SELECT - deleted_apps.app_id, - deleted_apps.deleted_at::date as date, - COUNT(*) as deleted_count - FROM public.deleted_apps - WHERE deleted_apps.owner_org = org_id - AND deleted_apps.deleted_at::date BETWEEN start_date AND end_date - GROUP BY deleted_apps.app_id, deleted_apps.deleted_at::date - ) - SELECT - aa.app_id, - ds.date::date, - COALESCE(dm.mau, 0) AS mau, - COALESCE(dst.storage, 0) AS storage, - COALESCE(db.bandwidth, 0) AS bandwidth, - COALESCE(SUM(dv.get)::bigint, 0) AS get, - COALESCE(SUM(dv.fail)::bigint, 0) AS fail, - COALESCE(SUM(dv.install)::bigint, 0) AS install, - COALESCE(SUM(dv.uninstall)::bigint, 0) AS uninstall - FROM - all_apps aa - CROSS JOIN - DateSeries ds - LEFT JOIN - public.daily_mau dm ON aa.app_id = dm.app_id AND ds.date = dm.date - LEFT JOIN - public.daily_storage dst ON aa.app_id = dst.app_id AND ds.date = dst.date - LEFT JOIN - public.daily_bandwidth db ON aa.app_id = db.app_id AND ds.date = db.date - LEFT JOIN - public.daily_version dv ON aa.app_id = dv.app_id AND ds.date = dv.date - LEFT JOIN - deleted_metrics del ON aa.app_id = del.app_id AND ds.date = del.date - GROUP BY - aa.app_id, ds.date, dm.mau, dst.storage, db.bandwidth, del.deleted_count - ORDER BY - aa.app_id, ds.date; -END; -$$; - -ALTER FUNCTION "public"."get_app_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT id - FROM public.app_versions - WHERE app_id=appid - AND name=name_version - AND owner_org=(SELECT public.get_user_main_org_id_by_app_id(appid)) - AND public.is_member_of_org(public.get_user_id(apikey), (SELECT public.get_user_main_org_id_by_app_id(appid))) - ); -END; -$$; - -ALTER FUNCTION "public"."get_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_current_plan_max_org" ("orgid" "uuid") RETURNS TABLE ( - "mau" bigint, - "bandwidth" bigint, - "storage" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN QUERY - (SELECT plans.mau, plans.bandwidth, plans.storage - FROM public.plans - WHERE stripe_id=( - SELECT product_id - FROM public.stripe_info - WHERE customer_id=( - SELECT customer_id - FROM public.orgs - WHERE id=orgid) - )); -END; -$$; - -ALTER FUNCTION "public"."get_current_plan_max_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_current_plan_name_org" ("orgid" "uuid") RETURNS character varying LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN - (SELECT name - FROM public.plans - WHERE stripe_id=(SELECT product_id - FROM public.stripe_info - WHERE customer_id=(SELECT customer_id FROM public.orgs WHERE id=orgid) - )); -END; -$$; - -ALTER FUNCTION "public"."get_current_plan_name_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_customer_counts" () RETURNS TABLE ("yearly" bigint, "monthly" bigint, "total" bigint) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH ActiveSubscriptions AS ( - -- Get the most recent subscription for each customer - SELECT DISTINCT ON (customer_id) - customer_id, - price_id, - status, - trial_at - FROM public.stripe_info - WHERE status = 'succeeded' - ORDER BY customer_id, created_at DESC - ) - SELECT - COUNT(CASE - WHEN s.price_id IN (SELECT price_y_id FROM public.plans WHERE price_y_id IS NOT NULL) - THEN 1 - END) AS yearly, - COUNT(CASE - WHEN s.price_id IN (SELECT price_m_id FROM public.plans WHERE price_m_id IS NOT NULL) - THEN 1 - END) AS monthly, - COUNT(*) AS total - FROM ActiveSubscriptions s; -END; -$$; - -ALTER FUNCTION "public"."get_customer_counts" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_cycle_info_org" ("orgid" "uuid") RETURNS TABLE ( - "subscription_anchor_start" timestamp with time zone, - "subscription_anchor_end" timestamp with time zone -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - customer_id_var text; - stripe_info_row public.stripe_info%ROWTYPE; - anchor_day INTERVAL; - start_date timestamp with time zone; - end_date timestamp with time zone; -BEGIN - SELECT customer_id INTO customer_id_var FROM public.orgs WHERE id = orgid; - - -- Get the stripe_info using the customer_id - SELECT * INTO stripe_info_row FROM public.stripe_info WHERE customer_id = customer_id_var; - - -- Extract the day of the month FROM public.subscription_anchor_start as an INTERVAL, default to '0 DAYS' if null - anchor_day := COALESCE(stripe_info_row.subscription_anchor_start - date_trunc('MONTH', stripe_info_row.subscription_anchor_start), '0 DAYS'::INTERVAL); - - -- Determine the start date based on the anchor day and current date - IF anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN - start_date := date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + anchor_day; - ELSE - start_date := date_trunc('MONTH', NOW()) + anchor_day; - END IF; - - -- Calculate the end date - end_date := start_date + INTERVAL '1 MONTH'; - - RETURN QUERY - SELECT start_date, end_date; -END; -$$; - -ALTER FUNCTION "public"."get_cycle_info_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_db_url" () RETURNS "text" LANGUAGE "plpgsql" -SET - search_path = '' STABLE SECURITY DEFINER PARALLEL SAFE AS $$ -BEGIN - RETURN (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name='db_url'); -END; -$$; - -ALTER FUNCTION "public"."get_db_url" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_global_metrics" ("org_id" "uuid") RETURNS TABLE ( - "date" "date", - "mau" bigint, - "storage" bigint, - "bandwidth" bigint, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - cycle_start timestamp with time zone; - cycle_end timestamp with time zone; -BEGIN - SELECT subscription_anchor_start, subscription_anchor_end - INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - - RETURN QUERY - SELECT * FROM public.get_global_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$$; - -ALTER FUNCTION "public"."get_global_metrics" ("org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_global_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) RETURNS TABLE ( - "date" "date", - "mau" bigint, - "storage" bigint, - "bandwidth" bigint, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - metrics.date, - SUM(metrics.mau)::bigint AS mau, - SUM(metrics.storage)::bigint AS storage, - SUM(metrics.bandwidth)::bigint AS bandwidth, - SUM(metrics.get)::bigint AS get, - SUM(metrics.fail)::bigint AS fail, - SUM(metrics.install)::bigint AS install, - SUM(metrics.uninstall)::bigint AS uninstall - FROM - public.get_app_metrics(org_id, start_date, end_date) AS metrics - GROUP BY - metrics.date - ORDER BY - metrics.date; -END; -$$; - -ALTER FUNCTION "public"."get_global_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_identity" () RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - auth_uid uuid; -BEGIN - SELECT auth.uid() INTO auth_uid; - - -- JWT auth.uid is not null, return - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - -- JWT is null - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."get_identity" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode" []) RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; -BEGIN - SELECT "public"."get_apikey_header"() INTO api_key_text; - - -- No api key found in headers, return - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - -- Fetch the api key - SELECT * FROM public.apikeys - WHERE key=api_key_text AND - mode=ANY(keymode) - limit 1 INTO api_key; - - if api_key IS DISTINCT FROM NULL THEN - RETURN api_key.user_id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode" []) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_identity" ("keymode" "public"."key_mode" []) RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key record; -BEGIN - SELECT auth.uid() INTO auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT "public"."get_apikey_header"() INTO api_key_text; - - -- No api key found in headers, return - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - -- Fetch the api key - SELECT * FROM public.apikeys - WHERE key=api_key_text AND - mode=ANY(keymode) - limit 1 INTO api_key; - - if api_key IS DISTINCT FROM NULL THEN - RETURN api_key.user_id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."get_identity" ("keymode" "public"."key_mode" []) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode" [], "org_id" "uuid") RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key record; -BEGIN - SELECT auth.uid() INTO auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT "public"."get_apikey_header"() INTO api_key_text; - - -- No api key found in headers, return - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - -- Fetch the api key - SELECT * FROM public.apikeys - WHERE key=api_key_text AND - mode=ANY(keymode) - limit 1 INTO api_key; - - if api_key IS DISTINCT FROM NULL THEN - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - RETURN NULL; - END IF; - END IF; - RETURN api_key.user_id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode" [], "org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_identity_org_appid" ( - "keymode" "public"."key_mode" [], - "org_id" "uuid", - "app_id" character varying -) RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key record; -BEGIN - SELECT auth.uid() INTO auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT "public"."get_apikey_header"() INTO api_key_text; - - -- No api key found in headers, return - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - -- Fetch the api key - SELECT * FROM public.apikeys - WHERE key=api_key_text AND - mode=ANY(keymode) - limit 1 INTO api_key; - - if api_key IS DISTINCT FROM NULL THEN - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - RETURN NULL; - END IF; - END IF; - IF COALESCE(array_length(api_key.limited_to_apps, 1), 0) > 0 THEN - IF NOT (app_id = ANY(api_key.limited_to_apps)) THEN - RETURN NULL; - END IF; - END IF; - - RETURN api_key.user_id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."get_identity_org_appid" ( - "keymode" "public"."key_mode" [], - "org_id" "uuid", - "app_id" character varying -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_metered_usage" () RETURNS "public"."stats_table" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN public.get_metered_usage((SELECT auth.uid())); -END; -$$; - -ALTER FUNCTION "public"."get_metered_usage" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_metered_usage" ("orgid" "uuid") RETURNS "public"."stats_table" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - current_usage public.stats_table; - max_plan public.stats_table; - result public.stats_table; -BEGIN - -- Get the total values for the user's current usage - SELECT * INTO current_usage FROM public.get_total_metrics(orgid); - SELECT * INTO max_plan FROM public.get_current_plan_max_org(orgid); - result.mau = current_usage.mau - max_plan.mau; - result.mau = (CASE WHEN result.mau > 0 THEN result.mau ELSE 0 END); - result.bandwidth = current_usage.bandwidth - max_plan.bandwidth; - result.bandwidth = (CASE WHEN result.bandwidth > 0 THEN result.bandwidth ELSE 0 END); - result.storage = current_usage.storage - max_plan.storage; - result.storage = (CASE WHEN result.storage > 0 THEN result.storage ELSE 0 END); - RETURN result; -END; -$$; - -ALTER FUNCTION "public"."get_metered_usage" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_next_cron_time" ( - "p_schedule" "text", - "p_timestamp" timestamp with time zone -) RETURNS timestamp with time zone LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - parts text[]; - minute_pattern text; - hour_pattern text; - day_pattern text; - month_pattern text; - dow_pattern text; - next_minute int; - next_hour int; - next_time timestamp with time zone; -BEGIN - -- Split cron expression - parts := regexp_split_to_array(p_schedule, '\s+'); - minute_pattern := parts[1]; - hour_pattern := parts[2]; - day_pattern := parts[3]; - month_pattern := parts[4]; - dow_pattern := parts[5]; - - -- Get next minute and hour - next_minute := public.get_next_cron_value( - minute_pattern, - EXTRACT(MINUTE FROM p_timestamp)::int, - 60 - ); - next_hour := public.get_next_cron_value( - hour_pattern, - EXTRACT(HOUR FROM p_timestamp)::int, - 24 - ); - - -- Calculate base next time - next_time := date_trunc('hour', p_timestamp) + - make_interval(hours => next_hour - EXTRACT(HOUR FROM p_timestamp)::int, - mins => next_minute); - - -- Ensure next_time is in the future - IF next_time <= p_timestamp THEN - IF hour_pattern LIKE '*/%' THEN - next_time := next_time + make_interval(hours => public.parse_step_pattern(hour_pattern)); - ELSIF minute_pattern LIKE '*/%' THEN - next_time := next_time + make_interval(mins => public.parse_step_pattern(minute_pattern)); - ELSE - next_time := next_time + interval '1 day'; - END IF; - END IF; - - RETURN next_time; -END; -$$; - -ALTER FUNCTION "public"."get_next_cron_time" ( - "p_schedule" "text", - "p_timestamp" timestamp with time zone -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_next_cron_value" ( - "pattern" "text", - "current_val" integer, - "max_val" integer -) RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - next_val int; -BEGIN - IF pattern = '*' THEN - RETURN current_val; - ELSIF pattern LIKE '*/%' THEN - DECLARE - step int := public.parse_step_pattern(pattern); - temp_next int := current_val + (step - (current_val % step)); - BEGIN - IF temp_next >= max_val THEN - RETURN step; - ELSE - RETURN temp_next; - END IF; - END; - ELSE - RETURN pattern::int; - END IF; -END; -$$; - -ALTER FUNCTION "public"."get_next_cron_value" ( - "pattern" "text", - "current_val" integer, - "max_val" integer -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_org_members" ("guild_id" "uuid") RETURNS TABLE ( - "aid" bigint, - "uid" "uuid", - "email" character varying, - "image_url" character varying, - "role" "public"."user_min_right" -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -begin - IF NOT public.check_min_rights('read'::"public"."user_min_right", (SELECT auth.uid()), get_org_members.guild_id, NULL::character varying, NULL::bigint) THEN - raise exception 'NO_RIGHTS'; - END IF; - - RETURN query SELECT * FROM public.get_org_members((SELECT auth.uid()), get_org_members.guild_id); -END; -$$; - -ALTER FUNCTION "public"."get_org_members" ("guild_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_org_members" ("user_id" "uuid", "guild_id" "uuid") RETURNS TABLE ( - "aid" bigint, - "uid" "uuid", - "email" character varying, - "image_url" character varying, - "role" "public"."user_min_right" -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -begin - RETURN query SELECT o.id as aid, users.id as uid, users.email, users.image_url, o.user_right as role FROM public.org_users as o - JOIN public.users on users.id = o.user_id - WHERE o.org_id=get_org_members.guild_id - AND public.is_member_of_org(users.id, o.org_id); -END; -$$; - -ALTER FUNCTION "public"."get_org_members" ("user_id" "uuid", "guild_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_org_owner_id" ("apikey" "text", "app_id" "text") RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_owner_id uuid; - real_user_id uuid; - org_id uuid; -BEGIN - SELECT apps.user_id FROM public.apps WHERE apps.app_id=get_org_owner_id.app_id INTO org_owner_id; - SELECT public.get_user_main_org_id_by_app_id(app_id) INTO org_id; - - SELECT user_id - INTO real_user_id - FROM public.apikeys - WHERE key=apikey; - - IF (public.is_member_of_org(real_user_id, org_id) IS FALSE) - THEN - raise exception 'NO_RIGHTS'; - END IF; - - RETURN org_owner_id; -END; -$$; - -ALTER FUNCTION "public"."get_org_owner_id" ("apikey" "text", "app_id" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_org_perm_for_apikey" ("apikey" "text", "app_id" "text") RETURNS "text" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -<> -DECLARE - apikey_user_id uuid; - org_id uuid; - user_perm "public"."user_min_right"; -BEGIN - SELECT public.get_user_id(apikey) INTO apikey_user_id; - - IF apikey_user_id IS NULL THEN - RETURN 'INVALID_APIKEY'; - END IF; - - SELECT owner_org FROM public.apps - INTO org_id - WHERE apps.app_id=get_org_perm_for_apikey.app_id - limit 1; - - IF org_id IS NULL THEN - RETURN 'NO_APP'; - END IF; - - SELECT user_right FROM public.org_users - INTO user_perm - WHERE user_id=apikey_user_id - AND org_users.org_id=get_org_perm_for_apikey.org_id; - - IF user_perm IS NULL THEN - RETURN 'perm_none'; - END IF; - - -- For compatibility reasons if you are a super_admin we will RETURN "owner" - -- The old cli relies on this behaviour, on get_org_perm_for_apikey_v2 we will change that - IF user_perm='super_admin'::"public"."user_min_right" THEN - RETURN 'perm_owner'; - END IF; - - RETURN format('perm_%s', user_perm); -END;$$; - -ALTER FUNCTION "public"."get_org_perm_for_apikey" ("apikey" "text", "app_id" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_organization_cli_warnings" ("orgid" "uuid", "cli_version" "text") RETURNS "jsonb" [] LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - messages jsonb[] := '{}'; - has_read_access boolean; -BEGIN - -- Check if API key has read access - SELECT public.check_min_rights('read'::"public"."user_min_right", public.get_identity_apikey_only('{write,all,upload,read}'::"public"."key_mode"[]), orgid, NULL::character varying, NULL::bigint) INTO has_read_access; - - IF NOT has_read_access THEN - messages := array_append(messages, jsonb_build_object( - 'message', 'API key does not have read access to this organization', - 'fatal', true - )); - RETURN messages; - END IF; - - -- test the user plan - IF (public.is_paying_and_good_plan_org_action(orgid, ARRAY['mau']::"public"."action_type"[]) = true AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::"public"."action_type"[]) = true AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::"public"."action_type"[]) = false) THEN - messages := array_append(messages, jsonb_build_object( - 'message', 'You have exceeded your storage limit.\nUpload will fail, but you can still download your data.\nMAU and bandwidth limits are not exceeded.\nIn order to upload your data, please upgrade your plan here: https://console.capgo.app/settings/plans.', - 'fatal', true - )); - END IF; - - RETURN messages; -END; -$$; - -ALTER FUNCTION "public"."get_organization_cli_warnings" ("orgid" "uuid", "cli_version" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_orgs_v6" () RETURNS TABLE ( - "gid" "uuid", - "created_by" "uuid", - "logo" "text", - "name" "text", - "role" character varying, - "paying" boolean, - "trial_left" integer, - "can_use_more" boolean, - "is_canceled" boolean, - "app_count" bigint, - "subscription_start" timestamp with time zone, - "subscription_end" timestamp with time zone, - "management_email" "text", - "is_yearly" boolean -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT "public"."get_apikey_header"() INTO api_key_text; - user_id := NULL; - - -- Check for API key first - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.apikeys WHERE key=api_key_text INTO api_key; - - IF api_key IS NULL THEN - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - user_id := api_key.user_id; - - -- Check limited_to_orgs only if api_key exists and has restrictions - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN query SELECT orgs.* FROM public.get_orgs_v6(user_id) orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - -- If no valid API key user_id yet, try to get FROM public.identity - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN query SELECT * FROM public.get_orgs_v6(user_id); -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v6" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_orgs_v6" ("userid" "uuid") RETURNS TABLE ( - "gid" "uuid", - "created_by" "uuid", - "logo" "text", - "name" "text", - "role" character varying, - "paying" boolean, - "trial_left" integer, - "can_use_more" boolean, - "is_canceled" boolean, - "app_count" bigint, - "subscription_start" timestamp with time zone, - "subscription_end" timestamp with time zone, - "management_email" "text", - "is_yearly" boolean -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN QUERY - SELECT - sub.id AS gid, - sub.created_by, - sub.logo, - sub.name, - org_users.user_right::varchar AS role, - public.is_paying_org(sub.id) AS paying, - public.is_trial_org(sub.id) AS trial_left, - public.is_allowed_action_org(sub.id) AS can_use_more, - public.is_canceled_org(sub.id) AS is_canceled, - (SELECT count(*) FROM public.apps WHERE owner_org = sub.id) AS app_count, - (sub.f).subscription_anchor_start AS subscription_start, - (sub.f).subscription_anchor_end AS subscription_end, - sub.management_email AS management_email, - public.is_org_yearly(sub.id) AS is_yearly - FROM ( - SELECT public.get_cycle_info_org(o.id) AS f, o.* AS o FROM public.orgs AS o - ) sub - JOIN public.org_users ON (org_users."user_id" = get_orgs_v6.userid AND sub.id = org_users."org_id"); -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v6" ("userid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_plan_usage_percent_detailed" ("orgid" "uuid") RETURNS TABLE ( - "total_percent" double precision, - "mau_percent" double precision, - "bandwidth_percent" double precision, - "storage_percent" double precision -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - cycle_start date; - cycle_end date; -BEGIN - -- Get the start and end dates of the current billing cycle - SELECT subscription_anchor_start::date, subscription_anchor_end::date - INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(orgid); - - -- Call the function with billing cycle dates as parameters - RETURN QUERY - SELECT * FROM public.get_plan_usage_percent_detailed(orgid, cycle_start, cycle_end); -END; -$$; - -ALTER FUNCTION "public"."get_plan_usage_percent_detailed" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_plan_usage_percent_detailed" ( - "orgid" "uuid", - "cycle_start" "date", - "cycle_end" "date" -) RETURNS TABLE ( - "total_percent" double precision, - "mau_percent" double precision, - "bandwidth_percent" double precision, - "storage_percent" double precision -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - current_plan_max public.stats_table; - total_stats public.stats_table; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; -BEGIN - -- Get the maximum values for the user's current plan - current_plan_max := public.get_current_plan_max_org(orgid); - - -- Get the user's maximum usage stats for the specified billing cycle - SELECT mau, bandwidth, storage - INTO total_stats - FROM public.get_total_metrics(orgid, cycle_start, cycle_end); - - -- Calculate the percentage of usage for each stat - percent_mau := public.convert_number_to_percent(total_stats.mau, current_plan_max.mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, current_plan_max.bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, current_plan_max.storage); - - -- Return the total usage percentage and the individual usage percentages - RETURN QUERY SELECT - GREATEST(percent_mau, percent_bandwidth, percent_storage) AS total_percent, - percent_mau AS mau_percent, - percent_bandwidth AS bandwidth_percent, - percent_storage AS storage_percent; -END; -$$; - -ALTER FUNCTION "public"."get_plan_usage_percent_detailed" ( - "orgid" "uuid", - "cycle_start" "date", - "cycle_end" "date" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_total_app_storage_size_orgs" ("org_id" "uuid", "app_id" character varying) RETURNS double precision LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - total_size double precision := 0; -BEGIN - SELECT COALESCE(SUM(app_versions_meta.size), 0) INTO total_size - FROM public.app_versions - INNER JOIN public.app_versions_meta ON app_versions.id = app_versions_meta.id - WHERE app_versions.owner_org = org_id - AND app_versions.app_id = get_total_app_storage_size_orgs.app_id - AND app_versions.deleted = false; - - RETURN total_size; -END; -$$; - -ALTER FUNCTION "public"."get_total_app_storage_size_orgs" ("org_id" "uuid", "app_id" character varying) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_total_metrics" ("org_id" "uuid") RETURNS TABLE ( - "mau" bigint, - "storage" bigint, - "bandwidth" bigint, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - cycle_start timestamp with time zone; - cycle_end timestamp with time zone; -BEGIN - SELECT subscription_anchor_start, subscription_anchor_end - INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - - RETURN QUERY - SELECT * FROM public.get_total_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$$; - -ALTER FUNCTION "public"."get_total_metrics" ("org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_total_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) RETURNS TABLE ( - "mau" bigint, - "storage" bigint, - "bandwidth" bigint, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - COALESCE(SUM(metrics.mau), 0)::bigint AS mau, - COALESCE(public.get_total_storage_size_org(org_id), 0)::bigint AS storage, - COALESCE(SUM(metrics.bandwidth), 0)::bigint AS bandwidth, - COALESCE(SUM(metrics.get), 0)::bigint AS get, - COALESCE(SUM(metrics.fail), 0)::bigint AS fail, - COALESCE(SUM(metrics.install), 0)::bigint AS install, - COALESCE(SUM(metrics.uninstall), 0)::bigint AS uninstall - FROM - public.get_app_metrics(org_id, start_date, end_date) AS metrics; -END; -$$; - -ALTER FUNCTION "public"."get_total_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_total_storage_size_org" ("org_id" "uuid") RETURNS double precision LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - total_size double precision := 0; -BEGIN - SELECT COALESCE(SUM(app_versions_meta.size), 0) INTO total_size - FROM public.app_versions - INNER JOIN public.app_versions_meta ON app_versions.id = app_versions_meta.id - WHERE app_versions.owner_org = org_id - AND app_versions.deleted = false; - - RETURN total_size; -END; -$$; - -ALTER FUNCTION "public"."get_total_storage_size_org" ("org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_update_stats" () RETURNS TABLE ( - "app_id" character varying, - "failed" bigint, - "install" bigint, - "get" bigint, - "success_rate" numeric, - "healthy" boolean -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN QUERY - WITH stats AS ( - SELECT - version_usage.app_id, - COALESCE(SUM(CASE WHEN action = 'fail' THEN 1 ELSE 0 END), 0) AS failed, - COALESCE(SUM(CASE WHEN action = 'install' THEN 1 ELSE 0 END), 0) AS install, - COALESCE(SUM(CASE WHEN action = 'get' THEN 1 ELSE 0 END), 0) AS get - FROM - public.version_usage - WHERE - timestamp >= (date_trunc('minute', NOW()) - INTERVAL '10 minutes') - AND timestamp < (date_trunc('minute', NOW()) - INTERVAL '9 minutes') - GROUP BY - version_usage.app_id - ) - SELECT - stats.app_id, - stats.failed, - stats.install, - stats.get, - CASE - WHEN (stats.install + stats.get) > 0 THEN - ROUND((stats.get::numeric / (stats.install + stats.get)) * 100, 2) - ELSE 100 - END AS success_rate, - CASE - WHEN (stats.install + stats.get) > 0 THEN - ((stats.get::numeric / (stats.install + stats.get)) * 100 >= 70) - ELSE true - END AS healthy - FROM - stats - WHERE - stats.get > 0; -END; -$$; - -ALTER FUNCTION "public"."get_update_stats" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_user_id" ("apikey" "text") RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - is_found uuid; -BEGIN - SELECT user_id - INTO is_found - FROM public.apikeys - WHERE key=apikey; - RETURN is_found; -END; -$$; - -ALTER FUNCTION "public"."get_user_id" ("apikey" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_user_id" ("apikey" "text", "app_id" "text") RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - real_user_id uuid; -BEGIN - SELECT public.get_user_id(apikey) INTO real_user_id; - - RETURN real_user_id; -END; -$$; - -ALTER FUNCTION "public"."get_user_id" ("apikey" "text", "app_id" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_user_main_org_id" ("user_id" "uuid") RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_id uuid; -begin - SELECT orgs.id FROM public.orgs - INTO org_id - WHERE orgs.created_by=get_user_main_org_id.user_id - limit 1; - - RETURN org_id; -END; -$$; - -ALTER FUNCTION "public"."get_user_main_org_id" ("user_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_user_main_org_id_by_app_id" ("app_id" "text") RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_id uuid; -begin - SELECT apps.owner_org FROM public.apps - INTO org_id - WHERE ((apps.app_id)::text = (get_user_main_org_id_by_app_id.app_id)::text) - limit 1; - - RETURN org_id; -END; -$$; - -ALTER FUNCTION "public"."get_user_main_org_id_by_app_id" ("app_id" "text") OWNER TO "postgres"; - -SET - default_tablespace = ''; - -SET - default_table_access_method = "heap"; - -CREATE TABLE IF NOT EXISTS "public"."app_versions" ( - "id" bigint NOT NULL, - "created_at" timestamp with time zone DEFAULT "now" (), - "app_id" character varying NOT NULL, - "name" character varying NOT NULL, - "updated_at" timestamp with time zone DEFAULT "now" (), - "deleted" boolean DEFAULT false NOT NULL, - "external_url" character varying, - "checksum" character varying, - "session_key" character varying, - "storage_provider" "text" DEFAULT 'r2'::"text" NOT NULL, - "min_update_version" character varying, - "native_packages" "jsonb" [], - "owner_org" "uuid" NOT NULL, - "user_id" "uuid", - "r2_path" character varying, - "manifest" "public"."manifest_entry" [], - "link" "text", - "comment" "text" -); - -ALTER TABLE "public"."app_versions" OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_apikey_header" () RETURNS text LANGUAGE plpgsql SECURITY DEFINER -SET - search_path TO '' AS $$ -DECLARE - headers_text text; -BEGIN - headers_text := "current_setting"('request.headers'::"text", true); - - IF headers_text IS NULL OR headers_text = '' THEN - RETURN NULL; - END IF; - - BEGIN - RETURN (headers_text::"json" ->> 'capgkey'::"text"); - EXCEPTION - WHEN OTHERS THEN - RETURN NULL; - END; -END; -$$; - -ALTER FUNCTION "public"."get_apikey_header" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_versions_with_no_metadata" () RETURNS SETOF "public"."app_versions" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT app_versions.* FROM public.app_versions - LEFT JOIN public.app_versions_meta ON app_versions_meta.id=app_versions.id - WHERE COALESCE(app_versions_meta.size, 0) = 0 - AND app_versions.deleted=false - AND app_versions.storage_provider != 'external' - AND NOW() - app_versions.created_at > interval '120 seconds'; -END; -$$; - -ALTER FUNCTION "public"."get_versions_with_no_metadata" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_weekly_stats" ("app_id" character varying) RETURNS TABLE ( - "all_updates" bigint, - "failed_updates" bigint, - "open_app" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - seven_days_ago DATE; - all_updates bigint; - failed_updates bigint; -BEGIN - seven_days_ago := CURRENT_DATE - INTERVAL '7 days'; - - SELECT COALESCE(SUM(install), 0) - INTO all_updates - FROM public.daily_version - WHERE date BETWEEN seven_days_ago AND CURRENT_DATE - AND public.daily_version.app_id = get_weekly_stats.app_id; - - SELECT COALESCE(SUM(fail), 0) - INTO failed_updates - FROM public.daily_version - WHERE date BETWEEN seven_days_ago AND CURRENT_DATE - AND public.daily_version.app_id = get_weekly_stats.app_id; - - SELECT COALESCE(SUM(get), 0) - INTO open_app - FROM public.daily_version - WHERE date BETWEEN seven_days_ago AND CURRENT_DATE - AND public.daily_version.app_id = get_weekly_stats.app_id; - - RETURN QUERY SELECT all_updates, failed_updates, open_app; -END; -$$; - -ALTER FUNCTION "public"."get_weekly_stats" ("app_id" character varying) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."has_app_right" ( - "appid" character varying, - "right" "public"."user_min_right" -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN public.has_app_right_userid("appid", "right", (SELECT auth.uid())); -END; -$$; - -ALTER FUNCTION "public"."has_app_right" ( - "appid" character varying, - "right" "public"."user_min_right" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."has_app_right_apikey" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid", - "apikey" "text" -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_id uuid; - api_key record; -BEGIN - org_id := public.get_user_main_org_id_by_app_id(appid); - - SELECT * FROM public.apikeys WHERE key = apikey INTO api_key; - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - RETURN false; - END IF; - END IF; - - IF api_key.limited_to_apps IS DISTINCT FROM '{}' THEN - IF NOT (appid = ANY(api_key.limited_to_apps)) THEN - RETURN false; - END IF; - END IF; - - RETURN (public.check_min_rights("right", userid, org_id, "appid", NULL::bigint)); -END; -$$; - -ALTER FUNCTION "public"."has_app_right_apikey" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid", - "apikey" "text" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."has_app_right_userid" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid" -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_id uuid; -BEGIN - org_id := public.get_user_main_org_id_by_app_id(appid); - - RETURN public.check_min_rights("right", userid, org_id, "appid", NULL::bigint); -END; -$$; - -ALTER FUNCTION "public"."has_app_right_userid" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."invite_user_to_org" ( - "email" character varying, - "org_id" "uuid", - "invite_type" "public"."user_min_right" -) RETURNS character varying LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org record; - invited_user record; - current_record record; -BEGIN - SELECT * FROM public.orgs - INTO org - WHERE orgs.id=invite_user_to_org.org_id; - - IF org IS NULL THEN - RETURN 'NO_ORG'; - END IF; - - if NOT (public.check_min_rights('admin'::"public"."user_min_right", (SELECT "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], invite_user_to_org.org_id)), invite_user_to_org.org_id, NULL::character varying, NULL::bigint)) THEN - RETURN 'NO_RIGHTS'; - END IF; - - - if NOT (public.check_min_rights('super_admin'::"public"."user_min_right", (SELECT "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], invite_user_to_org.org_id)), invite_user_to_org.org_id, NULL::character varying, NULL::bigint) AND (invite_type IS DISTINCT FROM 'super_admin'::"public"."user_min_right" or invite_type IS DISTINCT FROM 'invite_super_admin'::"public"."user_min_right")) THEN - RETURN 'NO_RIGHTS'; - END IF; - - SELECT users.id FROM public.users - INTO invited_user - WHERE users.email=invite_user_to_org.email; - - IF invited_user IS NOT NULL THEN - -- INSERT INTO publicorg_users (user_id, org_id, user_right) - -- VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - - SELECT org_users.id FROM public.org_users - INTO current_record - WHERE org_users.user_id=invited_user.id - AND org_users.org_id=invite_user_to_org.org_id; - - IF current_record IS NOT NULL THEN - RETURN 'ALREADY_INVITED'; - ELSE - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - - RETURN 'OK'; - END IF; - ELSE - RETURN 'NO_EMAIL'; - END IF; -END; -$$; - -ALTER FUNCTION "public"."invite_user_to_org" ( - "email" character varying, - "org_id" "uuid", - "invite_type" "public"."user_min_right" -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_admin" () RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN public.is_admin((SELECT auth.uid())); -END; -$$; - -ALTER FUNCTION "public"."is_admin" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_admin" ("userid" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - admin_ids_jsonb JSONB; - is_admin_flag BOOLEAN; - mfa_verified BOOLEAN; -BEGIN - -- Fetch the JSONB string of admin user IDs from the vault - SELECT decrypted_secret INTO admin_ids_jsonb FROM vault.decrypted_secrets WHERE name = 'admin_users'; - - -- Check if the provided userid is within the JSONB array of admin user IDs - is_admin_flag := (admin_ids_jsonb ? userid::text); - - -- Verify MFA status for the user - SELECT public.verify_mfa() INTO mfa_verified; - - -- An admin with no logged 2FA should not have his admin perms granted - RETURN is_admin_flag AND mfa_verified; -END; -$$; - -ALTER FUNCTION "public"."is_admin" ("userid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_allowed_action" ("apikey" "text", "appid" "text") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN public.is_allowed_action_org((SELECT owner_org FROM public.apps WHERE app_id=appid)); -END; -$$; - -ALTER FUNCTION "public"."is_allowed_action" ("apikey" "text", "appid" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_allowed_action_org" ("orgid" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN public.is_paying_and_good_plan_org(orgid); -END; -$$; - -ALTER FUNCTION "public"."is_allowed_action_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_allowed_action_org_action" ( - "orgid" "uuid", - "actions" "public"."action_type" [] -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN public.is_paying_and_good_plan_org_action(orgid, actions); -END; -$$; - -ALTER FUNCTION "public"."is_allowed_action_org_action" ( - "orgid" "uuid", - "actions" "public"."action_type" [] -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_allowed_capgkey" ("apikey" "text", "keymode" "public"."key_mode" []) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.apikeys - WHERE key=apikey - AND mode=ANY(keymode))); -END; -$$; - -ALTER FUNCTION "public"."is_allowed_capgkey" ("apikey" "text", "keymode" "public"."key_mode" []) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_allowed_capgkey" ( - "apikey" "text", - "keymode" "public"."key_mode" [], - "app_id" character varying -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.apikeys - WHERE key=apikey - AND mode=ANY(keymode))) AND public.is_app_owner(public.get_user_id(apikey), app_id); -END; -$$; - -ALTER FUNCTION "public"."is_allowed_capgkey" ( - "apikey" "text", - "keymode" "public"."key_mode" [], - "app_id" character varying -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_app_owner" ("appid" character varying) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN public.is_app_owner((SELECT auth.uid()), appid); -END; -$$; - -ALTER FUNCTION "public"."is_app_owner" ("appid" character varying) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_app_owner" ("apikey" "text", "appid" character varying) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN public.is_app_owner(public.get_user_id(apikey), appid); -END; -$$; - -ALTER FUNCTION "public"."is_app_owner" ("apikey" "text", "appid" character varying) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_app_owner" ("userid" "uuid", "appid" character varying) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.apps - WHERE app_id=appid - AND user_id=userid)); -END; -$$; - -ALTER FUNCTION "public"."is_app_owner" ("userid" "uuid", "appid" character varying) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_bandwidth_exceeded_by_org" ("org_id" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' STABLE AS $$ -BEGIN - RETURN (SELECT bandwidth_exceeded - FROM public.stripe_info - WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = is_bandwidth_exceeded_by_org.org_id)); -END; -$$; - -ALTER FUNCTION "public"."is_bandwidth_exceeded_by_org" ("org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_canceled_org" ("orgid" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.stripe_info - WHERE customer_id=(SELECT customer_id FROM public.orgs WHERE id=orgid) - AND status = 'canceled')); -END; -$$; - -ALTER FUNCTION "public"."is_canceled_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION public.is_good_plan_v5_org (orgid uuid) RETURNS boolean LANGUAGE plpgsql -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - s date; - e date; - v_mau bigint; - v_bandwidth bigint; - v_storage bigint; - current_plan_name text; -BEGIN - -- 1) get cycle dates once - SELECT subscription_anchor_start::date, - subscription_anchor_end::date - INTO s, e - FROM public.get_cycle_info_org(orgid); - - -- 2) call the 3-arg totals once via FROM (no repeated eval) - SELECT m.mau, m.bandwidth, m.storage - INTO v_mau, v_bandwidth, v_storage - FROM public.get_total_metrics( - orgid, s, e - ) AS m(mau, storage, bandwidth, "get", fail, install, uninstall); - - -- 3) current plan - current_plan_name := public.get_current_plan_name_org(orgid); - - -- 4) inline fit check (no extra function call) - RETURN EXISTS ( - SELECT 1 - FROM public.plans p - WHERE p.name = current_plan_name - AND ( - p.name = 'Enterprise' - OR (p.mau >= v_mau AND p.bandwidth >= v_bandwidth AND p.storage >= v_storage) - ) - ); -END; -$$; - -ALTER FUNCTION "public"."is_good_plan_v5_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_mau_exceeded_by_org" ("org_id" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' STABLE AS $$ -BEGIN - RETURN (SELECT mau_exceeded - FROM public.stripe_info - WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = is_mau_exceeded_by_org.org_id)); -END; -$$; - -ALTER FUNCTION "public"."is_mau_exceeded_by_org" ("org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_member_of_org" ("user_id" "uuid", "org_id" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - is_found integer; -BEGIN - SELECT count(*) - INTO is_found - FROM public.orgs - JOIN public.org_users on org_users.org_id = orgs.id - WhERE org_users.user_id = is_member_of_org.user_id AND - orgs.id = is_member_of_org.org_id; - RETURN is_found != 0; -END; -$$; - -ALTER FUNCTION "public"."is_member_of_org" ("user_id" "uuid", "org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_not_deleted" ("email_check" character varying) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - is_found integer; -BEGIN - SELECT count(*) - INTO is_found - FROM public.deleted_account - WHERE email=email_check; - RETURN is_found = 0; -END; -$$; - -ALTER FUNCTION "public"."is_not_deleted" ("email_check" character varying) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_numeric" ("text") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $_$ -BEGIN - RETURN $1 ~ '^[0-9]+$'; -END; -$_$; - -ALTER FUNCTION "public"."is_numeric" ("text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_onboarded_org" ("orgid" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.apps - WHERE owner_org=orgid)) AND (SELECT EXISTS (SELECT 1 - FROM public.app_versions - WHERE owner_org=orgid)); -END; -$$; - -ALTER FUNCTION "public"."is_onboarded_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_onboarding_needed_org" ("orgid" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (NOT public.is_onboarded_org(orgid)) AND public.is_trial_org(orgid) = 0; -END; -$$; - -ALTER FUNCTION "public"."is_onboarding_needed_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_org_yearly" ("orgid" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - is_yearly boolean; -BEGIN - SELECT - CASE - WHEN si.price_id = p.price_y_id THEN true - ELSE false - END INTO is_yearly - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid - LIMIT 1; - - RETURN COALESCE(is_yearly, false); -END; -$$; - -ALTER FUNCTION "public"."is_org_yearly" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_paying_and_good_plan_org" ("orgid" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.stripe_info - WHERE customer_id=(SELECT customer_id FROM public.orgs WHERE id=orgid) - AND ( - (status = 'succeeded' AND is_good_plan = true) - OR (trial_at::date - (NOW())::date > 0) - ) - ) -); -END; -$$; - -ALTER FUNCTION "public"."is_paying_and_good_plan_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION public.is_paying_and_good_plan_org_action (orgid uuid, actions public.action_type[]) RETURNS boolean LANGUAGE plpgsql -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE org_customer_id text; result boolean; -BEGIN - SELECT o.customer_id INTO org_customer_id FROM public.orgs o WHERE o.id = orgid; - - SELECT (si.trial_at > NOW()) - OR (si.status = 'succeeded' AND NOT ( - (si.mau_exceeded AND 'mau' = ANY(actions)) OR - (si.storage_exceeded AND 'storage' = ANY(actions)) OR - (si.bandwidth_exceeded AND 'bandwidth' = ANY(actions)) - )) - INTO result - FROM public.stripe_info si - WHERE si.customer_id = org_customer_id - LIMIT 1; - - RETURN COALESCE(result, false); -END; $$; - -ALTER FUNCTION "public"."is_paying_and_good_plan_org_action" ( - "orgid" "uuid", - "actions" "public"."action_type" [] -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_paying_org" ("orgid" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.stripe_info - WHERE customer_id=(SELECT customer_id FROM public.orgs WHERE id=orgid) - AND status = 'succeeded')); -END; -$$; - -ALTER FUNCTION "public"."is_paying_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_storage_exceeded_by_org" ("org_id" "uuid") RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' STABLE AS $$ -BEGIN - RETURN (SELECT storage_exceeded - FROM public.stripe_info - WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = is_storage_exceeded_by_org.org_id)); -END; -$$; - -ALTER FUNCTION "public"."is_storage_exceeded_by_org" ("org_id" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_trial_org" ("orgid" "uuid") RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN (SELECT GREATEST((trial_at::date - (NOW())::date), 0) AS days - FROM public.stripe_info - WHERE customer_id=(SELECT customer_id FROM public.orgs WHERE id=orgid)); -END; -$$; - -ALTER FUNCTION "public"."is_trial_org" ("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."noupdate" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' AS $_$ -DECLARE - val RECORD; - is_different boolean; -BEGIN - -- API key? We do not care - IF (SELECT auth.uid()) IS NULL THEN - RETURN NEW; - END IF; - - -- If the user has the 'admin' role then we do not care - IF public.check_min_rights('admin'::"public"."user_min_right", (SELECT auth.uid()), OLD.owner_org, NULL::character varying, NULL::bigint) THEN - RETURN NEW; - END IF; - - for val in - SELECT * FROM json_each_text(row_to_json(NEW)) - loop - -- raise warning '?? % % %', val.key, val.value, format('SELECT (NEW."%s" <> OLD."%s")', val.key, val.key); - - EXECUTE format('SELECT ($1."%s" IS DISTINCT FROM $2."%s")', val.key, val.key) using NEW, OLD - INTO is_different; - - IF is_different AND val.key <> 'version' AND val.key <> 'updated_at' THEN - RAISE EXCEPTION 'not allowed %', val.key; - END IF; - end loop; - - RETURN NEW; -END;$_$; - -ALTER FUNCTION "public"."noupdate" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."one_month_ahead" () RETURNS timestamp without time zone LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN NOW() + INTERVAL '1 month'; -END; -$$; - -ALTER FUNCTION "public"."one_month_ahead" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."parse_cron_field" ( - "field" "text", - "current_val" integer, - "max_val" integer -) RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - IF field = '*' THEN - RETURN current_val; - ELSIF public.is_numeric(field) THEN - RETURN field::int; - ELSIF field LIKE '*/%' THEN - DECLARE - step int := regexp_replace(field, '\*/(\d+)', '\1')::int; - next_val int := current_val + (step - (current_val % step)); - BEGIN - IF next_val >= max_val THEN - RETURN step; - ELSE - RETURN next_val; - END IF; - END; - ELSE - RETURN 0; - END IF; -END; -$$; - -ALTER FUNCTION "public"."parse_cron_field" ( - "field" "text", - "current_val" integer, - "max_val" integer -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."parse_step_pattern" ("pattern" "text") RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN (regexp_replace(pattern, '\*/(\d+)', '\1'))::int; -END; -$$; - -ALTER FUNCTION "public"."parse_step_pattern" ("pattern" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."process_admin_stats" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - org_record RECORD; -BEGIN - PERFORM pgmq.send('admin_stats', - jsonb_build_object( - 'function_name', 'logsnag_insights', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object() - ) - ); -END; -$$; - -ALTER FUNCTION "public"."process_admin_stats" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."process_cron_stats_jobs" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - app_record RECORD; -BEGIN - FOR app_record IN ( - SELECT DISTINCT av.app_id, av.owner_org - FROM public.app_versions av - WHERE av.created_at >= NOW() - INTERVAL '30 days' - - UNION - - SELECT DISTINCT dm.app_id, av.owner_org - FROM public.daily_mau dm - JOIN public.app_versions av ON dm.app_id = av.app_id - WHERE dm.date >= NOW() - INTERVAL '30 days' AND dm.mau > 0 - ) - LOOP - PERFORM pgmq.send('cron_stats', - jsonb_build_object( - 'function_name', 'cron_stats', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'appId', app_record.app_id, - 'orgId', app_record.owner_org, - 'todayOnly', false - ) - ) - ); - END LOOP; -END; -$$; - -ALTER FUNCTION "public"."process_cron_stats_jobs" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."process_failed_uploads" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - failed_version RECORD; -BEGIN - FOR failed_version IN ( - SELECT * FROM public.get_versions_with_no_metadata() - ) - LOOP - PERFORM pgmq.send('cron_clear_versions', - jsonb_build_object( - 'function_name', 'cron_clear_versions', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object('version', failed_version) - ) - ); - END LOOP; -END; -$$; - -ALTER FUNCTION "public"."process_failed_uploads" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."process_free_trial_expired" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - UPDATE public.stripe_info - SET is_good_plan = false - WHERE status <> 'succeeded' AND trial_at < NOW(); -END; -$$; - -ALTER FUNCTION "public"."process_free_trial_expired" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."process_function_queue" ("queue_name" "text") RETURNS bigint LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - request_id text; - headers jsonb; - url text; - queue_size bigint; - calls_needed int; - i int; -BEGIN - -- Check if the queue has elements - EXECUTE format('SELECT count(*) FROM pgmq.q_%I', queue_name) INTO queue_size; - - -- Only make the HTTP request if the queue is not empty - IF queue_size > 0 THEN - headers := jsonb_build_object( - 'Content-Type', 'application/json', - 'apisecret', public.get_apikey() - ); - url := public.get_db_url() || '/functions/v1/triggers/queue_consumer/sync'; - - -- Calculate how many times to call the sync endpoint (1 call per 1000 items, max 10 calls) - calls_needed := least(ceil(queue_size / 1000.0)::int, 10); - - -- Call the endpoint multiple times if needed - FOR i IN 1..calls_needed LOOP - SELECT INTO request_id net.http_post( - url := url, - headers := headers, - body := jsonb_build_object('queue_name', queue_name), - timeout_milliseconds := 15000 - ); - END LOOP; - - RETURN request_id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."process_function_queue" ("queue_name" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."process_stats_email_monthly" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - app_record RECORD; -BEGIN - FOR app_record IN ( - SELECT a.app_id, o.management_email - FROM public.apps a - JOIN public.orgs o ON a.owner_org = o.id - ) - LOOP - PERFORM pgmq.send('cron_email', - jsonb_build_object( - 'function_name', 'cron_email', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'email', app_record.management_email, - 'appId', app_record.app_id, - 'type', 'monthly_create_stats' - ) - ) - ); - END LOOP; -END; -$$; - -ALTER FUNCTION "public"."process_stats_email_monthly" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."process_stats_email_weekly" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - app_record RECORD; -BEGIN - FOR app_record IN ( - SELECT a.app_id, o.management_email - FROM public.apps a - JOIN public.orgs o ON a.owner_org = o.id - ) - LOOP - PERFORM pgmq.send('cron_email', - jsonb_build_object( - 'function_name', 'cron_email', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'email', app_record.management_email, - 'appId', app_record.app_id, - 'type', 'weekly_install_stats' - ) - ) - ); - END LOOP; -END; -$$; - -ALTER FUNCTION "public"."process_stats_email_weekly" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."process_subscribed_orgs" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - org_record RECORD; -BEGIN - FOR org_record IN ( - SELECT o.id, o.customer_id - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE si.status = 'succeeded' - ) - LOOP - PERFORM pgmq.send('cron_plan', - jsonb_build_object( - 'function_name', 'cron_plan', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'orgId', org_record.id, - 'customerId', org_record.customer_id - ) - ) - ); - END LOOP; -END; -$$; - -ALTER FUNCTION "public"."process_subscribed_orgs" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."read_bandwidth_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) RETURNS TABLE ( - "date" timestamp without time zone, - "bandwidth" numeric, - "app_id" character varying -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - DATE_TRUNC('day', timestamp) AS date, - SUM(file_size) AS bandwidth, - bandwidth_usage.app_id - FROM public.bandwidth_usage - WHERE - timestamp >= p_period_start - AND timestamp < p_period_end - AND bandwidth_usage. app_id = p_app_id - GROUP BY bandwidth_usage.app_id, date - ORDER BY date; -END; -$$; - -ALTER FUNCTION "public"."read_bandwidth_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."read_device_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) RETURNS TABLE ( - "date" "date", - "mau" bigint, - "app_id" character varying -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - DATE_TRUNC('day', device_usage.timestamp)::date AS date, - COUNT(DISTINCT device_usage.device_id) AS mau, - device_usage.app_id - FROM public.device_usage - WHERE - device_usage.app_id = p_app_id - AND device_usage.timestamp >= p_period_start - AND device_usage.timestamp < p_period_end - GROUP BY DATE_TRUNC('day', device_usage.timestamp)::date, device_usage.app_id - ORDER BY date; -END; -$$; - -ALTER FUNCTION "public"."read_device_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."read_storage_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) RETURNS TABLE ( - "app_id" character varying, - "date" "date", - "storage" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - p_app_id AS app_id, - DATE_TRUNC('day', timestamp)::DATE AS date, - SUM(size)::BIGINT AS storage - FROM public.version_meta - WHERE - timestamp >= p_period_start - AND timestamp < p_period_end - AND version_meta.app_id = p_app_id - GROUP BY version_meta.app_id, date - ORDER BY date; -END; -$$; - -ALTER FUNCTION "public"."read_storage_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."read_version_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) RETURNS TABLE ( - "app_id" character varying, - "version_id" bigint, - "date" timestamp without time zone, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - version_usage.app_id, - version_usage.version_id as version_id, - DATE_TRUNC('day', timestamp) AS date, - SUM(CASE WHEN action = 'get' THEN 1 ELSE 0 END) AS get, - SUM(CASE WHEN action = 'fail' THEN 1 ELSE 0 END) AS fail, - SUM(CASE WHEN action = 'install' THEN 1 ELSE 0 END) AS install, - SUM(CASE WHEN action = 'uninstall' THEN 1 ELSE 0 END) AS uninstall - FROM public.version_usage - WHERE - version_usage.app_id = p_app_id - AND timestamp >= p_period_start - AND timestamp < p_period_end - GROUP BY date, version_usage.app_id, version_usage.version_id - ORDER BY date; -END; -$$; - -ALTER FUNCTION "public"."read_version_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."record_deployment_history" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -BEGIN - -- If version is changing, record the deployment - IF OLD.version <> NEW.version THEN - -- Insert new record - INSERT INTO public.deploy_history ( - channel_id, - app_id, - version_id, - owner_org, - created_by - ) - VALUES ( - NEW.id, - NEW.app_id, - NEW.version, - NEW.owner_org, - COALESCE(public.get_identity()::uuid, NEW.created_by) - ); - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."record_deployment_history" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."remove_old_jobs" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - DELETE FROM cron.job_run_details - WHERE end_time < NOW() - interval '1 day'; -END; -$$; - -ALTER FUNCTION "public"."remove_old_jobs" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."set_bandwidth_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - UPDATE public.stripe_info - SET bandwidth_exceeded = disabled - WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = org_id); -END; -$$; - -ALTER FUNCTION "public"."set_bandwidth_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."set_mau_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - UPDATE public.stripe_info - SET mau_exceeded = disabled - WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = org_id); -END; -$$; - -ALTER FUNCTION "public"."set_mau_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."set_storage_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - UPDATE public.stripe_info - SET storage_exceeded = disabled - WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = set_storage_exceeded_by_org.org_id); -END; -$$; - -ALTER FUNCTION "public"."set_storage_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."transfer_app" ( - "p_app_id" character varying, - "p_new_org_id" "uuid" -) RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - v_old_org_id uuid; - v_user_id uuid; - v_last_transfer jsonb; - v_last_transfer_date timestamp; -BEGIN - -- Get the current owner_org - SELECT owner_org, transfer_history[array_length(transfer_history, 1)] - INTO v_old_org_id, v_last_transfer - FROM public.apps - WHERE app_id = p_app_id; - - -- Check if app exists - IF v_old_org_id IS NULL THEN - RAISE EXCEPTION 'App % not found', p_app_id; - END IF; - - -- Get the current user ID - v_user_id := (SELECT auth.uid()); - -if NOT (public.check_min_rights('super_admin'::"public"."user_min_right", v_user_id, v_old_org_id, NULL::character varying, NULL::bigint)) THEN - RAISE EXCEPTION 'You are not authorized to transfer this app. (You don''t have super_admin rights on the old organization)'; -END IF; - -if NOT (public.check_min_rights('super_admin'::"public"."user_min_right", v_user_id, p_new_org_id, NULL::character varying, NULL::bigint)) THEN - RAISE EXCEPTION 'You are not authorized to transfer this app. (You don''t have super_admin rights on the new organization)'; -END IF; - - -- Check if enough time has passed since last transfer - IF v_last_transfer IS NOT NULL THEN - v_last_transfer_date := (v_last_transfer->>'transferred_at')::timestamp; - IF v_last_transfer_date + interval '32 days' > NOW() THEN - RAISE EXCEPTION 'Cannot transfer app. Must wait at least 32 days between transfers. Last transfer was on %', v_last_transfer_date; - END IF; - END IF; - - -- Update the app's owner_org and user_id - UPDATE public.apps - SET - owner_org = p_new_org_id, - updated_at = NOW(), - transfer_history = COALESCE(transfer_history, '{}') || jsonb_build_object( - 'transferred_at', NOW(), - 'transferred_from', v_old_org_id, - 'transferred_to', p_new_org_id, - 'initiated_by', v_user_id - )::jsonb - WHERE app_id = p_app_id; - - -- Update app_versions owner_org - UPDATE public.app_versions - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - -- Update app_versions_meta owner_org - UPDATE public.app_versions_meta - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - -- Update channel_devices owner_org - UPDATE public.channel_devices - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - -- Update channels owner_org - UPDATE public.channels - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - -- Update notifications owner_org - UPDATE public.notifications - SET owner_org = p_new_org_id - WHERE owner_org = v_old_org_id; -END; -$$; - -ALTER FUNCTION "public"."transfer_app" ( - "p_app_id" character varying, - "p_new_org_id" "uuid" -) OWNER TO "postgres"; - -COMMENT ON FUNCTION "public"."transfer_app" ( - "p_app_id" character varying, - "p_new_org_id" "uuid" -) IS 'Transfers an app and all its related data to a new organization. Requires the caller to have appropriate permissions on both organizations.'; - -CREATE OR REPLACE FUNCTION "public"."trigger_http_queue_post_to_function" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - payload jsonb; -BEGIN - -- Build the base payload - payload := jsonb_build_object( - 'function_name', TG_ARGV[0], - 'function_type', TG_ARGV[1], - 'payload', jsonb_build_object( - 'old_record', OLD, - 'record', NEW, - 'type', TG_OP, - 'table', TG_TABLE_NAME, - 'schema', TG_TABLE_SCHEMA - ) - ); - - -- Also send to function-specific queue - IF TG_ARGV[0] IS NOT NULL THEN - PERFORM pgmq.send(TG_ARGV[0], payload); - END IF; - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."trigger_http_queue_post_to_function" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."update_app_versions_retention" () RETURNS void LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - -- Use a more efficient approach with direct timestamp comparison - UPDATE public.app_versions - SET deleted = true - WHERE app_versions.deleted = false -- Filter non-deleted first - AND app_versions.created_at < ( - SELECT NOW() - make_interval(secs => apps.retention) - FROM public.apps - WHERE apps.app_id = app_versions.app_id - ) - AND NOT EXISTS ( - SELECT 1 - FROM public.channels - WHERE channels.app_id = app_versions.app_id - AND channels.version = app_versions.id - ); -END; -$$; - -ALTER FUNCTION "public"."update_app_versions_retention" () OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."verify_mfa"() RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - RETURN ( - array[(SELECT coalesce(auth.jwt()->>'aal', 'aal1'))] <@ ( - SELECT - CASE - WHEN count(id) > 0 THEN array['aal2'] - ELSE array['aal1', 'aal2'] - END AS aal - FROM auth.mfa_factors - WHERE (SELECT auth.uid()) = user_id AND status = 'verified' - ) - ) OR ( - EXISTS( - SELECT 1 FROM jsonb_array_elements((SELECT auth.jwt())->'amr') AS amr_elem - WHERE amr_elem->>'method' = 'otp' - ) - ); -END; -$$; - -ALTER FUNCTION "public"."verify_mfa" () OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."apikeys" ( - "id" bigint NOT NULL, - "created_at" timestamp with time zone DEFAULT "now" (), - "user_id" "uuid" NOT NULL, - "key" character varying NOT NULL, - "mode" "public"."key_mode" NOT NULL, - "updated_at" timestamp with time zone DEFAULT "now" (), - "name" character varying NOT NULL, - "limited_to_orgs" "uuid" [] DEFAULT '{}'::"uuid" [], - "limited_to_apps" character varying[] DEFAULT '{}'::character varying[] -); - -ALTER TABLE "public"."apikeys" OWNER TO "postgres"; - -ALTER TABLE "public"."apikeys" -ALTER COLUMN "id" -ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."apikeys_id_seq" START - WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 -); - -ALTER TABLE "public"."app_versions" -ALTER COLUMN "id" -ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."app_versions_id_seq" START - WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 -); - -CREATE TABLE IF NOT EXISTS "public"."app_versions_meta" ( - "created_at" timestamp with time zone DEFAULT "now" (), - "app_id" character varying NOT NULL, - "updated_at" timestamp with time zone DEFAULT "now" (), - "checksum" character varying NOT NULL, - "size" bigint NOT NULL, - "id" bigint NOT NULL, - "fails" bigint DEFAULT '0'::bigint, - "installs" bigint DEFAULT '0'::bigint, - "uninstalls" bigint DEFAULT '0'::bigint, - "owner_org" "uuid" NOT NULL -); - -ALTER TABLE "public"."app_versions_meta" OWNER TO "postgres"; - -ALTER TABLE "public"."app_versions_meta" -ALTER COLUMN "id" -ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."app_versions_meta_id_seq" START - WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 -); - -CREATE TABLE IF NOT EXISTS "public"."apps" ( - "created_at" timestamp with time zone DEFAULT "now" (), - "app_id" character varying NOT NULL, - "icon_url" character varying NOT NULL, - "user_id" "uuid", - "name" character varying, - "last_version" character varying, - "updated_at" timestamp with time zone, - "id" "uuid" DEFAULT "extensions"."uuid_generate_v4" (), - "retention" bigint DEFAULT '2592000'::bigint NOT NULL, - "owner_org" "uuid" NOT NULL, - "default_upload_channel" character varying DEFAULT 'production'::character varying NOT NULL, - "transfer_history" "jsonb" [] DEFAULT '{}'::"jsonb" [] -); - -ALTER TABLE "public"."apps" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."bandwidth_usage" ( - "id" integer NOT NULL, - "device_id" character varying(255) NOT NULL, - "app_id" character varying(255) NOT NULL, - "file_size" bigint NOT NULL, - "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL -); - -ALTER TABLE "public"."bandwidth_usage" OWNER TO "postgres"; - -CREATE SEQUENCE IF NOT EXISTS "public"."bandwidth_usage_id_seq" AS integer START -WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; - -ALTER TABLE "public"."bandwidth_usage_id_seq" OWNER TO "postgres"; - -ALTER SEQUENCE "public"."bandwidth_usage_id_seq" OWNED BY "public"."bandwidth_usage"."id"; - -CREATE TABLE IF NOT EXISTS "public"."channel_devices" ( - "created_at" timestamp with time zone DEFAULT "now" (), - "channel_id" bigint NOT NULL, - "app_id" character varying NOT NULL, - "updated_at" timestamp with time zone DEFAULT "now" () NOT NULL, - "device_id" "text" NOT NULL, - "id" bigint NOT NULL, - "owner_org" "uuid" NOT NULL -); - -ALTER TABLE "public"."channel_devices" OWNER TO "postgres"; - -ALTER TABLE "public"."channel_devices" -ALTER COLUMN "id" -ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."channel_devices_id_seq" START - WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 -); - -CREATE TABLE IF NOT EXISTS "public"."channels" ( - "id" bigint NOT NULL, - "created_at" timestamp with time zone DEFAULT "now" () NOT NULL, - "name" character varying NOT NULL, - "app_id" character varying NOT NULL, - "version" bigint NOT NULL, - "updated_at" timestamp with time zone DEFAULT "now" () NOT NULL, - "public" boolean DEFAULT false NOT NULL, - "disable_auto_update_under_native" boolean DEFAULT true NOT NULL, - "ios" boolean DEFAULT true NOT NULL, - "android" boolean DEFAULT true NOT NULL, - "allow_device_self_set" boolean DEFAULT false NOT NULL, - "allow_emulator" boolean DEFAULT true NOT NULL, - "allow_device" boolean DEFAULT true NOT NULL, - "allow_dev" boolean DEFAULT true NOT NULL, - "allow_prod" boolean DEFAULT true NOT NULL, - "disable_auto_update" "public"."disable_update" DEFAULT 'major'::"public"."disable_update" NOT NULL, - "owner_org" "uuid" NOT NULL, - "created_by" "uuid" NOT NULL -); - -ALTER TABLE "public"."channels" OWNER TO "postgres"; - -ALTER TABLE "public"."channels" -ALTER COLUMN "id" -ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."channel_id_seq" START - WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 -); - -CREATE TABLE IF NOT EXISTS "public"."daily_bandwidth" ( - "id" integer NOT NULL, - "app_id" character varying(255) NOT NULL, - "date" "date" NOT NULL, - "bandwidth" bigint NOT NULL -); - -ALTER TABLE "public"."daily_bandwidth" OWNER TO "postgres"; - -CREATE SEQUENCE IF NOT EXISTS "public"."daily_bandwidth_id_seq" AS integer START -WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; - -ALTER TABLE "public"."daily_bandwidth_id_seq" OWNER TO "postgres"; - -ALTER SEQUENCE "public"."daily_bandwidth_id_seq" OWNED BY "public"."daily_bandwidth"."id"; - -CREATE TABLE IF NOT EXISTS "public"."daily_mau" ( - "id" integer NOT NULL, - "app_id" character varying(255) NOT NULL, - "date" "date" NOT NULL, - "mau" bigint NOT NULL -); - -ALTER TABLE "public"."daily_mau" OWNER TO "postgres"; - -CREATE SEQUENCE IF NOT EXISTS "public"."daily_mau_id_seq" AS integer START -WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; - -ALTER TABLE "public"."daily_mau_id_seq" OWNER TO "postgres"; - -ALTER SEQUENCE "public"."daily_mau_id_seq" OWNED BY "public"."daily_mau"."id"; - -CREATE TABLE IF NOT EXISTS "public"."daily_storage" ( - "id" integer NOT NULL, - "app_id" character varying(255) NOT NULL, - "date" "date" NOT NULL, - "storage" bigint NOT NULL -); - -ALTER TABLE "public"."daily_storage" OWNER TO "postgres"; - -CREATE SEQUENCE IF NOT EXISTS "public"."daily_storage_id_seq" AS integer START -WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; - -ALTER TABLE "public"."daily_storage_id_seq" OWNER TO "postgres"; - -ALTER SEQUENCE "public"."daily_storage_id_seq" OWNED BY "public"."daily_storage"."id"; - -CREATE TABLE IF NOT EXISTS "public"."daily_version" ( - "date" "date" NOT NULL, - "app_id" character varying(255) NOT NULL, - "version_id" bigint NOT NULL, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -); - -ALTER TABLE "public"."daily_version" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."deleted_account" ( - "created_at" timestamp with time zone DEFAULT "now" (), - "email" character varying DEFAULT ''::character varying NOT NULL, - "id" "uuid" DEFAULT "extensions"."uuid_generate_v4" () NOT NULL -); - -ALTER TABLE "public"."deleted_account" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."deleted_apps" ( - "id" bigint NOT NULL, - "created_at" timestamp with time zone DEFAULT "now" (), - "app_id" character varying NOT NULL, - "owner_org" "uuid" NOT NULL, - "deleted_at" timestamp with time zone DEFAULT "now" () -); - -ALTER TABLE "public"."deleted_apps" OWNER TO "postgres"; - -ALTER TABLE "public"."deleted_apps" -ALTER COLUMN "id" -ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."deleted_apps_id_seq" START - WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 -); - -CREATE TABLE IF NOT EXISTS "public"."deploy_history" ( - "id" bigint NOT NULL, - "created_at" timestamp with time zone DEFAULT "now" (), - "updated_at" timestamp with time zone DEFAULT "now" (), - "channel_id" bigint NOT NULL, - "app_id" character varying NOT NULL, - "version_id" bigint NOT NULL, - "deployed_at" timestamp with time zone DEFAULT "now" (), - "created_by" "uuid" NOT NULL, - "owner_org" "uuid" NOT NULL -); - -ALTER TABLE "public"."deploy_history" OWNER TO "postgres"; - -ALTER TABLE "public"."deploy_history" -ALTER COLUMN "id" -ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."deploy_history_id_seq" START - WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 -); - -CREATE TABLE IF NOT EXISTS "public"."device_usage" ( - "id" integer NOT NULL, - "device_id" character varying(255) NOT NULL, - "app_id" character varying(255) NOT NULL, - "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - "org_id" character varying(255) NOT NULL -); - -ALTER TABLE "public"."device_usage" OWNER TO "postgres"; - -CREATE SEQUENCE IF NOT EXISTS "public"."device_usage_id_seq" AS integer START -WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; - -ALTER TABLE "public"."device_usage_id_seq" OWNER TO "postgres"; - -ALTER SEQUENCE "public"."device_usage_id_seq" OWNED BY "public"."device_usage"."id"; - -CREATE TABLE IF NOT EXISTS "public"."devices" ( - "updated_at" timestamp with time zone NOT NULL, - "device_id" "text" NOT NULL, - "version" bigint NOT NULL, - "app_id" character varying(50) NOT NULL, - "platform" "public"."platform_os" NOT NULL, - "plugin_version" character varying(20) DEFAULT '2.3.3'::"text" NOT NULL, - "os_version" character varying(20), - "version_build" character varying(70) DEFAULT 'builtin'::"text", - "custom_id" character varying(36) DEFAULT ''::"text" NOT NULL, - "is_prod" boolean DEFAULT true, - "is_emulator" boolean DEFAULT false, - id bigint generated always as identity not null -); - -ALTER TABLE "public"."devices" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."global_stats" ( - "created_at" timestamp with time zone DEFAULT "now" (), - "date_id" character varying NOT NULL, - "apps" bigint NOT NULL, - "updates" bigint NOT NULL, - "stars" bigint NOT NULL, - "users" bigint DEFAULT '0'::bigint, - "paying" bigint DEFAULT '0'::bigint, - "trial" bigint DEFAULT '0'::bigint, - "need_upgrade" bigint DEFAULT '0'::bigint, - "not_paying" bigint DEFAULT '0'::bigint, - "onboarded" bigint DEFAULT '0'::bigint, - "apps_active" integer DEFAULT 0, - "users_active" integer DEFAULT 0, - "paying_monthly" integer DEFAULT 0, - "paying_yearly" integer DEFAULT 0, - "updates_last_month" bigint DEFAULT '0'::bigint, - "updates_external" bigint DEFAULT '0'::bigint, - "success_rate" double precision, - "plan_solo" bigint DEFAULT 0, - "plan_maker" bigint DEFAULT 0, - "plan_team" bigint DEFAULT 0 -); - -ALTER TABLE "public"."global_stats" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."manifest" ( - "id" integer NOT NULL, - "app_version_id" bigint NOT NULL, - "file_name" character varying NOT NULL, - "s3_path" character varying NOT NULL, - "file_hash" character varying NOT NULL, - "file_size" bigint DEFAULT 0 -); - -ALTER TABLE "public"."manifest" -SET - ( - autovacuum_vacuum_scale_factor = 0.05, -- vacuum after 5% dead rows (default 20%) - autovacuum_analyze_scale_factor = 0.02 -- analyze after 2% changes - ); - -ALTER TABLE "public"."manifest" OWNER TO "postgres"; - -CREATE SEQUENCE IF NOT EXISTS "public"."manifest_id_seq" AS integer START -WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; - -ALTER TABLE "public"."manifest_id_seq" OWNER TO "postgres"; - -ALTER SEQUENCE "public"."manifest_id_seq" OWNED BY "public"."manifest"."id"; - -CREATE TABLE IF NOT EXISTS "public"."notifications" ( - "created_at" timestamp with time zone DEFAULT "now" (), - "updated_at" timestamp with time zone DEFAULT "now" (), - "last_send_at" timestamp with time zone DEFAULT "now" () NOT NULL, - "total_send" bigint DEFAULT '1'::bigint NOT NULL, - "owner_org" "uuid" NOT NULL, - "event" character varying(255) NOT NULL, - "uniq_id" character varying(255) NOT NULL -); - -ALTER TABLE "public"."notifications" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."org_users" ( - "id" bigint NOT NULL, - "created_at" timestamp with time zone DEFAULT "now" (), - "updated_at" timestamp with time zone DEFAULT "now" (), - "user_id" "uuid" NOT NULL, - "org_id" "uuid" NOT NULL, - "app_id" character varying, - "channel_id" bigint, - "user_right" "public"."user_min_right" -); - -ALTER TABLE "public"."org_users" OWNER TO "postgres"; - -ALTER TABLE "public"."org_users" -ALTER COLUMN "id" -ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."org_users_id_seq" START - WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 -); - -CREATE TABLE IF NOT EXISTS "public"."orgs" ( - "id" "uuid" DEFAULT "gen_random_uuid" () NOT NULL, - "created_by" "uuid" NOT NULL, - "created_at" timestamp with time zone DEFAULT "now" (), - "updated_at" timestamp with time zone DEFAULT "now" (), - "logo" "text", - "name" "text" NOT NULL, - "management_email" "text" NOT NULL, - "customer_id" character varying -); - -ALTER TABLE "public"."orgs" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."plans" ( - "created_at" timestamp with time zone DEFAULT "now" () NOT NULL, - "updated_at" timestamp with time zone DEFAULT "now" () NOT NULL, - "name" character varying DEFAULT ''::character varying NOT NULL, - "description" character varying DEFAULT ''::character varying NOT NULL, - "price_m" bigint DEFAULT '0'::bigint NOT NULL, - "price_y" bigint DEFAULT '0'::bigint NOT NULL, - "stripe_id" character varying DEFAULT ''::character varying NOT NULL, - "id" "uuid" DEFAULT "extensions"."uuid_generate_v4" () NOT NULL, - "price_m_id" character varying NOT NULL, - "price_y_id" character varying NOT NULL, - "storage" bigint NOT NULL, - "bandwidth" bigint NOT NULL, - "mau" bigint DEFAULT '0'::bigint NOT NULL, - "market_desc" character varying DEFAULT ''::character varying, - "storage_unit" double precision DEFAULT '0'::double precision, - "bandwidth_unit" double precision DEFAULT '0'::double precision, - "mau_unit" double precision DEFAULT '0'::double precision, - "price_m_storage_id" "text", - "price_m_bandwidth_id" "text", - "price_m_mau_id" "text" -); - -ALTER TABLE "public"."plans" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."stats" ( - "created_at" timestamp with time zone NOT NULL, - "action" "public"."stats_action" NOT NULL, - "device_id" character varying(36) NOT NULL, - "version" bigint NOT NULL, - "app_id" character varying(50) NOT NULL, - "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY -); - -ALTER TABLE "public"."stats" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."storage_usage" ( - "id" integer NOT NULL, - "device_id" character varying(255) NOT NULL, - "app_id" character varying(255) NOT NULL, - "file_size" bigint NOT NULL, - "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL -); - -ALTER TABLE "public"."storage_usage" OWNER TO "postgres"; - -CREATE SEQUENCE IF NOT EXISTS "public"."storage_usage_id_seq" AS integer START -WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; - -ALTER TABLE "public"."storage_usage_id_seq" OWNER TO "postgres"; - -ALTER SEQUENCE "public"."storage_usage_id_seq" OWNED BY "public"."storage_usage"."id"; - -CREATE TABLE IF NOT EXISTS "public"."stripe_info" ( - "created_at" timestamp with time zone DEFAULT "now" () NOT NULL, - "updated_at" timestamp with time zone DEFAULT "now" () NOT NULL, - "subscription_id" character varying, - "customer_id" character varying NOT NULL, - "status" "public"."stripe_status", - "product_id" character varying NOT NULL, - "trial_at" timestamp with time zone DEFAULT "now" () NOT NULL, - "price_id" character varying, - "is_good_plan" boolean DEFAULT true, - "plan_usage" bigint DEFAULT '0'::bigint, - "subscription_metered" "json" DEFAULT '{}'::"json" NOT NULL, - "subscription_anchor_start" timestamp with time zone DEFAULT "now" () NOT NULL, - "subscription_anchor_end" timestamp with time zone DEFAULT "public"."one_month_ahead" () NOT NULL, - "canceled_at" timestamp with time zone, - "mau_exceeded" boolean DEFAULT false, - "storage_exceeded" boolean DEFAULT false, - "bandwidth_exceeded" boolean DEFAULT false, - "id" integer NOT NULL -); - -ALTER TABLE "public"."stripe_info" OWNER TO "postgres"; - -CREATE SEQUENCE IF NOT EXISTS "public"."stripe_info_id_seq" AS integer START -WITH - 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; - -ALTER TABLE "public"."stripe_info_id_seq" OWNER TO "postgres"; - -ALTER SEQUENCE "public"."stripe_info_id_seq" OWNED BY "public"."stripe_info"."id"; - -CREATE TABLE IF NOT EXISTS "public"."users" ( - "created_at" timestamp with time zone DEFAULT "now" (), - "image_url" character varying, - "first_name" character varying, - "last_name" character varying, - "country" character varying, - "email" character varying NOT NULL, - "id" "uuid" NOT NULL, - "updated_at" timestamp with time zone DEFAULT "now" (), - "enableNotifications" boolean DEFAULT false NOT NULL, - "optForNewsletters" boolean DEFAULT false NOT NULL, - "legalAccepted" boolean DEFAULT false NOT NULL, - "ban_time" timestamp with time zone -); - -ALTER TABLE "public"."users" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."version_meta" ( - "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - "app_id" character varying(255) NOT NULL, - "version_id" bigint NOT NULL, - "size" bigint NOT NULL -); - -ALTER TABLE "public"."version_meta" OWNER TO "postgres"; - -CREATE TABLE IF NOT EXISTS "public"."version_usage" ( - "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - "app_id" character varying(50) NOT NULL, - "version_id" bigint NOT NULL, - "action" "public"."version_action" NOT NULL -); - -ALTER TABLE "public"."version_usage" OWNER TO "postgres"; - -ALTER TABLE ONLY "public"."bandwidth_usage" -ALTER COLUMN "id" -SET DEFAULT "nextval" ('"public"."bandwidth_usage_id_seq"'::"regclass"); - -ALTER TABLE ONLY "public"."daily_bandwidth" -ALTER COLUMN "id" -SET DEFAULT "nextval" ('"public"."daily_bandwidth_id_seq"'::"regclass"); - -ALTER TABLE ONLY "public"."daily_mau" -ALTER COLUMN "id" -SET DEFAULT "nextval" ('"public"."daily_mau_id_seq"'::"regclass"); - -ALTER TABLE ONLY "public"."daily_storage" -ALTER COLUMN "id" -SET DEFAULT "nextval" ('"public"."daily_storage_id_seq"'::"regclass"); - -ALTER TABLE ONLY "public"."device_usage" -ALTER COLUMN "id" -SET DEFAULT "nextval" ('"public"."device_usage_id_seq"'::"regclass"); - -ALTER TABLE ONLY "public"."manifest" -ALTER COLUMN "id" -SET DEFAULT "nextval" ('"public"."manifest_id_seq"'::"regclass"); - -ALTER TABLE ONLY "public"."storage_usage" -ALTER COLUMN "id" -SET DEFAULT "nextval" ('"public"."storage_usage_id_seq"'::"regclass"); - -ALTER TABLE ONLY "public"."stripe_info" -ALTER COLUMN "id" -SET DEFAULT "nextval" ('"public"."stripe_info_id_seq"'::"regclass"); - -ALTER TABLE ONLY "public"."apikeys" -ADD CONSTRAINT "apikeys_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."app_versions_meta" -ADD CONSTRAINT "app_versions_meta_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."app_versions" -ADD CONSTRAINT "app_versions_name_app_id_key" UNIQUE ("name", "app_id"); - -ALTER TABLE ONLY "public"."app_versions" -ADD CONSTRAINT "app_versions_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."apps" -ADD CONSTRAINT "apps_pkey" PRIMARY KEY ("app_id"); - -ALTER TABLE ONLY "public"."bandwidth_usage" -ADD CONSTRAINT "bandwidth_usage_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."channel_devices" -ADD CONSTRAINT "channel_devices_app_id_device_id_key" UNIQUE ("app_id", "device_id"); - -ALTER TABLE ONLY "public"."channels" -ADD CONSTRAINT "channel_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."daily_bandwidth" -ADD CONSTRAINT "daily_bandwidth_pkey" PRIMARY KEY ("app_id", "date"); - -ALTER TABLE ONLY "public"."daily_mau" -ADD CONSTRAINT "daily_mau_pkey" PRIMARY KEY ("app_id", "date"); - -ALTER TABLE ONLY "public"."daily_storage" -ADD CONSTRAINT "daily_storage_pkey" PRIMARY KEY ("app_id", "date"); - -ALTER TABLE ONLY "public"."daily_version" -ADD CONSTRAINT "daily_version_pkey" PRIMARY KEY ("date", "app_id", "version_id"); - -ALTER TABLE ONLY "public"."deleted_account" -ADD CONSTRAINT "deleted_account_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."deleted_apps" -ADD CONSTRAINT "deleted_apps_app_id_owner_org_key" UNIQUE ("app_id", "owner_org"); - -ALTER TABLE ONLY "public"."deleted_apps" -ADD CONSTRAINT "deleted_apps_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."deploy_history" -ADD CONSTRAINT "deploy_history_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."device_usage" -ADD CONSTRAINT "device_usage_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."devices" -ADD CONSTRAINT "devices_pkey" PRIMARY KEY ("app_id", "device_id"); - -ALTER TABLE ONLY "public"."global_stats" -ADD CONSTRAINT "global_stats_pkey" PRIMARY KEY ("date_id"); - -ALTER TABLE ONLY "public"."manifest" -ADD CONSTRAINT "manifest_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."notifications" -ADD CONSTRAINT "notifications_pkey" PRIMARY KEY ("owner_org", "event", "uniq_id"); - -ALTER TABLE ONLY "public"."org_users" -ADD CONSTRAINT "org_users_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."orgs" -ADD CONSTRAINT "orgs_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."plans" -ADD CONSTRAINT "plans_pkey" PRIMARY KEY ("name", "stripe_id", "id"); - -ALTER TABLE ONLY "public"."plans" -ADD CONSTRAINT "plans_stripe_id_key" UNIQUE ("stripe_id"); - -ALTER TABLE ONLY "public"."storage_usage" -ADD CONSTRAINT "storage_usage_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."stripe_info" -ADD CONSTRAINT "stripe_info_pkey" PRIMARY KEY ("customer_id"); - -ALTER TABLE ONLY "public"."orgs" -ADD CONSTRAINT "unique customer_id on orgs" UNIQUE ("customer_id"); - -ALTER TABLE ONLY "public"."channel_devices" -ADD CONSTRAINT "unique_device_app" UNIQUE ("device_id", "app_id"); - -ALTER TABLE ONLY "public"."channels" -ADD CONSTRAINT "unique_name_app_id" UNIQUE ("name", "app_id"); - -ALTER TABLE ONLY "public"."orgs" -ADD CONSTRAINT "unique_name_created_by" UNIQUE ("name", "created_by"); - -ALTER TABLE ONLY "public"."users" -ADD CONSTRAINT "users_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."version_meta" -ADD CONSTRAINT "version_meta_pkey" PRIMARY KEY ("timestamp", "app_id", "version_id", "size"); - -ALTER TABLE ONLY "public"."version_usage" -ADD CONSTRAINT "version_usage_pkey" PRIMARY KEY ("timestamp", "app_id", "version_id", "action"); - -CREATE INDEX IF NOT EXISTS si_customer_status_trial_idx ON public.stripe_info (customer_id, status, trial_at) INCLUDE ( - mau_exceeded, - storage_exceeded, - bandwidth_exceeded -); - -CREATE INDEX IF NOT EXISTS orgs_updated_at_id_idx ON public.orgs (updated_at DESC) INCLUDE (id) -WHERE - customer_id IS NOT NULL; - -CREATE INDEX "apikeys_key_idx" ON "public"."apikeys" USING "btree" ("key"); - -CREATE INDEX "app_versions_meta_app_id_idx" ON "public"."app_versions_meta" USING "btree" ("app_id"); - -CREATE INDEX "deploy_history_app_id_idx" ON "public"."deploy_history" USING "btree" ("app_id"); - -CREATE INDEX "deploy_history_app_version_idx" ON "public"."deploy_history" USING "btree" ("app_id", "version_id"); - -CREATE INDEX "deploy_history_channel_app_idx" ON "public"."deploy_history" USING "btree" ("channel_id", "app_id"); - -CREATE INDEX "deploy_history_channel_deployed_idx" ON "public"."deploy_history" USING "btree" ("channel_id", "deployed_at"); - -CREATE INDEX "deploy_history_channel_id_idx" ON "public"."deploy_history" USING "btree" ("channel_id"); - -CREATE INDEX "deploy_history_deployed_at_idx" ON "public"."deploy_history" USING "btree" ("deployed_at"); - -CREATE INDEX "deploy_history_version_id_idx" ON "public"."deploy_history" USING "btree" ("version_id"); - -CREATE INDEX "devices_app_id_device_id_updated_at_idx" ON "public"."devices" USING "btree" ("app_id", "device_id", "updated_at"); - -CREATE INDEX "devices_app_id_updated_at_idx" ON "public"."devices" USING "btree" ("app_id", "updated_at"); - -CREATE INDEX "finx_apikeys_user_id" ON "public"."apikeys" USING "btree" ("user_id"); - -CREATE INDEX "finx_app_versions_meta_owner_org" ON "public"."app_versions_meta" USING "btree" ("owner_org"); - -CREATE INDEX "finx_app_versions_owner_org" ON "public"."app_versions" USING "btree" ("owner_org"); - -CREATE INDEX "finx_apps_owner_org" ON "public"."apps" USING "btree" ("owner_org"); - -CREATE INDEX "finx_apps_user_id" ON "public"."apps" USING "btree" ("user_id"); - -CREATE INDEX "finx_channel_devices_app_id" ON "public"."channel_devices" USING "btree" ("app_id"); - -CREATE INDEX "finx_channel_devices_channel_id" ON "public"."channel_devices" USING "btree" ("channel_id"); - -CREATE INDEX "finx_channel_devices_owner_org" ON "public"."channel_devices" USING "btree" ("owner_org"); - -CREATE INDEX "finx_channels_app_id" ON "public"."channels" USING "btree" ("app_id"); - -CREATE INDEX "finx_channels_owner_org" ON "public"."channels" USING "btree" ("owner_org"); - -CREATE INDEX "finx_channels_version" ON "public"."channels" USING "btree" ("version"); - -CREATE INDEX "finx_org_users_channel_id" ON "public"."org_users" USING "btree" ("channel_id"); - -CREATE INDEX "finx_org_users_org_id" ON "public"."org_users" USING "btree" ("org_id"); - -CREATE INDEX "finx_org_users_user_id" ON "public"."org_users" USING "btree" ("user_id"); - -CREATE INDEX "finx_orgs_created_by" ON "public"."orgs" USING "btree" ("created_by"); - -CREATE INDEX "finx_orgs_stripe_info" ON "public"."stripe_info" USING "btree" ("product_id"); - -CREATE INDEX "idx_app_id_app_versions" ON "public"."app_versions" USING "btree" ("app_id"); - -CREATE UNIQUE INDEX "idx_app_id_device_id_channel_id_channel_devices" ON "public"."channel_devices" USING "btree" ("app_id", "device_id", "channel_id"); - -CREATE INDEX "idx_app_id_name_app_versions" ON "public"."app_versions" USING "btree" ("app_id", "name"); - -CREATE INDEX "idx_app_id_public_channel" ON "public"."channels" USING "btree" ("app_id", "public"); - -CREATE INDEX "idx_app_id_version_devices" ON "public"."devices" USING "btree" ("app_id", "version"); - -CREATE INDEX "idx_app_versions_created_at" ON "public"."app_versions" USING "btree" ("created_at"); - -CREATE INDEX "idx_app_versions_created_at_app_id" ON "public"."app_versions" USING "btree" ("created_at", "app_id"); - -CREATE INDEX "idx_app_versions_deleted" ON "public"."app_versions" USING "btree" ("deleted"); - -CREATE INDEX "idx_app_versions_retention_cleanup" ON "public"."app_versions" USING "btree" ("deleted", "created_at", "app_id") -WHERE - ("deleted" = false); - -CREATE INDEX "idx_app_versions_id" ON "public"."app_versions" USING "btree" ("id"); - -CREATE INDEX "idx_app_versions_meta_id" ON "public"."app_versions_meta" USING "btree" ("id"); - -CREATE INDEX "idx_app_versions_name" ON "public"."app_versions" USING "btree" ("name"); - -CREATE INDEX "idx_channels_app_id_name" ON "public"."channels" USING "btree" ("app_id", "name"); - -CREATE INDEX "idx_channels_app_id_version" ON "public"."channels" USING "btree" ("app_id", "version"); - -CREATE INDEX "idx_channels_public_app_id_android" ON "public"."channels" USING "btree" ("public", "app_id", "android"); - -CREATE INDEX "idx_channels_public_app_id_ios" ON "public"."channels" USING "btree" ("public", "app_id", "ios"); - -CREATE INDEX "idx_daily_bandwidth_app_id_date" ON "public"."daily_bandwidth" USING "btree" ("app_id", "date"); - -CREATE INDEX "idx_daily_mau_app_id_date" ON "public"."daily_mau" USING "btree" ("app_id", "date"); - -CREATE INDEX "idx_daily_storage_app_id_date" ON "public"."daily_storage" USING "btree" ("app_id", "date"); - -CREATE INDEX "idx_daily_version_app_id" ON "public"."daily_version" USING "btree" ("app_id"); - -CREATE INDEX "idx_deleted_apps_app_id" ON "public"."deleted_apps" USING "btree" ("app_id"); - -CREATE INDEX "idx_deleted_apps_deleted_at" ON "public"."deleted_apps" USING "btree" ("deleted_at"); - -CREATE INDEX "idx_deleted_apps_owner_org" ON "public"."deleted_apps" USING "btree" ("owner_org"); - -CREATE INDEX "idx_deploy_history_created_by" ON "public"."deploy_history" USING "btree" ("created_by"); - -CREATE INDEX "idx_manifest_app_version_id" ON "public"."manifest" USING "btree" ("app_version_id"); - -CREATE INDEX "idx_orgs_customer_id" ON "public"."orgs" USING "btree" ("customer_id"); - -CREATE INDEX "idx_stats_app_id_action" ON "public"."stats" USING "btree" ("app_id", "action"); - -CREATE INDEX "idx_stats_app_id_created_at" ON "public"."stats" USING "btree" ("app_id", "created_at"); - -CREATE INDEX "idx_stats_app_id_device_id" ON "public"."stats" USING "btree" ("app_id", "device_id"); - -CREATE INDEX "idx_stats_app_id_version" ON "public"."stats" USING "btree" ("app_id", "version"); - -CREATE INDEX "idx_stripe_info_customer_id" ON "public"."stripe_info" USING "btree" ("customer_id"); - -CREATE INDEX "idx_stripe_info_status_plan" ON "public"."stripe_info" USING "btree" ("status", "is_good_plan") -WHERE - ( - ("status" = 'succeeded'::"public"."stripe_status") - AND ("is_good_plan" = true) - ); - -CREATE INDEX "idx_stripe_info_trial" ON "public"."stripe_info" USING "btree" ("trial_at") -WHERE - ("trial_at" IS NOT NULL); - -CREATE INDEX "org_users_app_id_idx" ON "public"."org_users" USING "btree" ("app_id"); - -CREATE OR REPLACE TRIGGER "check_if_org_can_exist_org_users" -AFTER DELETE ON "public"."org_users" FOR EACH ROW -EXECUTE FUNCTION "public"."check_if_org_can_exist" (); - -CREATE OR REPLACE TRIGGER "check_privileges" BEFORE INSERT -OR -UPDATE ON "public"."org_users" FOR EACH ROW -EXECUTE FUNCTION "public"."check_org_user_privileges" (); - -CREATE OR REPLACE TRIGGER "force_valid_apikey_name" BEFORE INSERT -OR -UPDATE ON "public"."apikeys" FOR EACH ROW -EXECUTE FUNCTION "public"."auto_apikey_name_by_id" (); - -CREATE OR REPLACE TRIGGER "force_valid_owner_org_app_versions" BEFORE INSERT -OR -UPDATE ON "public"."app_versions" FOR EACH ROW -EXECUTE FUNCTION "public"."auto_owner_org_by_app_id" (); - -CREATE OR REPLACE TRIGGER "force_valid_owner_org_app_versions_meta" BEFORE INSERT -OR -UPDATE ON "public"."app_versions_meta" FOR EACH ROW -EXECUTE FUNCTION "public"."auto_owner_org_by_app_id" (); - -CREATE OR REPLACE TRIGGER "force_valid_owner_org_channel_devices" BEFORE INSERT -OR -UPDATE ON "public"."channel_devices" FOR EACH ROW -EXECUTE FUNCTION "public"."auto_owner_org_by_app_id" (); - -CREATE OR REPLACE TRIGGER "force_valid_owner_org_channels" BEFORE INSERT -OR -UPDATE ON "public"."channels" FOR EACH ROW -EXECUTE FUNCTION "public"."auto_owner_org_by_app_id" (); - -CREATE OR REPLACE TRIGGER "generate_org_on_user_create" -AFTER INSERT ON "public"."users" FOR EACH ROW -EXECUTE FUNCTION "public"."generate_org_on_user_create" (); - -CREATE OR REPLACE TRIGGER "generate_org_user_on_org_create" -AFTER INSERT ON "public"."orgs" FOR EACH ROW -EXECUTE FUNCTION "public"."generate_org_user_on_org_create" (); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."apikeys" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."app_versions" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."app_versions_meta" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."apps" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."channel_devices" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."channels" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."org_users" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."plans" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."stripe_info" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE -UPDATE ON "public"."users" FOR EACH ROW -EXECUTE FUNCTION "extensions"."moddatetime" ('updated_at'); - -CREATE OR REPLACE TRIGGER "noupdate" BEFORE -UPDATE ON "public"."channels" FOR EACH ROW -EXECUTE FUNCTION "public"."noupdate" (); - -CREATE OR REPLACE TRIGGER "on_app_create" -AFTER INSERT ON "public"."apps" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_app_create'); - -CREATE OR REPLACE TRIGGER "on_app_delete" -AFTER DELETE ON "public"."apps" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_app_delete'); - -CREATE OR REPLACE TRIGGER "on_channel_update" -AFTER -UPDATE ON "public"."channels" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_channel_update'); - -CREATE OR REPLACE TRIGGER "on_manifest_create" -AFTER INSERT ON "public"."manifest" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_manifest_create'); - -CREATE OR REPLACE TRIGGER "on_org_create" -AFTER INSERT ON "public"."orgs" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_organization_create'); - -CREATE OR REPLACE TRIGGER "on_organization_delete" -AFTER DELETE ON "public"."orgs" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_organization_delete'); - -CREATE OR REPLACE TRIGGER "on_user_create" -AFTER INSERT ON "public"."users" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_user_create'); - -CREATE OR REPLACE TRIGGER "on_user_delete" -AFTER DELETE ON "public"."users" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_user_delete'); - -CREATE OR REPLACE TRIGGER "on_user_update" -AFTER -UPDATE ON "public"."users" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_user_update'); - -CREATE OR REPLACE TRIGGER "on_version_create" -AFTER INSERT ON "public"."app_versions" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_version_create'); - -CREATE OR REPLACE TRIGGER "on_version_delete" -AFTER DELETE ON "public"."app_versions" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_version_delete'); - -CREATE OR REPLACE TRIGGER "on_version_update" -AFTER -UPDATE ON "public"."app_versions" FOR EACH ROW -EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_version_update'); - -CREATE OR REPLACE TRIGGER "record_deployment_history_trigger" -AFTER -UPDATE OF "version" ON "public"."channels" FOR EACH ROW -EXECUTE FUNCTION "public"."record_deployment_history" (); - -ALTER TABLE ONLY "public"."apikeys" -ADD CONSTRAINT "apikeys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."app_versions" -ADD CONSTRAINT "app_versions_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps" ("app_id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."app_versions_meta" -ADD CONSTRAINT "app_versions_meta_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps" ("app_id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."app_versions_meta" -ADD CONSTRAINT "app_versions_meta_id_fkey" FOREIGN KEY ("id") REFERENCES "public"."app_versions" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."apps" -ADD CONSTRAINT "apps_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."channel_devices" -ADD CONSTRAINT "channel_devices_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps" ("app_id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."channel_devices" -ADD CONSTRAINT "channel_devices_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "public"."channels" ("id"); - -ALTER TABLE ONLY "public"."channels" -ADD CONSTRAINT "channels_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps" ("app_id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."channels" -ADD CONSTRAINT "channels_version_fkey" FOREIGN KEY ("version") REFERENCES "public"."app_versions" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."deploy_history" -ADD CONSTRAINT "deploy_history_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps" ("app_id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."deploy_history" -ADD CONSTRAINT "deploy_history_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "public"."channels" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."deploy_history" -ADD CONSTRAINT "deploy_history_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."users" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."deploy_history" -ADD CONSTRAINT "deploy_history_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "public"."app_versions" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."manifest" -ADD CONSTRAINT "manifest_app_version_id_fkey" FOREIGN KEY ("app_version_id") REFERENCES "public"."app_versions" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."org_users" -ADD CONSTRAINT "org_users_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps" ("app_id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."org_users" -ADD CONSTRAINT "org_users_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "public"."channels" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."org_users" -ADD CONSTRAINT "org_users_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."org_users" -ADD CONSTRAINT "org_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."orgs" -ADD CONSTRAINT "orgs_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."users" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."orgs" -ADD CONSTRAINT "orgs_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."stripe_info" ("customer_id"); - -ALTER TABLE ONLY "public"."apps" -ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."app_versions" -ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."app_versions_meta" -ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."channel_devices" -ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."channels" -ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."notifications" -ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs" ("id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."stripe_info" -ADD CONSTRAINT "stripe_info_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "public"."plans" ("stripe_id"); - -ALTER TABLE ONLY "public"."users" -ADD CONSTRAINT "users_id_fkey" FOREIGN KEY ("id") REFERENCES "auth"."users" ("id") ON DELETE CASCADE; - -CREATE POLICY "Allow all for auth (super_admin+)" ON "public"."app_versions" FOR DELETE TO "authenticated" USING ( - "public"."check_min_rights" ( - 'super_admin'::"public"."user_min_right", - "public"."get_identity" (), - "owner_org", - "app_id", - NULL::bigint - ) -); - -CREATE POLICY "Allow for auth, api keys (read+)" ON "public"."app_versions" FOR -SELECT - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'read'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{read,upload,write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow all for auth (super_admin+)" ON "public"."apps" FOR DELETE TO "authenticated" USING ( - "public"."check_min_rights" ( - 'super_admin'::"public"."user_min_right", - "public"."get_identity" (), - "owner_org", - "app_id", - NULL::bigint - ) -); - -CREATE POLICY "Allow for auth, api keys (read+)" ON "public"."apps" FOR -SELECT - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'read'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{read,upload,write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow anon to select" ON "public"."global_stats" FOR -SELECT - TO "anon" USING (true); - -CREATE POLICY "Allow apikey to read" ON "public"."stats" FOR -SELECT - TO "anon" USING ( - "public"."is_allowed_capgkey" ( - ( - SELECT - "public"."get_apikey_header" () - ), - '{all,write}'::"public"."key_mode" [], - "app_id" - ) - ); - -CREATE POLICY "Allow delete for auth, api keys (write+)" ON "public"."channel_devices" FOR DELETE TO "authenticated", -"anon" USING ( - "public"."check_min_rights" ( - 'write'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) -); - -CREATE POLICY "Allow insert for api keys (write,all,upload) (upload+)" ON "public"."app_versions" FOR INSERT TO "anon" -WITH - CHECK ( - "public"."check_min_rights" ( - 'upload'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all,upload}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow insert for apikey (write,all) (admin+)" ON "public"."apps" FOR INSERT TO "anon" -WITH - CHECK ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow insert for auth (write+)" ON "public"."channel_devices" FOR INSERT TO "authenticated" -WITH - CHECK ( - "public"."check_min_rights" ( - 'write'::"public"."user_min_right", - "public"."get_identity" (), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow org delete for super_admin" ON "public"."orgs" FOR DELETE TO "authenticated" USING ( - "public"."check_min_rights" ( - 'super_admin'::"public"."user_min_right", - "public"."get_identity" (), - "id", - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow owner to update" ON "public"."devices" -FOR UPDATE - TO "authenticated" USING ( - "public"."is_app_owner" ( - ( - SELECT - "auth"."uid" () AS "uid" - ), - "app_id" - ) - ) -WITH - CHECK ( - "public"."is_app_owner" ( - ( - SELECT - "auth"."uid" () AS "uid" - ), - "app_id" - ) - ); - -CREATE POLICY "Allow devices select" ON "public"."devices" FOR -SELECT - TO "authenticated" USING ( - "public"."is_admin" ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - ) - OR "public"."is_app_owner" ( - ( - SELECT - "auth"."uid" () AS "uid" - ), - "app_id" - ) - OR "public"."has_app_right_userid" ( - "app_id", - 'read'::"public"."user_min_right", - "public"."get_identity" () - ) - OR "public"."check_min_rights" ( - 'read'::"public"."user_min_right", - ( - SELECT - "auth"."uid" () AS "uid" - ), - "public"."get_user_main_org_id_by_app_id" (("app_id")::"text"), - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow read for auth (read+)" ON "public"."app_versions_meta" FOR -SELECT - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'read'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{read,upload,write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow read for auth, api keys (read+)" ON "public"."channel_devices" FOR -SELECT - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'read'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{read,upload,write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_bandwidth" FOR -SELECT - TO "authenticated" USING ( - "public"."has_app_right_userid" ( - "app_id", - 'read'::"public"."user_min_right", - "public"."get_identity" () - ) - ); - -CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_mau" FOR -SELECT - TO "authenticated" USING ( - "public"."has_app_right_userid" ( - "app_id", - 'read'::"public"."user_min_right", - "public"."get_identity" () - ) - ); - -CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_storage" FOR -SELECT - TO "authenticated" USING ( - "public"."has_app_right_userid" ( - "app_id", - 'read'::"public"."user_min_right", - "public"."get_identity" () - ) - ); - -CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_version" FOR -SELECT - TO "authenticated" USING ( - "public"."has_app_right_userid" ( - "app_id", - 'read'::"public"."user_min_right", - "public"."get_identity" () - ) - ); - -CREATE POLICY "Allow read for auth (read+)" ON "public"."stats" FOR -SELECT - TO "authenticated" USING ( - "public"."has_app_right_userid" ( - "app_id", - 'read'::"public"."user_min_right", - "public"."get_identity" () - ) - ); - -CREATE POLICY "Allow select for auth, api keys (read+)" ON "public"."orgs" FOR -SELECT - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'read'::"public"."user_min_right", - "public"."get_identity_org_allowed" ( - '{read,upload,write,all}'::"public"."key_mode" [], - "id" - ), - "id", - NULL::character varying, - NULL::bigint - ) - ); - -CREATE POLICY "Allow self to modify self" ON "public"."users" TO "authenticated" USING ( - ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) = "id" - ) - AND "public"."is_not_deleted" ( - ( - ( - SELECT - "auth"."email" () AS "email" - ) - )::character varying - ) - ) - OR "public"."is_admin" ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - ) - ) -) -WITH - CHECK ( - ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) = "id" - ) - AND "public"."is_not_deleted" ( - ( - ( - SELECT - "auth"."email" () AS "email" - ) - )::character varying - ) - ) - OR "public"."is_admin" ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - ) - ) - ); - --- SELECT -CREATE POLICY "Allow member and owner to select" ON "public"."org_users" FOR -SELECT - TO "authenticated", - "anon" USING ( - "public"."is_member_of_org" ( - ( - SELECT - "public"."get_identity_org_allowed" ( - '{read,upload,write,all}'::"public"."key_mode" [], - "org_users"."org_id" - ) AS "get_identity_org_allowed" - ), - "org_id" - ) - ); - --- UPDATE -CREATE POLICY "Allow org admin to update" ON "public"."org_users" -FOR UPDATE - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - ( - SELECT - "public"."get_identity_org_allowed" ( - '{all}'::"public"."key_mode" [], - "org_users"."org_id" - ) AS "get_identity_org_allowed" - ), - "org_id", - NULL::character varying, - NULL::bigint - ) - ) -WITH - CHECK ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - ( - SELECT - "public"."get_identity_org_allowed" ( - '{all}'::"public"."key_mode" [], - "org_users"."org_id" - ) AS "get_identity_org_allowed" - ), - "org_id", - NULL::character varying, - NULL::bigint - ) - ); - --- DELETE -CREATE POLICY "Allow to self delete" ON "public"."org_users" FOR DELETE TO "authenticated", -"anon" USING ( - ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - ( - SELECT - "public"."get_identity_org_allowed" ( - '{all}'::"public"."key_mode" [], - "org_users"."org_id" - ) AS "get_identity_org_allowed" - ), - "org_id", - NULL::character varying, - NULL::bigint - ) - ) - OR ( - "user_id" = ( - SELECT - "public"."get_identity_org_allowed" ( - '{read,upload,write,all}'::"public"."key_mode" [], - "org_users"."org_id" - ) AS "get_identity_org_allowed" - ) - ) -); - --- INSERT -CREATE POLICY "Allow org admin to insert" ON "public"."org_users" FOR INSERT TO "authenticated", -"anon" -WITH - CHECK ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - ( - SELECT - "public"."get_identity_org_allowed" ( - '{all}'::"public"."key_mode" [], - "org_users"."org_id" - ) AS "get_identity_org_allowed" - ), - "org_id", - NULL::character varying, - NULL::bigint - ) - ); - -CREATE POLICY "Allow update for auth (admin+)" ON "public"."orgs" -FOR UPDATE - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - "public"."get_identity_org_allowed" ('{all,write}'::"public"."key_mode" [], "id"), - "id", - NULL::character varying, - NULL::bigint - ) - ) -WITH - CHECK ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - "public"."get_identity_org_allowed" ('{all,write}'::"public"."key_mode" [], "id"), - "id", - NULL::character varying, - NULL::bigint - ) - ); - -CREATE POLICY "Allow update for auth (write+)" ON "public"."app_versions" -FOR UPDATE - TO "authenticated" USING ( - "public"."check_min_rights" ( - 'write'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all,upload}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ) -WITH - CHECK ( - "public"."check_min_rights" ( - 'write'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all,upload}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow update for api keys (write,all,upload) (upload+)" ON "public"."app_versions" -FOR UPDATE - TO "anon" USING ( - "public"."check_min_rights" ( - 'upload'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all,upload}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ) -WITH - CHECK ( - "public"."check_min_rights" ( - 'upload'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all,upload}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow update for auth, api keys (write+)" ON "public"."channel_devices" -FOR UPDATE - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'write'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ) -WITH - CHECK ( - "public"."check_min_rights" ( - 'write'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow update for auth, api keys (write, all) (admin+)" ON "public"."apps" -FOR UPDATE - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ) -WITH - CHECK ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow delete for auth (admin+) (all apikey)" ON "public"."channels" FOR DELETE TO "authenticated", -"anon" USING ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) -); - -CREATE POLICY "Allow insert for auth, api keys (write, all) (admin+)" ON "public"."channels" FOR INSERT TO "authenticated", -"anon" -WITH - CHECK ( - "public"."check_min_rights" ( - 'admin'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow select for auth, api keys (read+)" ON "public"."channels" FOR -SELECT - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'read'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{read,upload,write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow update for auth, api keys (write, all) (write+)" ON "public"."channels" -FOR UPDATE - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'write'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ) -WITH - CHECK ( - "public"."check_min_rights" ( - 'write'::"public"."user_min_right", - "public"."get_identity_org_appid" ( - '{write,all}'::"public"."key_mode" [], - "owner_org", - "app_id" - ), - "owner_org", - "app_id", - NULL::bigint - ) - ); - -CREATE POLICY "Allow users to delete manifest entries" ON "public"."manifest" FOR DELETE TO "authenticated" USING ( - ( - EXISTS ( - SELECT - 1 - FROM - ( - "public"."app_versions" "av" - JOIN "public"."apps" "a" ON ( - (("av"."app_id")::"text" = ("a"."app_id")::"text") - ) - ) - WHERE - ( - ("av"."id" = "manifest"."app_version_id") - AND ( - "a"."owner_org" IN ( - SELECT - "o"."id" - FROM - "public"."orgs" "o" - WHERE - ( - "o"."id" IN ( - SELECT - "ou"."org_id" - FROM - "public"."org_users" "ou" - WHERE - ( - "ou"."user_id" = ( - SELECT - "auth"."uid" () AS "uid" - ) - ) - ) - ) - ) - ) - ) - ) - ) -); - -CREATE POLICY "Allow users to insert manifest entries" ON "public"."manifest" FOR INSERT TO "authenticated" -WITH - CHECK ( - ( - EXISTS ( - SELECT - 1 - FROM - ( - "public"."app_versions" "av" - JOIN "public"."apps" "a" ON ( - (("av"."app_id")::"text" = ("a"."app_id")::"text") - ) - ) - WHERE - ( - ("av"."id" = "manifest"."app_version_id") - AND ( - "a"."owner_org" IN ( - SELECT - "o"."id" - FROM - "public"."orgs" "o" - WHERE - ( - "o"."id" IN ( - SELECT - "ou"."org_id" - FROM - "public"."org_users" "ou" - WHERE - ( - "ou"."user_id" = ( - SELECT - "auth"."uid" () AS "uid" - ) - ) - ) - ) - ) - ) - ) - ) - ) - ); - -CREATE POLICY "Allow users to read any manifest entry" ON "public"."manifest" FOR -SELECT - TO "authenticated" USING (true); - -CREATE POLICY "Allow users to view deploy history for their org" ON "public"."deploy_history" FOR -SELECT - TO "authenticated" USING ( - ( - SELECT - ( - select - auth.uid () - ) IN ( - SELECT - public."org_users"."user_id" - FROM - "public"."org_users" - WHERE - ( - "org_users"."org_id" = "deploy_history"."owner_org" - ) - ) - ) - ); - -CREATE POLICY "Allow users with write permissions to insert deploy history" ON "public"."deploy_history" FOR INSERT -WITH - CHECK (false); - -CREATE POLICY "Allow webapp to insert" ON "public"."orgs" FOR INSERT TO "authenticated" -WITH - CHECK ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) = "created_by" - ) - ); - -CREATE POLICY "Deny delete on deploy history" ON "public"."deploy_history" FOR DELETE USING (false); - -CREATE POLICY "Disable for all" ON "public"."bandwidth_usage" USING (false) -WITH - CHECK (false); - -CREATE POLICY "Disable for all" ON "public"."device_usage" USING (false) -WITH - CHECK (false); - -CREATE POLICY "Disable for all" ON "public"."notifications" USING (false) -WITH - CHECK (false); - -CREATE POLICY "Disable for all" ON "public"."storage_usage" USING (false) -WITH - CHECK (false); - -CREATE POLICY "Disable for all" ON "public"."version_meta" USING (false) -WITH - CHECK (false); - -CREATE POLICY "Disable for all" ON "public"."version_usage" USING (false) -WITH - CHECK (false); - -CREATE POLICY "Enable all for user based on user_id" ON "public"."apikeys" TO "authenticated" USING ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) = "user_id" - ) -) -WITH - CHECK ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) = "user_id" - ) - ); - -CREATE POLICY "Enable select for anyone" ON "public"."plans" FOR -SELECT - TO "authenticated", - "anon" USING (true); - -CREATE POLICY "Enable update for users based on email" ON "public"."deleted_account" TO "authenticated" -WITH - CHECK ( - ( - "encode" ( - "extensions"."digest" ( - ( - SELECT - "auth"."email" () AS "email" - ), - 'sha256'::"text" - ), - 'hex'::"text" - ) = ("email")::"text" - ) - ); - -CREATE POLICY "Prevent non 2FA access" ON "public"."apikeys" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa" ()); - -CREATE POLICY "Prevent non 2FA access" ON "public"."app_versions" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa" ()); - -CREATE POLICY "Prevent non 2FA access" ON "public"."apps" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa" ()); - -CREATE POLICY "Prevent non 2FA access" ON "public"."channel_devices" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa" ()); - -CREATE POLICY "Prevent non 2FA access" ON "public"."channels" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa" ()); - -CREATE POLICY "Prevent non 2FA access" ON "public"."org_users" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa" ()); - -CREATE POLICY "Prevent non 2FA access" ON "public"."orgs" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa" ()); - -CREATE POLICY "Prevent update on deploy history" ON "public"."deploy_history" -FOR UPDATE - USING (false) -WITH - CHECK (false); - -CREATE POLICY "Prevent users from updating manifest entries" ON "public"."manifest" -FOR UPDATE - TO "authenticated" USING (false); - -ALTER TABLE "public"."apikeys" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."app_versions" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."app_versions_meta" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."apps" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."bandwidth_usage" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."channel_devices" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."channels" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."daily_bandwidth" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."daily_mau" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."daily_storage" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."daily_version" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."deleted_account" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."deleted_apps" ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "deny_all_access" ON "public"."deleted_apps" USING (false) -WITH - CHECK (false); - -ALTER TABLE "public"."deploy_history" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."device_usage" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."devices" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."global_stats" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."manifest" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."notifications" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."org_users" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."orgs" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."plans" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."stats" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."storage_usage" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."stripe_info" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."users" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."version_meta" ENABLE ROW LEVEL SECURITY; - -ALTER TABLE "public"."version_usage" ENABLE ROW LEVEL SECURITY; - -REVOKE USAGE ON SCHEMA "public" -FROM - PUBLIC; - -GRANT USAGE ON SCHEMA "public" TO "anon"; - -GRANT USAGE ON SCHEMA "public" TO "authenticated"; - -GRANT USAGE ON SCHEMA "public" TO "service_role"; - -GRANT ALL ON FUNCTION "public"."accept_invitation_to_org" ("org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."accept_invitation_to_org" ("org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."accept_invitation_to_org" ("org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."auto_apikey_name_by_id" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."auto_apikey_name_by_id" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."auto_apikey_name_by_id" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."auto_owner_org_by_app_id" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."auto_owner_org_by_app_id" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."auto_owner_org_by_app_id" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."check_if_org_can_exist" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."check_if_org_can_exist" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."check_if_org_can_exist" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."check_org_user_privileges" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."check_org_user_privileges" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."check_org_user_privileges" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."check_revert_to_builtin_version" ("appid" character varying) TO "anon"; - -GRANT ALL ON FUNCTION "public"."check_revert_to_builtin_version" ("appid" character varying) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."check_revert_to_builtin_version" ("appid" character varying) TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."cleanup_frequent_job_details" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."cleanup_frequent_job_details" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."cleanup_frequent_job_details" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."cleanup_frequent_job_details" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."cleanup_queue_messages" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."cleanup_queue_messages" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."cleanup_queue_messages" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."cleanup_queue_messages" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."convert_bytes_to_gb" ("bytes_value" double precision) TO "anon"; - -GRANT ALL ON FUNCTION "public"."convert_bytes_to_gb" ("bytes_value" double precision) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."convert_bytes_to_gb" ("bytes_value" double precision) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."convert_bytes_to_mb" ("bytes_value" double precision) TO "anon"; - -GRANT ALL ON FUNCTION "public"."convert_bytes_to_mb" ("bytes_value" double precision) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."convert_bytes_to_mb" ("bytes_value" double precision) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."convert_gb_to_bytes" ("gb" double precision) TO "anon"; - -GRANT ALL ON FUNCTION "public"."convert_gb_to_bytes" ("gb" double precision) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."convert_gb_to_bytes" ("gb" double precision) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."convert_mb_to_bytes" ("gb" double precision) TO "anon"; - -GRANT ALL ON FUNCTION "public"."convert_mb_to_bytes" ("gb" double precision) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."convert_mb_to_bytes" ("gb" double precision) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."convert_number_to_percent" ( - "val" double precision, - "max_val" double precision -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."convert_number_to_percent" ( - "val" double precision, - "max_val" double precision -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."convert_number_to_percent" ( - "val" double precision, - "max_val" double precision -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."count_active_users" ("app_ids" character varying[]) TO "anon"; - -GRANT ALL ON FUNCTION "public"."count_active_users" ("app_ids" character varying[]) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."count_active_users" ("app_ids" character varying[]) TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."count_all_need_upgrade" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."count_all_need_upgrade" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."count_all_need_upgrade" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."count_all_need_upgrade" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."count_all_onboarded" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."count_all_onboarded" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."count_all_onboarded" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."count_all_onboarded" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."count_all_plans_v2" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."count_all_plans_v2" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."count_all_plans_v2" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."count_all_plans_v2" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."delete_http_response" ("request_id" bigint) -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."delete_http_response" ("request_id" bigint) TO "anon"; - -GRANT ALL ON FUNCTION "public"."delete_http_response" ("request_id" bigint) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."delete_http_response" ("request_id" bigint) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."delete_old_deleted_apps" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."delete_old_deleted_apps" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."delete_old_deleted_apps" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."delete_user" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."delete_user" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."delete_user" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."exist_app_v2" ("appid" character varying) TO "anon"; - -GRANT ALL ON FUNCTION "public"."exist_app_v2" ("appid" character varying) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."exist_app_v2" ("appid" character varying) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."force_valid_user_id_on_app" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."force_valid_user_id_on_app" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."force_valid_user_id_on_app" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."generate_org_on_user_create" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."generate_org_on_user_create" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."generate_org_on_user_create" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."generate_org_user_on_org_create" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."generate_org_user_on_org_create" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."generate_org_user_on_org_create" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."get_apikey" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."get_apikey" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_apikey" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_apikey" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_app_metrics" ("org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_app_metrics" ("org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_app_metrics" ("org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_app_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_app_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_app_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_current_plan_max_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_current_plan_max_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_current_plan_max_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_current_plan_name_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_current_plan_name_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_current_plan_name_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_customer_counts" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_customer_counts" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_customer_counts" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_cycle_info_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_cycle_info_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_cycle_info_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_db_url" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_db_url" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_db_url" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_global_metrics" ("org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_global_metrics" ("org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_global_metrics" ("org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_global_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_global_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_global_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_identity" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_identity" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_identity" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_identity" ("keymode" "public"."key_mode" []) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_identity" ("keymode" "public"."key_mode" []) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_identity" ("keymode" "public"."key_mode" []) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode" []) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode" []) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode" []) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode" [], "org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode" [], "org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode" [], "org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_identity_org_appid" ( - "keymode" "public"."key_mode" [], - "org_id" "uuid", - "app_id" character varying -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_identity_org_appid" ( - "keymode" "public"."key_mode" [], - "org_id" "uuid", - "app_id" character varying -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_identity_org_appid" ( - "keymode" "public"."key_mode" [], - "org_id" "uuid", - "app_id" character varying -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_metered_usage" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_metered_usage" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_metered_usage" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_metered_usage" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_metered_usage" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_metered_usage" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_next_cron_time" ( - "p_schedule" "text", - "p_timestamp" timestamp with time zone -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_next_cron_time" ( - "p_schedule" "text", - "p_timestamp" timestamp with time zone -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_next_cron_time" ( - "p_schedule" "text", - "p_timestamp" timestamp with time zone -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_next_cron_value" ( - "pattern" "text", - "current_val" integer, - "max_val" integer -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_next_cron_value" ( - "pattern" "text", - "current_val" integer, - "max_val" integer -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_next_cron_value" ( - "pattern" "text", - "current_val" integer, - "max_val" integer -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."get_org_members" ("user_id" "uuid", "guild_id" "uuid") -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."get_org_members" ("user_id" "uuid", "guild_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_org_members" ("user_id" "uuid", "guild_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_org_members" ("user_id" "uuid", "guild_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_org_owner_id" ("apikey" "text", "app_id" "text") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_org_owner_id" ("apikey" "text", "app_id" "text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_org_owner_id" ("apikey" "text", "app_id" "text") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_org_perm_for_apikey" ("apikey" "text", "app_id" "text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_org_perm_for_apikey" ("apikey" "text", "app_id" "text") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings" ("orgid" "uuid", "cli_version" "text") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings" ("orgid" "uuid", "cli_version" "text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings" ("orgid" "uuid", "cli_version" "text") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_orgs_v6" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_orgs_v6" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_orgs_v6" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."get_orgs_v6" ("userid" "uuid") -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."get_orgs_v6" ("userid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_orgs_v6" ("userid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed" ( - "orgid" "uuid", - "cycle_start" "date", - "cycle_end" "date" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed" ( - "orgid" "uuid", - "cycle_start" "date", - "cycle_end" "date" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed" ( - "orgid" "uuid", - "cycle_start" "date", - "cycle_end" "date" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_total_app_storage_size_orgs" ("org_id" "uuid", "app_id" character varying) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_total_app_storage_size_orgs" ("org_id" "uuid", "app_id" character varying) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_total_app_storage_size_orgs" ("org_id" "uuid", "app_id" character varying) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_total_metrics" ("org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_total_metrics" ("org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_total_metrics" ("org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_total_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_total_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_total_metrics" ( - "org_id" "uuid", - "start_date" "date", - "end_date" "date" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_total_storage_size_org" ("org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_total_storage_size_org" ("org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_total_storage_size_org" ("org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_update_stats" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_update_stats" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_update_stats" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_user_id" ("apikey" "text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_user_id" ("apikey" "text") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_user_id" ("apikey" "text", "app_id" "text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_user_id" ("apikey" "text", "app_id" "text") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_user_main_org_id" ("user_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_user_main_org_id" ("user_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_user_main_org_id" ("user_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_user_main_org_id_by_app_id" ("app_id" "text") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_user_main_org_id_by_app_id" ("app_id" "text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_user_main_org_id_by_app_id" ("app_id" "text") TO "service_role"; - -GRANT ALL ON TABLE "public"."app_versions" TO "anon"; - -GRANT ALL ON TABLE "public"."app_versions" TO "authenticated"; - -GRANT ALL ON TABLE "public"."app_versions" TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."get_versions_with_no_metadata" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."get_versions_with_no_metadata" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."get_weekly_stats" ("app_id" character varying) TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_weekly_stats" ("app_id" character varying) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_weekly_stats" ("app_id" character varying) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."has_app_right" ( - "appid" character varying, - "right" "public"."user_min_right" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."has_app_right" ( - "appid" character varying, - "right" "public"."user_min_right" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."has_app_right" ( - "appid" character varying, - "right" "public"."user_min_right" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."has_app_right_apikey" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid", - "apikey" "text" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."has_app_right_apikey" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid", - "apikey" "text" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."has_app_right_apikey" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid", - "apikey" "text" -) TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."has_app_right_userid" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid" -) -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."has_app_right_userid" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."has_app_right_userid" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."has_app_right_userid" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."invite_user_to_org" ( - "email" character varying, - "org_id" "uuid", - "invite_type" "public"."user_min_right" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."invite_user_to_org" ( - "email" character varying, - "org_id" "uuid", - "invite_type" "public"."user_min_right" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."invite_user_to_org" ( - "email" character varying, - "org_id" "uuid", - "invite_type" "public"."user_min_right" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_admin" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_admin" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_admin" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_admin" ("userid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_admin" ("userid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_admin" ("userid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_allowed_action" ("apikey" "text", "appid" "text") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_allowed_action" ("apikey" "text", "appid" "text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_allowed_action" ("apikey" "text", "appid" "text") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_allowed_action_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_allowed_action_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_allowed_action_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_allowed_action_org_action" ( - "orgid" "uuid", - "actions" "public"."action_type" [] -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_allowed_action_org_action" ( - "orgid" "uuid", - "actions" "public"."action_type" [] -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_allowed_action_org_action" ( - "orgid" "uuid", - "actions" "public"."action_type" [] -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_allowed_capgkey" ("apikey" "text", "keymode" "public"."key_mode" []) TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_allowed_capgkey" ("apikey" "text", "keymode" "public"."key_mode" []) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_allowed_capgkey" ("apikey" "text", "keymode" "public"."key_mode" []) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_allowed_capgkey" ( - "apikey" "text", - "keymode" "public"."key_mode" [], - "app_id" character varying -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_allowed_capgkey" ( - "apikey" "text", - "keymode" "public"."key_mode" [], - "app_id" character varying -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_allowed_capgkey" ( - "apikey" "text", - "keymode" "public"."key_mode" [], - "app_id" character varying -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_app_owner" ("appid" character varying) TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_app_owner" ("appid" character varying) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_app_owner" ("appid" character varying) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_app_owner" ("apikey" "text", "appid" character varying) TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_app_owner" ("apikey" "text", "appid" character varying) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_app_owner" ("apikey" "text", "appid" character varying) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_app_owner" ("userid" "uuid", "appid" character varying) TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_app_owner" ("userid" "uuid", "appid" character varying) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_app_owner" ("userid" "uuid", "appid" character varying) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_bandwidth_exceeded_by_org" ("org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_bandwidth_exceeded_by_org" ("org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_bandwidth_exceeded_by_org" ("org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_canceled_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_canceled_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_canceled_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_good_plan_v5_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_good_plan_v5_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_good_plan_v5_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_mau_exceeded_by_org" ("org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_mau_exceeded_by_org" ("org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_mau_exceeded_by_org" ("org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_member_of_org" ("user_id" "uuid", "org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_member_of_org" ("user_id" "uuid", "org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_member_of_org" ("user_id" "uuid", "org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_not_deleted" ("email_check" character varying) TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_not_deleted" ("email_check" character varying) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_not_deleted" ("email_check" character varying) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_numeric" ("text") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_numeric" ("text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_numeric" ("text") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_onboarded_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_onboarded_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_onboarded_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_onboarding_needed_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_onboarding_needed_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_onboarding_needed_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_org_yearly" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_org_yearly" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_org_yearly" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org_action" ( - "orgid" "uuid", - "actions" "public"."action_type" [] -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org_action" ( - "orgid" "uuid", - "actions" "public"."action_type" [] -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org_action" ( - "orgid" "uuid", - "actions" "public"."action_type" [] -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_paying_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_paying_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_paying_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_storage_exceeded_by_org" ("org_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_storage_exceeded_by_org" ("org_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_storage_exceeded_by_org" ("org_id" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."is_trial_org" ("orgid" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."is_trial_org" ("orgid" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."is_trial_org" ("orgid" "uuid") TO "service_role"; - -GRANT ALL ON FUNCTION "public"."noupdate" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."noupdate" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."noupdate" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."one_month_ahead" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."one_month_ahead" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."one_month_ahead" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."parse_cron_field" ( - "field" "text", - "current_val" integer, - "max_val" integer -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."parse_cron_field" ( - "field" "text", - "current_val" integer, - "max_val" integer -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."parse_cron_field" ( - "field" "text", - "current_val" integer, - "max_val" integer -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."parse_step_pattern" ("pattern" "text") TO "anon"; - -GRANT ALL ON FUNCTION "public"."parse_step_pattern" ("pattern" "text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."parse_step_pattern" ("pattern" "text") TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_admin_stats" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."process_admin_stats" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."process_admin_stats" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."process_admin_stats" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."process_cron_stats_jobs" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."process_cron_stats_jobs" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."process_cron_stats_jobs" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_failed_uploads" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."process_failed_uploads" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_free_trial_expired" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."process_free_trial_expired" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."process_free_trial_expired" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."process_free_trial_expired" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text") -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text") TO "anon"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text") TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_stats_email_monthly" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."process_stats_email_monthly" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."process_stats_email_monthly" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."process_stats_email_monthly" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_stats_email_weekly" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."process_stats_email_weekly" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."process_stats_email_weekly" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."process_stats_email_weekly" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_subscribed_orgs" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."process_subscribed_orgs" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."process_subscribed_orgs" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."process_subscribed_orgs" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."read_bandwidth_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."read_bandwidth_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."read_bandwidth_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."read_bandwidth_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."read_device_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."read_device_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."read_device_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."read_device_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."read_storage_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."read_storage_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."read_storage_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."read_version_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."read_version_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."read_version_usage" ( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."record_deployment_history" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."record_deployment_history" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."record_deployment_history" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."record_deployment_history" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."remove_old_jobs" () -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."remove_old_jobs" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."remove_old_jobs" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."remove_old_jobs" () TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."set_bandwidth_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."set_bandwidth_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."set_mau_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."set_mau_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."set_storage_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."set_storage_exceeded_by_org" ("org_id" "uuid", "disabled" boolean) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."transfer_app" ( - "p_app_id" character varying, - "p_new_org_id" "uuid" -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."transfer_app" ( - "p_app_id" character varying, - "p_new_org_id" "uuid" -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."transfer_app" ( - "p_app_id" character varying, - "p_new_org_id" "uuid" -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."trigger_http_queue_post_to_function" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."trigger_http_queue_post_to_function" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."trigger_http_queue_post_to_function" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."update_app_versions_retention" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."update_app_versions_retention" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."update_app_versions_retention" () TO "service_role"; - -GRANT ALL ON FUNCTION "public"."verify_mfa" () TO "anon"; - -GRANT ALL ON FUNCTION "public"."verify_mfa" () TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."verify_mfa" () TO "service_role"; - -GRANT ALL ON TABLE "public"."apikeys" TO "anon"; - -GRANT ALL ON TABLE "public"."apikeys" TO "authenticated"; - -GRANT ALL ON TABLE "public"."apikeys" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."apikeys_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."apikeys_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."apikeys_id_seq" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."app_versions_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."app_versions_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."app_versions_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."app_versions_meta" TO "anon"; - -GRANT ALL ON TABLE "public"."app_versions_meta" TO "authenticated"; - -GRANT ALL ON TABLE "public"."app_versions_meta" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."app_versions_meta_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."app_versions_meta_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."app_versions_meta_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."apps" TO "anon"; - -GRANT ALL ON TABLE "public"."apps" TO "authenticated"; - -GRANT ALL ON TABLE "public"."apps" TO "service_role"; - -GRANT ALL ON TABLE "public"."bandwidth_usage" TO "anon"; - -GRANT ALL ON TABLE "public"."bandwidth_usage" TO "authenticated"; - -GRANT ALL ON TABLE "public"."bandwidth_usage" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."bandwidth_usage_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."bandwidth_usage_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."bandwidth_usage_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."channel_devices" TO "anon"; - -GRANT ALL ON TABLE "public"."channel_devices" TO "authenticated"; - -GRANT ALL ON TABLE "public"."channel_devices" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."channel_devices_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."channel_devices_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."channel_devices_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."channels" TO "anon"; - -GRANT ALL ON TABLE "public"."channels" TO "authenticated"; - -GRANT ALL ON TABLE "public"."channels" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."channel_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."channel_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."channel_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."daily_bandwidth" TO "anon"; - -GRANT ALL ON TABLE "public"."daily_bandwidth" TO "authenticated"; - -GRANT ALL ON TABLE "public"."daily_bandwidth" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."daily_bandwidth_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."daily_bandwidth_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."daily_bandwidth_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."daily_mau" TO "anon"; - -GRANT ALL ON TABLE "public"."daily_mau" TO "authenticated"; - -GRANT ALL ON TABLE "public"."daily_mau" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."daily_mau_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."daily_mau_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."daily_mau_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."daily_storage" TO "anon"; - -GRANT ALL ON TABLE "public"."daily_storage" TO "authenticated"; - -GRANT ALL ON TABLE "public"."daily_storage" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."daily_storage_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."daily_storage_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."daily_storage_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."daily_version" TO "anon"; - -GRANT ALL ON TABLE "public"."daily_version" TO "authenticated"; - -GRANT ALL ON TABLE "public"."daily_version" TO "service_role"; - -GRANT ALL ON TABLE "public"."deleted_account" TO "anon"; - -GRANT ALL ON TABLE "public"."deleted_account" TO "authenticated"; - -GRANT ALL ON TABLE "public"."deleted_account" TO "service_role"; - -GRANT ALL ON TABLE "public"."deleted_apps" TO "anon"; - -GRANT ALL ON TABLE "public"."deleted_apps" TO "authenticated"; - -GRANT ALL ON TABLE "public"."deleted_apps" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."deleted_apps_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."deleted_apps_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."deleted_apps_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."deploy_history" TO "anon"; - -GRANT ALL ON TABLE "public"."deploy_history" TO "authenticated"; - -GRANT ALL ON TABLE "public"."deploy_history" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."deploy_history_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."deploy_history_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."deploy_history_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."device_usage" TO "anon"; - -GRANT ALL ON TABLE "public"."device_usage" TO "authenticated"; - -GRANT ALL ON TABLE "public"."device_usage" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."device_usage_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."device_usage_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."device_usage_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."devices" TO "anon"; - -GRANT ALL ON TABLE "public"."devices" TO "authenticated"; - -GRANT ALL ON TABLE "public"."devices" TO "service_role"; - -GRANT ALL ON TABLE "public"."global_stats" TO "anon"; - -GRANT ALL ON TABLE "public"."global_stats" TO "authenticated"; - -GRANT ALL ON TABLE "public"."global_stats" TO "service_role"; - -GRANT ALL ON TABLE "public"."manifest" TO "anon"; - -GRANT ALL ON TABLE "public"."manifest" TO "authenticated"; - -GRANT ALL ON TABLE "public"."manifest" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."manifest_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."manifest_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."manifest_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."notifications" TO "anon"; - -GRANT ALL ON TABLE "public"."notifications" TO "authenticated"; - -GRANT ALL ON TABLE "public"."notifications" TO "service_role"; - -GRANT ALL ON TABLE "public"."org_users" TO "anon"; - -GRANT ALL ON TABLE "public"."org_users" TO "authenticated"; - -GRANT ALL ON TABLE "public"."org_users" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."org_users_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."org_users_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."org_users_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."orgs" TO "anon"; - -GRANT ALL ON TABLE "public"."orgs" TO "authenticated"; - -GRANT ALL ON TABLE "public"."orgs" TO "service_role"; - -GRANT ALL ON TABLE "public"."plans" TO "anon"; - -GRANT ALL ON TABLE "public"."plans" TO "authenticated"; - -GRANT ALL ON TABLE "public"."plans" TO "service_role"; - -GRANT ALL ON TABLE "public"."stats" TO "anon"; - -GRANT ALL ON TABLE "public"."stats" TO "authenticated"; - -GRANT ALL ON TABLE "public"."stats" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."stats_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."stats_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."stats_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."storage_usage" TO "anon"; - -GRANT ALL ON TABLE "public"."storage_usage" TO "authenticated"; - -GRANT ALL ON TABLE "public"."storage_usage" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."storage_usage_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."storage_usage_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."storage_usage_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."stripe_info" TO "anon"; - -GRANT ALL ON TABLE "public"."stripe_info" TO "authenticated"; - -GRANT ALL ON TABLE "public"."stripe_info" TO "service_role"; - -GRANT ALL ON SEQUENCE "public"."stripe_info_id_seq" TO "anon"; - -GRANT ALL ON SEQUENCE "public"."stripe_info_id_seq" TO "authenticated"; - -GRANT ALL ON SEQUENCE "public"."stripe_info_id_seq" TO "service_role"; - -GRANT ALL ON TABLE "public"."users" TO "anon"; - -GRANT ALL ON TABLE "public"."users" TO "authenticated"; - -GRANT ALL ON TABLE "public"."users" TO "service_role"; - -GRANT ALL ON TABLE "public"."version_meta" TO "anon"; - -GRANT ALL ON TABLE "public"."version_meta" TO "authenticated"; - -GRANT ALL ON TABLE "public"."version_meta" TO "service_role"; - -GRANT ALL ON TABLE "public"."version_usage" TO "anon"; - -GRANT ALL ON TABLE "public"."version_usage" TO "authenticated"; - -GRANT ALL ON TABLE "public"."version_usage" TO "service_role"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON SEQUENCES TO "postgres"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON SEQUENCES TO "anon"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON SEQUENCES TO "authenticated"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON SEQUENCES TO "service_role"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON FUNCTIONS TO "postgres"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON FUNCTIONS TO "anon"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON FUNCTIONS TO "authenticated"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON FUNCTIONS TO "service_role"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON TABLES TO "postgres"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON TABLES TO "anon"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON TABLES TO "authenticated"; - -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" -GRANT ALL ON TABLES TO "service_role"; - -RESET ALL; - --- --- Dumped schema changes for auth and storage --- -CREATE POLICY "All all users to act" ON "storage"."objects" USING (true) -WITH - CHECK (true); - -CREATE POLICY "Allow user or apikey to delete they own folder in images" ON "storage"."objects" FOR DELETE USING ( - ( - ("bucket_id" = 'images'::"text") - AND ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - OR ( - ( - ( - "public"."get_user_id" (("public"."get_apikey_header" ())) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - AND "public"."is_allowed_capgkey" ( - ( - SELECT - "public"."get_apikey_header" () - ), - '{all}'::"public"."key_mode" [], - (("storage"."foldername" ("name")) [1])::character varying - ) - ) - ) - ) -); - -CREATE POLICY "Allow user or apikey to update they own folder in images" ON "storage"."objects" -FOR UPDATE - USING ( - ( - ("bucket_id" = 'images'::"text") - AND ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - OR ( - ( - ( - "public"."get_user_id" (("public"."get_apikey_header" ())) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - AND "public"."is_allowed_capgkey" ( - ( - SELECT - "public"."get_apikey_header" () - ), - '{write,all}'::"public"."key_mode" [], - (("storage"."foldername" ("name")) [1])::character varying - ) - ) - ) - ) - ); - -CREATE POLICY "Allow user or apikey to insert they own folder in images" ON "storage"."objects" FOR INSERT -WITH - CHECK ( - ( - ("bucket_id" = 'images'::"text") - AND ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - OR ( - ( - ( - "public"."get_user_id" ( - ( - SELECT - "public"."get_apikey_header" () - ) - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - AND "public"."is_allowed_capgkey" ( - ( - SELECT - "public"."get_apikey_header" () - ), - '{write,all}'::"public"."key_mode" [], - (("storage"."foldername" ("name")) [1])::character varying - ) - ) - ) - ) - ); - -CREATE POLICY "Allow user or apikey to read they own folder in images" ON "storage"."objects" FOR -SELECT - USING ( - ( - ("bucket_id" = 'images'::"text") - AND ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - OR ( - ( - ( - "public"."get_user_id" ("public"."get_apikey_header" ()) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - AND "public"."is_allowed_capgkey" ( - ( - SELECT - "public"."get_apikey_header" () - ), - '{read,all}'::"public"."key_mode" [], - (("storage"."foldername" ("name")) [1])::character varying - ) - ) - ) - ) - ); - -CREATE POLICY "Allow user or apikey to delete they own folder in apps" ON "storage"."objects" FOR DELETE USING ( - ( - ("bucket_id" = 'apps'::"text") - AND ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - OR ( - ( - ( - "public"."get_user_id" (("public"."get_apikey_header" ())) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - AND "public"."is_allowed_capgkey" ( - ( - SELECT - "public"."get_apikey_header" () - ), - '{all}'::"public"."key_mode" [], - (("storage"."foldername" ("name")) [1])::character varying - ) - ) - ) - ) -); - -CREATE POLICY "Allow user or apikey to update they own folder in apps" ON "storage"."objects" -FOR UPDATE - USING ( - ( - ("bucket_id" = 'apps'::"text") - AND ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - OR ( - ( - ( - "public"."get_user_id" (("public"."get_apikey_header" ())) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - AND "public"."is_allowed_capgkey" ( - ( - SELECT - "public"."get_apikey_header" () - ), - '{write,all}'::"public"."key_mode" [], - (("storage"."foldername" ("name")) [1])::character varying - ) - ) - ) - ) - ); - -CREATE POLICY "Allow user or apikey to insert they own folder in apps" ON "storage"."objects" FOR INSERT -WITH - CHECK ( - ( - ("bucket_id" = 'apps'::"text") - AND ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - OR ( - ( - ( - "public"."get_user_id" ( - ( - SELECT - "public"."get_apikey_header" () - ) - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - AND "public"."is_allowed_capgkey" ( - ( - SELECT - "public"."get_apikey_header" () - ), - '{write,all}'::"public"."key_mode" [], - (("storage"."foldername" ("name")) [1])::character varying - ) - ) - ) - ) - ); - -CREATE POLICY "Allow user or apikey to read they own folder in apps" ON "storage"."objects" FOR -SELECT - USING ( - ( - ("bucket_id" = 'apps'::"text") - AND ( - ( - ( - ( - SELECT - "auth"."uid" () AS "uid" - ) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - OR ( - ( - ( - "public"."get_user_id" ("public"."get_apikey_header" ()) - )::"text" = ("storage"."foldername" ("name")) [0] - ) - AND "public"."is_allowed_capgkey" ( - ( - SELECT - "public"."get_apikey_header" () - ), - '{read,all}'::"public"."key_mode" [], - (("storage"."foldername" ("name")) [1])::character varying - ) - ) - ) - ) - ); - -CREATE POLICY "Disable act bucket for users" ON "storage"."buckets" USING (false) -WITH - CHECK (false); - --- CREATE ALL QUEUES -SELECT - pgmq.create ('cron_stats'); - -SELECT - pgmq.create ('cron_plan'); - -SELECT - pgmq.create ('cron_clear_versions'); - -SELECT - pgmq.create ('cron_email'); - -SELECT - pgmq.create ('on_app_create'); - -SELECT - pgmq.create ('on_channel_update'); - -SELECT - pgmq.create ('on_organization_create'); - -SELECT - pgmq.create ('on_organization_delete'); - -SELECT - pgmq.create ('on_user_create'); - -SELECT - pgmq.create ('on_user_update'); - -SELECT - pgmq.create ('on_version_create'); - -SELECT - pgmq.create ('on_version_delete'); - -SELECT - pgmq.create ('on_version_update'); - -SELECT - pgmq.create ('on_user_delete'); - -SELECT - pgmq.create ('on_app_delete'); - -SELECT - pgmq.create ('on_manifest_create'); - -SELECT - pgmq.create ('on_deploy_history_create'); - -SELECT - pgmq.create ('admin_stats'); - --- CREATE ALL CRON JOBS -SELECT - cron.schedule ( - 'Delete old app version', - '40 0 * * *', - 'SELECT update_app_versions_retention();' - ); - -SELECT - cron.schedule ( - 'process_subscribed_orgs', - '0 3 * * *', - 'SELECT process_subscribed_orgs();' - ); - -SELECT - cron.schedule ( - 'process_free_trial_expired', - '0 0 * * *', - 'SELECT process_free_trial_expired();' - ); - -SELECT - cron.schedule ( - 'delete-job-run-details', - '0 12 * * *', - 'DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval ''7 days'';' - ); - -SELECT - cron.schedule ( - 'cleanup_queue_messages', - '0 0 * * *', - 'SELECT cleanup_queue_messages();' - ); - -SELECT - cron.schedule ( - 'process_cron_stats_jobs', - '0 */2 * * *', - 'SELECT process_cron_stats_jobs();' - ); - -SELECT - cron.schedule ( - 'delete_old_deleted_apps', - '0 0 * * *', - 'SELECT delete_old_deleted_apps();' - ); - -SELECT - cron.schedule ( - 'process_manifest_create_queue', - '5 seconds', - 'SELECT process_function_queue(''on_manifest_create'');' - ); - -SELECT - cron.schedule ( - 'Send stats email every month', - '0 12 1 * *', - 'SELECT process_stats_email_monthly();' - ); - -SELECT - cron.schedule ( - 'create_admin_stats', - '0 14 1 * *', - 'SELECT public.process_admin_stats()' - ); - -SELECT - cron.schedule ( - 'Send stats email every week', - '0 12 * * 6', - 'SELECT process_stats_email_weekly();' - ); - -SELECT - cron.schedule ( - 'Cleanup frequent job details', - '0 * * * *', - 'SELECT cleanup_frequent_job_details()' - ); - -SELECT - cron.schedule ( - 'Remove old jobs', - '0 0 * * *', - 'SELECT remove_old_jobs()' - ); - -SELECT - cron.schedule ( - 'process_admin_stats', - '0 */2 * * *', - 'SELECT public.process_function_queue(''admin_stats'')' - ); - -SELECT - cron.schedule ( - 'process_cron_stats_queue', - '10 seconds', - 'SELECT public.process_function_queue(''cron_stats'')' - ); - -SELECT - cron.schedule ( - 'process_channel_update_queue', - '10 seconds', - 'SELECT public.process_function_queue(''on_channel_update'')' - ); - -SELECT - cron.schedule ( - 'process_user_create_queue', - '10 seconds', - 'SELECT public.process_function_queue(''on_user_create'')' - ); - -SELECT - cron.schedule ( - 'process_user_update_queue', - '10 seconds', - 'SELECT public.process_function_queue(''on_user_update'')' - ); - -SELECT - cron.schedule ( - 'process_version_delete_queue', - '10 seconds', - 'SELECT public.process_function_queue(''on_version_delete'')' - ); - -SELECT - cron.schedule ( - 'process_version_update_queue', - '10 seconds', - 'SELECT public.process_function_queue(''on_version_update'')' - ); - -SELECT - cron.schedule ( - 'process_app_delete_queue', - '10 seconds', - 'SELECT public.process_function_queue(''on_app_delete'')' - ); - -SELECT - cron.schedule ( - 'process_cron_plan_queue', - '0 */2 * * *', - 'SELECT public.process_function_queue(''cron_plan'')' - ); - -SELECT - cron.schedule ( - 'process_cron_email_queue', - '0 */2 * * *', - 'SELECT public.process_function_queue(''cron_email'')' - ); - -SELECT - cron.schedule ( - 'process_app_create_queue', - '0 */2 * * *', - 'SELECT public.process_function_queue(''on_app_create'')' - ); - -SELECT - cron.schedule ( - 'process_version_create_queue', - '0 */2 * * *', - 'SELECT public.process_function_queue(''on_version_create'')' - ); - -SELECT - cron.schedule ( - 'process_organization_create_queue', - '10 seconds', - 'SELECT public.process_function_queue(''on_organization_create'')' - ); - -SELECT - cron.schedule ( - 'process_organization_delete_queue', - '0 */2 * * *', - 'SELECT public.process_function_queue(''on_organization_delete'')' - ); - -SELECT - cron.schedule ( - 'process_deploy_history_create_queue', - '0 */2 * * *', - 'SELECT public.process_function_queue(''on_deploy_history_create'')' - ); diff --git a/supabase/migrations/20250601115144_better_queue_logs.sql b/supabase/migrations/20250601115144_better_queue_logs.sql deleted file mode 100644 index 4636fe3fc8..0000000000 --- a/supabase/migrations/20250601115144_better_queue_logs.sql +++ /dev/null @@ -1,60 +0,0 @@ --- Create the type for the input array first -CREATE TYPE message_update AS (msg_id bigint, cf_id varchar, queue varchar); - -CREATE OR REPLACE FUNCTION mass_edit_queue_messages_cf_ids( - updates public.message_update [] -) RETURNS void LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -DECLARE - update_record public.message_update; - current_message jsonb; - current_cf_ids jsonb; -BEGIN - FOR update_record IN SELECT * FROM unnest(updates) - LOOP - -- Get the current message using dynamic SQL - EXECUTE format( - 'SELECT message FROM pgmq.q_%I WHERE msg_id = $1', - update_record.queue - ) INTO current_message USING update_record.msg_id; - - IF current_message IS NOT NULL THEN - -- Check if cf_ids exists and is an array - current_cf_ids := current_message->'cf_ids'; - - IF current_cf_ids IS NULL OR NOT jsonb_typeof(current_cf_ids) = 'array' THEN - -- Create new cf_ids array with single element - current_message := jsonb_set( - current_message, - '{cf_ids}', - jsonb_build_array(update_record.cf_id) - ); - ELSE - -- Append new cf_id to existing array - current_message := jsonb_set( - current_message, - '{cf_ids}', - current_cf_ids || jsonb_build_array(update_record.cf_id) - ); - END IF; - - -- Update the message - EXECUTE format( - 'UPDATE pgmq.q_%I SET message = $1 WHERE msg_id = $2', - update_record.queue - ) USING current_message, update_record.msg_id; - END IF; - END LOOP; -END; -$$; - --- Grant execute permission to postgres role only -REVOKE ALL ON FUNCTION mass_edit_queue_messages_cf_ids(message_update []) -FROM -public; - -GRANT -EXECUTE ON FUNCTION mass_edit_queue_messages_cf_ids( - message_update [] -) TO postgres; diff --git a/supabase/migrations/20250605151648_credits.sql b/supabase/migrations/20250605151648_credits.sql deleted file mode 100644 index 0be9eb140f..0000000000 --- a/supabase/migrations/20250605151648_credits.sql +++ /dev/null @@ -1,47 +0,0 @@ -CREATE TABLE IF NOT EXISTS capgo_credits_steps ( - id BIGSERIAL PRIMARY KEY, - step_min BIGINT NOT NULL, - step_max BIGINT NOT NULL, - price_per_unit FLOAT NOT NULL, - type TEXT NOT NULL, - unit_factor BIGINT NOT NULL DEFAULT 1, - stripe_id TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT step_range_check CHECK (step_min < step_max) -); - --- Add a comment to the table -COMMENT ON TABLE capgo_credits_steps IS 'Table to store token pricing tiers'; - --- Add comments to the columns -COMMENT ON COLUMN capgo_credits_steps.id IS 'The unique identifier for the pricing tier'; - -COMMENT ON COLUMN capgo_credits_steps.step_min IS 'The minimum number of credits for this tier'; - -COMMENT ON COLUMN capgo_credits_steps.step_max IS 'The maximum number of credits for this tier'; - -COMMENT ON COLUMN capgo_credits_steps.price_per_unit IS 'The price per token in this tier'; - -COMMENT ON COLUMN capgo_credits_steps.unit_factor IS 'The unit conversion factor (e.g., bytes to GB = 1073741824)'; - -COMMENT ON COLUMN capgo_credits_steps.created_at IS 'Timestamp when the tier was created'; - -COMMENT ON COLUMN capgo_credits_steps.updated_at IS 'Timestamp when the tier was last updated'; - --- Create trigger for updating updated_at column -CREATE TRIGGER handle_updated_at BEFORE -UPDATE ON capgo_credits_steps FOR EACH ROW -EXECUTE FUNCTION extensions.moddatetime('updated_at'); - --- Create an index on step ranges for faster lookups -CREATE INDEX capgo_credits_steps_range_idx ON capgo_credits_steps ( - step_min, step_max -); - -ALTER TABLE capgo_credits_steps ENABLE ROW LEVEL SECURITY; - --- Allow anyone to read capgo_credits_steps -CREATE POLICY "Anyone can read capgo_credits_steps" ON capgo_credits_steps FOR -SELECT -TO public USING (true); diff --git a/supabase/migrations/20250608130257_fix_version_meta.sql b/supabase/migrations/20250608130257_fix_version_meta.sql deleted file mode 100644 index 618bf015a2..0000000000 --- a/supabase/migrations/20250608130257_fix_version_meta.sql +++ /dev/null @@ -1,238 +0,0 @@ --- First, let's see what duplicates we have --- This is just for logging/debugging - you can remove this in production -DO $$ -BEGIN - RAISE NOTICE 'Duplicates found: %', ( - SELECT COUNT(*) - FROM ( - SELECT app_id, version_id, COUNT(*) as cnt - FROM version_meta - GROUP BY app_id, version_id - HAVING COUNT(*) > 1 - ) dups - ); -END $$; - --- Create a temporary table with the rows we want to keep -CREATE TEMP TABLE version_meta_keep AS -WITH -ranked_positive AS ( - -- For positive sizes, rank by timestamp ASC (earliest first) - SELECT - timestamp, - app_id, - version_id, - size, - ROW_NUMBER() OVER ( - PARTITION BY - app_id, - version_id - ORDER BY - timestamp ASC - ) AS rn - FROM - version_meta - WHERE - size > 0 -), - -ranked_negative AS ( - -- For negative sizes, rank by timestamp DESC (latest first) - SELECT - timestamp, - app_id, - version_id, - size, - ROW_NUMBER() OVER ( - PARTITION BY - app_id, - version_id - ORDER BY - timestamp DESC - ) AS rn - FROM - version_meta - WHERE - size < 0 -), - -zero_sizes AS ( - -- Handle size = 0 case (keep earliest) - SELECT - timestamp, - app_id, - version_id, - size, - ROW_NUMBER() OVER ( - PARTITION BY - app_id, - version_id - ORDER BY - timestamp ASC - ) AS rn - FROM - version_meta - WHERE - size = 0 -) - -SELECT - timestamp, - app_id, - version_id, - size -FROM - ranked_positive -WHERE - rn = 1 -UNION ALL -SELECT - timestamp, - app_id, - version_id, - size -FROM - ranked_negative -WHERE - rn = 1 -UNION ALL -SELECT - timestamp, - app_id, - version_id, - size -FROM - zero_sizes -WHERE - rn = 1; - --- Show how many rows we're keeping vs deleting -DO $$ -DECLARE - original_count INTEGER; - keep_count INTEGER; -BEGIN - SELECT COUNT(*) INTO original_count FROM version_meta; - SELECT COUNT(*) INTO keep_count FROM version_meta_keep; - - RAISE NOTICE 'Original rows: %, Keeping: %, Deleting: %', - original_count, keep_count, (original_count - keep_count); -END $$; - --- Delete all rows from version_meta -DELETE FROM version_meta; - --- Insert the deduplicated rows back -INSERT INTO -version_meta (timestamp, app_id, version_id, size) -SELECT - timestamp, - app_id, - version_id, - size -FROM - version_meta_keep; - --- Drop the temp table -DROP TABLE version_meta_keep; - --- Create partial unique constraints - one for positive sizes, one for negative sizes --- This allows both positive and negative entries for the same (app_id, version_id) --- but prevents duplicate positive or duplicate negative entries -CREATE UNIQUE INDEX unique_app_version_positive ON version_meta ( - app_id, version_id -) -WHERE -size > 0; - -CREATE UNIQUE INDEX unique_app_version_negative ON version_meta ( - app_id, version_id -) -WHERE -size < 0; - --- Create a secure function to handle version_meta upserts --- Only available to supabase service role, not public users -CREATE OR REPLACE FUNCTION UPSERT_VERSION_META( - p_app_id VARCHAR(255), - p_version_id BIGINT, - p_size BIGINT --- Run with definer's privileges (postgres/service role) -) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' -- Security: fix search path -AS $$ -DECLARE - existing_count INTEGER; -BEGIN - -- Check if a row already exists for this app_id, version_id with same sign - IF p_size > 0 THEN - -- Check for existing positive size - SELECT COUNT(*) INTO existing_count - FROM public.version_meta - WHERE public.version_meta.app_id = p_app_id - AND public.version_meta.version_id = p_version_id - AND public.version_meta.size > 0; - ELSIF p_size < 0 THEN - -- Check for existing negative size - SELECT COUNT(*) INTO existing_count - FROM public.version_meta - WHERE public.version_meta.app_id = p_app_id - AND public.version_meta.version_id = p_version_id - AND public.version_meta.size < 0; - END IF; - - -- If row already exists, do nothing and return false - IF existing_count > 0 THEN - RETURN FALSE; - END IF; - - -- Insert the new row - INSERT INTO public.version_meta (app_id, version_id, size) - VALUES (p_app_id, p_version_id, p_size); - - -- Return true to indicate insertion happened - RETURN TRUE; - -EXCEPTION - WHEN unique_violation THEN - -- If there's a race condition and constraint is violated, just return false - RETURN FALSE; -END; -$$; - --- Revoke public access and grant only to service role -REVOKE ALL ON FUNCTION UPSERT_VERSION_META(VARCHAR(255), BIGINT, BIGINT) -FROM -public; - -GRANT -EXECUTE ON FUNCTION UPSERT_VERSION_META( - VARCHAR(255), BIGINT, BIGINT -) TO service_role; - --- Verify the deduplication worked -DO $$ -BEGIN - RAISE NOTICE 'Final row count: %', (SELECT COUNT(*) FROM version_meta); - RAISE NOTICE 'Positive duplicates: %', ( - SELECT COUNT(*) - FROM ( - SELECT app_id, version_id, COUNT(*) as cnt - FROM version_meta - WHERE size > 0 - GROUP BY app_id, version_id - HAVING COUNT(*) > 1 - ) dups - ); - RAISE NOTICE 'Negative duplicates: %', ( - SELECT COUNT(*) - FROM ( - SELECT app_id, version_id, COUNT(*) as cnt - FROM version_meta - WHERE size < 0 - GROUP BY app_id, version_id - HAVING COUNT(*) > 1 - ) dups - ); -END $$; diff --git a/supabase/migrations/20250612131646_exist_app.sql b/supabase/migrations/20250612131646_exist_app.sql deleted file mode 100644 index 7aa20a1e7f..0000000000 --- a/supabase/migrations/20250612131646_exist_app.sql +++ /dev/null @@ -1,33 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -Begin - RETURN (SELECT EXISTS (SELECT 1 - FROM public.app_versions - WHERE app_id=appid - AND name=name_version)); -End; -$$; - -GRANT ALL ON FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying -) TO "anon"; - -GRANT ALL ON FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying -) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying -) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying -) TO "anon"; diff --git a/supabase/migrations/20250613034031_tmp_users_table.sql b/supabase/migrations/20250613034031_tmp_users_table.sql deleted file mode 100644 index 7a58cc5839..0000000000 --- a/supabase/migrations/20250613034031_tmp_users_table.sql +++ /dev/null @@ -1,403 +0,0 @@ --- Create tmp_users table -CREATE TABLE public.tmp_users ( - id SERIAL PRIMARY KEY, - email TEXT NOT NULL, - org_id UUID NOT NULL REFERENCES public.orgs (id), - role user_min_right NOT NULL, - invite_magic_string TEXT NOT NULL DEFAULT encode(gen_random_bytes (128), 'hex')::text, - future_uuid UUID NOT NULL DEFAULT gen_random_uuid (), - first_name TEXT NOT NULL, - last_name TEXT NOT NULL, - -- I call it cancelled_at, but it's a dumified name for rescinded_at - cancelled_at TIMESTAMPTZ DEFAULT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Create unique index on both org_id and email -CREATE UNIQUE INDEX tmp_users_org_id_email_idx ON public.tmp_users (org_id, email); - -CREATE POLICY "Disable for all" ON "public"."tmp_users" USING (false) -WITH - CHECK (false); - --- Create index on invite_magic_string for faster lookups -CREATE INDEX tmp_users_invite_magic_string_idx ON public.tmp_users (invite_magic_string); - --- Add trigger for automatically updating updated_at -CREATE TRIGGER handle_updated_at BEFORE -UPDATE ON public.tmp_users FOR EACH ROW -EXECUTE FUNCTION moddatetime ('updated_at'); - --- Enable Row Level Security -ALTER TABLE public.tmp_users ENABLE ROW LEVEL SECURITY; - --- No RLS policies are added as per requirements --- Function to transform role to invite_role -CREATE OR REPLACE FUNCTION public.transform_role_to_invite (role_input public.user_min_right) RETURNS public.user_min_right LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -BEGIN - CASE role_input - WHEN 'read'::public.user_min_right THEN RETURN 'invite_read'::public.user_min_right; - WHEN 'upload'::public.user_min_right THEN RETURN 'invite_upload'::public.user_min_right; - WHEN 'write'::public.user_min_right THEN RETURN 'invite_write'::public.user_min_right; - WHEN 'admin'::public.user_min_right THEN RETURN 'invite_admin'::public.user_min_right; - WHEN 'super_admin'::public.user_min_right THEN RETURN 'invite_super_admin'::public.user_min_right; - ELSE RETURN role_input; -- If it's already an invite role or unrecognized, return as is - END CASE; -END; -$$; - --- Grant privileges for the function -ALTER FUNCTION public.transform_role_to_invite (user_min_right) OWNER TO postgres; - -GRANT ALL ON FUNCTION public.transform_role_to_invite (user_min_right) TO service_role; - -GRANT -EXECUTE ON FUNCTION public.transform_role_to_invite (user_min_right) TO authenticated; - --- Modify get_members. We will not create a new function, but will modify the existing one to support the new tmp_users table. -DROP FUNCTION "public"."get_org_members" ("guild_id" "uuid"); - -DROP FUNCTION "public"."get_org_members" (user_id "uuid", "guild_id" "uuid"); - -CREATE OR REPLACE FUNCTION "public"."get_org_members" (user_id "uuid", "guild_id" "uuid") RETURNS TABLE ( - "aid" bigint, - "uid" "uuid", - "email" character varying, - "image_url" character varying, - "role" "public"."user_min_right", - "is_tmp" boolean -) LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -begin - return query select o.id as aid, public.users.id as uid, public.users.email, public.users.image_url, o.user_right as role, false as is_tmp from public.org_users as o - join public.users on public.users.id = o.user_id - where o.org_id=get_org_members.guild_id - AND (public.is_member_of_org(public.users.id, o.org_id)) - UNION - select ((select max(id) from public.org_users) + tmp.id) as aid, tmp.future_uuid as uid, tmp.email, '' as image_url, public.transform_role_to_invite(tmp.role) as role, true as is_tmp from public.tmp_users as tmp - where tmp.org_id=get_org_members.guild_id - AND tmp.cancelled_at IS NULL - AND tmp.created_at > (CURRENT_TIMESTAMP - INTERVAL '7 days'); -End; -$$; - -ALTER FUNCTION "public"."get_org_members" (user_id "uuid", "guild_id" "uuid") OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."get_org_members" (user_id "uuid", "guild_id" "uuid") -FROM - PUBLIC; - -GRANT ALL ON FUNCTION "public"."get_org_members" (user_id "uuid", "guild_id" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_org_members" ("guild_id" "uuid") RETURNS TABLE ( - "aid" bigint, - "uid" "uuid", - "email" character varying, - "image_url" character varying, - "role" "public"."user_min_right", - "is_tmp" boolean -) LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -begin - IF NOT (public.check_min_rights('read'::public.user_min_right, (select auth.uid()), get_org_members.guild_id, NULL::character varying, NULL::bigint)) THEN - raise exception 'NO_RIGHTS'; - END IF; - - return query select * from public.get_org_members((select auth.uid()), get_org_members.guild_id); -End; -$$; - -ALTER FUNCTION "public"."get_org_members" ("guild_id" "uuid") OWNER TO "postgres"; - -GRANT ALL ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "anon"; - -GRANT ALL ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."invite_user_to_org" ( - "email" character varying, - "org_id" "uuid", - "invite_type" "public"."user_min_right" -) RETURNS character varying LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -Declare - org record; - invited_user record; - current_record record; - current_tmp_user record; -Begin - SELECT * FROM public.orgs - INTO org - WHERE public.orgs.id=invite_user_to_org.org_id; - - IF org IS NULL THEN - return 'NO_ORG'; - END IF; - - if NOT (public.check_min_rights('admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], invite_user_to_org.org_id)), invite_user_to_org.org_id, NULL::character varying, NULL::bigint)) THEN - return 'NO_RIGHTS'; - END IF; - - - if NOT (public.check_min_rights('super_admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], invite_user_to_org.org_id)), invite_user_to_org.org_id, NULL::character varying, NULL::bigint) AND (invite_type is distinct from 'super_admin'::"public"."user_min_right" or invite_type is distinct from 'invite_super_admin'::"public"."user_min_right")) THEN - return 'NO_RIGHTS'; - END IF; - - SELECT public.users.id FROM public.users - INTO invited_user - WHERE public.users.email=invite_user_to_org.email; - - IF FOUND THEN - -- INSERT INTO org_users (user_id, org_id, user_right) - -- VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - - SELECT public.org_users.id from public.org_users - INTO current_record - WHERE public.org_users.user_id=invited_user.id - AND public.org_users.org_id=invite_user_to_org.org_id; - - IF FOUND THEN - RETURN 'ALREADY_INVITED'; - ELSE - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - - RETURN 'OK'; - END IF; - ELSE - SELECT * FROM public.tmp_users - INTO current_tmp_user - WHERE public.tmp_users.email=invite_user_to_org.email - AND public.tmp_users.org_id=invite_user_to_org.org_id; - - IF FOUND THEN - IF current_tmp_user.cancelled_at IS NOT NULL THEN - -- Check if cancelled less than 3 hours ago - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; -- Allow reinvitation after 3 hours - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - return 'NO_EMAIL'; -- This is expected. the frontend expects this response. - END IF; - - return 'NO_EMAIL'; - END IF; -End; -$$; - --- Function to rescind an invitation to an organization -CREATE OR REPLACE FUNCTION "public"."rescind_invitation" ("email" TEXT, "org_id" UUID) RETURNS character varying LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - tmp_user record; - org record; -BEGIN - -- Check if org exists - SELECT * FROM public.orgs - INTO org - WHERE public.orgs.id = rescind_invitation.org_id; - - IF NOT FOUND THEN - RETURN 'NO_ORG'; - END IF; - - -- Check if user has admin rights - IF NOT (public.check_min_rights('admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], rescind_invitation.org_id)), rescind_invitation.org_id, NULL::character varying, NULL::bigint)) THEN - RETURN 'NO_RIGHTS'; - END IF; - - -- Find the temporary user - SELECT * FROM public.tmp_users - INTO tmp_user - WHERE public.tmp_users.email = rescind_invitation.email - AND public.tmp_users.org_id = rescind_invitation.org_id; - - IF NOT FOUND THEN - RETURN 'NO_INVITATION'; - END IF; - - -- Check if already cancelled - IF tmp_user.cancelled_at IS NOT NULL THEN - RETURN 'ALREADY_CANCELLED'; - END IF; - - -- Update the cancelled_at field - UPDATE public.tmp_users - SET cancelled_at = CURRENT_TIMESTAMP - WHERE public.tmp_users.id = tmp_user.id; - - RETURN 'OK'; -END; -$$; - --- Grant privileges -ALTER FUNCTION "public"."rescind_invitation" (TEXT, UUID) OWNER TO postgres; - -GRANT ALL ON FUNCTION "public"."rescind_invitation" (TEXT, UUID) TO service_role; - -GRANT -EXECUTE ON FUNCTION "public"."rescind_invitation" (TEXT, UUID) TO authenticated; - --- Function to transform invite_role to regular role -CREATE OR REPLACE FUNCTION public.transform_role_to_non_invite (role_input public.user_min_right) RETURNS public.user_min_right LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -BEGIN - CASE role_input - WHEN 'invite_read'::public.user_min_right THEN RETURN 'read'::public.user_min_right; - WHEN 'invite_upload'::public.user_min_right THEN RETURN 'upload'::public.user_min_right; - WHEN 'invite_write'::public.user_min_right THEN RETURN 'write'::public.user_min_right; - WHEN 'invite_admin'::public.user_min_right THEN RETURN 'admin'::public.user_min_right; - WHEN 'invite_super_admin'::public.user_min_right THEN RETURN 'super_admin'::public.user_min_right; - ELSE RETURN role_input; -- If it's already a non-invite role or unrecognized, return as is - END CASE; -END; -$$; - --- Grant privileges for the function -ALTER FUNCTION public.transform_role_to_non_invite (user_min_right) OWNER TO postgres; - -GRANT ALL ON FUNCTION public.transform_role_to_non_invite (user_min_right) TO service_role; - -GRANT -EXECUTE ON FUNCTION public.transform_role_to_non_invite (user_min_right) TO authenticated; - --- Function to modify permissions for a temporary user -CREATE OR REPLACE FUNCTION "public"."modify_permissions_tmp" ( - "email" TEXT, - "org_id" UUID, - "new_role" "public"."user_min_right" -) RETURNS character varying LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - tmp_user record; - org record; - non_invite_role "public"."user_min_right"; -BEGIN - -- Convert the role to non-invite format for permission checks - non_invite_role := public.transform_role_to_non_invite(new_role); - - -- Check if org exists - SELECT * FROM public.orgs - INTO org - WHERE public.orgs.id = modify_permissions_tmp.org_id; - - IF NOT FOUND THEN - RETURN 'NO_ORG'; - END IF; - - -- Check if user has admin rights - IF NOT (public.check_min_rights('admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], modify_permissions_tmp.org_id)), modify_permissions_tmp.org_id, NULL::character varying, NULL::bigint)) THEN - RETURN 'NO_RIGHTS'; - END IF; - - -- Special permission check for super_admin roles - IF (non_invite_role = 'super_admin'::public.user_min_right) THEN - IF NOT (public.check_min_rights('super_admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], modify_permissions_tmp.org_id)), modify_permissions_tmp.org_id, NULL::character varying, NULL::bigint)) THEN - RETURN 'NO_RIGHTS_FOR_SUPER_ADMIN'; - END IF; - END IF; - - -- Find the temporary user - SELECT * FROM public.tmp_users - INTO tmp_user - WHERE public.tmp_users.email = modify_permissions_tmp.email - AND public.tmp_users.org_id = modify_permissions_tmp.org_id; - - IF NOT FOUND THEN - RETURN 'NO_INVITATION'; - END IF; - - -- Check if invitation has been cancelled - IF tmp_user.cancelled_at IS NOT NULL THEN - RETURN 'INVITATION_CANCELLED'; - END IF; - - -- Make sure we store the non-invite role (we store the raw roles in tmp_users) - UPDATE public.tmp_users - SET role = non_invite_role, - updated_at = CURRENT_TIMESTAMP - WHERE public.tmp_users.id = tmp_user.id; - - RETURN 'OK'; -END; -$$; - --- Grant privileges -ALTER FUNCTION "public"."modify_permissions_tmp" (TEXT, UUID, "public"."user_min_right") OWNER TO postgres; - -GRANT ALL ON FUNCTION "public"."modify_permissions_tmp" (TEXT, UUID, "public"."user_min_right") TO service_role; - -GRANT -EXECUTE ON FUNCTION "public"."modify_permissions_tmp" (TEXT, UUID, "public"."user_min_right") TO authenticated; - --- Function to get invite by magic string lookup -CREATE OR REPLACE FUNCTION "public"."get_invite_by_magic_lookup" ("lookup" TEXT) RETURNS TABLE ( - org_name TEXT, - org_logo TEXT, - role public.user_min_right -) LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - o.name AS org_name, - o.logo AS org_logo, - tmp.role - FROM public.tmp_users tmp - JOIN public.orgs o ON tmp.org_id = o.id - WHERE tmp.invite_magic_string = get_invite_by_magic_lookup.lookup - AND tmp.cancelled_at IS NULL - AND tmp.created_at > (CURRENT_TIMESTAMP - INTERVAL '7 days'); -END; -$$; - --- Grant privileges -ALTER FUNCTION "public"."get_invite_by_magic_lookup" (TEXT) OWNER TO postgres; - -GRANT ALL ON FUNCTION "public"."get_invite_by_magic_lookup" (TEXT) TO service_role; - -GRANT -EXECUTE ON FUNCTION "public"."get_invite_by_magic_lookup" (TEXT) TO authenticated; - -CREATE OR REPLACE FUNCTION "public"."check_org_user_privileges" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' AS $$BEGIN - - -- here we check if the user is a service role in order to bypass this permission check - IF (((SELECT auth.jwt() ->> 'role')='service_role') OR ((select current_user) IS NOT DISTINCT FROM 'postgres')) THEN - RETURN NEW; - END IF; - - IF ("public"."check_min_rights"('super_admin'::"public"."user_min_right", (select auth.uid()), NEW.org_id, NULL::character varying, NULL::bigint)) - THEN - RETURN NEW; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'super_admin'::"public"."user_min_right" - THEN - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'invite_super_admin'::"public"."user_min_right" - THEN - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - RETURN NEW; -END;$$; diff --git a/supabase/migrations/20250619221552_global_stats.sql b/supabase/migrations/20250619221552_global_stats.sql deleted file mode 100644 index 0c8c9d3aea..0000000000 --- a/supabase/migrations/20250619221552_global_stats.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE public.global_stats -ADD COLUMN devices_last_month bigint DEFAULT 0; diff --git a/supabase/migrations/20250714021423_manifest_perf.sql b/supabase/migrations/20250714021423_manifest_perf.sql deleted file mode 100644 index 7613001c31..0000000000 --- a/supabase/migrations/20250714021423_manifest_perf.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Add composite index for manifest table performance optimization --- This index optimizes queries that filter by file_name, file_hash, and app_version_id --- which is used in the deleteManifest function to check for duplicate files -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_manifest_file_name_hash_version ON public.manifest USING btree ( - file_name, file_hash, app_version_id -); - --- This index will significantly improve performance for queries like: --- SELECT * FROM manifest WHERE file_name = ? AND file_hash = ? AND app_version_id <> ? diff --git a/supabase/migrations/20250903010822_consolidated_org_apikey_migrations.sql b/supabase/migrations/20250903010822_consolidated_org_apikey_migrations.sql deleted file mode 100644 index 5611b991cf..0000000000 --- a/supabase/migrations/20250903010822_consolidated_org_apikey_migrations.sql +++ /dev/null @@ -1,329 +0,0 @@ --- Adjust RLS to allow anon + capgkey-based access for apikeys management --- and allow app creation with 'write' rights (instead of 'admin') --- 1) Relax apps insert policy from 'admin' to 'write' for apikey-based access -ALTER POLICY "Allow insert for apikey (write,all) (admin+)" ON public.apps TO anon, -authenticated -WITH -CHECK ( - ( - SELECT - public.check_min_rights( - 'write'::public.user_min_right, - ( - SELECT - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - owner_org, - app_id - ) - ), - owner_org, - app_id, - NULL::bigint - ) - ) -); - --- 2) Policies on public.apikeys for anon using capgkey header -DROP POLICY "Enable all for user based on user_id" ON public.apikeys; - --- Allow owner to SELECT own keys -CREATE POLICY "Allow owner to select own apikeys" ON public.apikeys FOR -SELECT -TO anon, -authenticated USING ( - user_id = ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ) -); - --- Allow owner to INSERT own keys (subkeys) -CREATE POLICY "Allow owner to insert own apikeys" ON public.apikeys FOR INSERT TO anon, -authenticated -WITH -CHECK ( - user_id = ( - SELECT public.get_identity('{write,all}'::public.key_mode []) - ) -); - --- Allow owner to UPDATE own keys -CREATE POLICY "Allow owner to update own apikeys" ON public.apikeys -FOR UPDATE -TO anon, -authenticated USING ( - user_id = ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ) -) -WITH -CHECK ( - user_id = ( - SELECT public.get_identity('{write,all}'::public.key_mode []) - ) -); - --- Allow owner to DELETE own keys -CREATE POLICY "Allow owner to delete own apikeys" ON public.apikeys FOR DELETE TO anon, -authenticated USING ( - user_id = ( - SELECT public.get_identity('{write,all}'::public.key_mode []) - ) -); - -DROP POLICY "Allow webapp to insert" ON public.orgs; - --- Allow creating orgs using apikey (anon role) where created_by matches apikey's user -CREATE POLICY "Allow insert org for apikey or user" ON public.orgs FOR INSERT TO anon, -authenticated -WITH -CHECK ( - created_by = ( - SELECT public.get_identity('{write,all}'::public.key_mode []) - ) -); - -DROP POLICY "Allow org delete for super_admin" ON public.orgs; - --- Allow deleting orgs with apikey when caller has super_admin rights -CREATE POLICY "Allow org delete for super_admin" ON public.orgs FOR DELETE TO anon, -authenticated USING ( - ( - SELECT - public.check_min_rights( - 'super_admin'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - id - ) - ), - id, - NULL::character varying, - NULL::bigint - ) - ) -); - -DROP POLICY "Allow self to modify self" ON public.users; - --- Allow owner to SELECT own user -CREATE POLICY "Allow owner to select own user" ON public.users FOR -SELECT -TO anon, -authenticated USING ( - ( - id = ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ) - ) - AND ( - SELECT public.is_not_deleted(email) - ) -); - --- Allow owner to INSERT own user -CREATE POLICY "Allow owner to insert own users" ON public.users FOR INSERT TO anon, -authenticated -WITH -CHECK ( - ( - id = ( - SELECT public.get_identity('{write,all}'::public.key_mode []) - ) - ) - AND ( - SELECT public.is_not_deleted(email) - ) -); - --- Allow owner to UPDATE own user -CREATE POLICY "Allow owner to update own users" ON public.users -FOR UPDATE -TO anon, -authenticated USING ( - ( - id = ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ) - ) - AND ( - SELECT public.is_not_deleted(email) - ) -) -WITH -CHECK ( - ( - id = ( - SELECT public.get_identity('{write,all}'::public.key_mode []) - ) - ) - AND ( - SELECT public.is_not_deleted(email) - ) -); - --- Allow owner to DELETE own user -CREATE POLICY "Disallow owner to delete own users" ON public.users FOR DELETE TO anon, -authenticated USING (FALSE); - --- Replace legacy self-get policy with org membership-based access for stripe_info -DROP POLICY IF EXISTS "Allow user to self get" ON public.stripe_info; - --- Allow users (JWT or capgkey) who are members of the organization --- linked via orgs.customer_id -> stripe_info.customer_id to read Stripe info -CREATE POLICY "Allow org member to select stripe_info" ON public.stripe_info FOR -SELECT -TO anon, -authenticated USING ( - EXISTS ( - SELECT 1 - FROM - public.orgs AS o - WHERE - o.customer_id = stripe_info.customer_id - AND ( - SELECT - public.check_min_rights( - 'read'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - o.id - ) - ), - o.id, - NULL::character varying, - NULL::bigint - ) - ) - ) -); - -DROP POLICY "Allow owner to update" ON public.devices; - --- Allow org members with write+ to update device rows of apps in their orgs -CREATE POLICY "Allow org member to update devices" ON public.devices -FOR UPDATE -TO anon, -authenticated USING ( - ( - SELECT - public.check_min_rights( - 'write'::public.user_min_right, - ( - SELECT - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - public.get_user_main_org_id_by_app_id(app_id), - app_id - ) - ), - ( - SELECT public.get_user_main_org_id_by_app_id(app_id) - ), - app_id, - NULL::bigint - ) - ) -) -WITH -CHECK ( - ( - SELECT - public.check_min_rights( - 'write'::public.user_min_right, - ( - SELECT - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - public.get_user_main_org_id_by_app_id(app_id), - app_id - ) - ), - ( - SELECT public.get_user_main_org_id_by_app_id(app_id) - ), - app_id, - NULL::bigint - ) - ) -); - -DROP POLICY "Allow devices select" ON public.devices; - --- Allow org members with read+ to query device rows of apps in their orgs -CREATE POLICY "Allow org member to select devices" ON public.devices FOR -SELECT -TO anon, -authenticated USING ( - ( - SELECT - public.check_min_rights( - 'read'::public.user_min_right, - ( - SELECT - public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode [], - ( - SELECT - public.get_user_main_org_id_by_app_id( - app_id - ) - ), - app_id - ) - ), - ( - SELECT public.get_user_main_org_id_by_app_id(app_id) - ), - app_id, - NULL::bigint - ) - ) -); - --- Allow org members with write+ to insert device rows for apps in their orgs -CREATE POLICY "Allow org member to insert devices" ON public.devices FOR INSERT TO anon, -authenticated -WITH -CHECK ( - ( - SELECT - public.check_min_rights( - 'write'::public.user_min_right, - ( - SELECT - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ( - SELECT - public.get_user_main_org_id_by_app_id( - app_id - ) - ), - app_id - ) - ), - ( - SELECT public.get_user_main_org_id_by_app_id(app_id) - ), - app_id, - NULL::bigint - ) - ) -); diff --git a/supabase/migrations/20250908120000_pg_log_and_rls_logging.sql b/supabase/migrations/20250908120000_pg_log_and_rls_logging.sql deleted file mode 100644 index 56d8682303..0000000000 --- a/supabase/migrations/20250908120000_pg_log_and_rls_logging.sql +++ /dev/null @@ -1,764 +0,0 @@ --- Create a small, durable logging helper for RLS-related decisions --- Logs minimal context to PostgreSQL logs and auto-captures caller function -CREATE OR REPLACE FUNCTION public.pg_log (decision text, input jsonb DEFAULT '{}'::jsonb) RETURNS void LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - uid uuid; - req_id text; - role text; - ctx text; - fn text; -BEGIN - uid := auth.uid(); - req_id := current_setting('request.header.x-request-id', true); - role := current_setting('request.jwt.claim.role', true); - - -- Best-effort: extract caller from the PL/pgSQL context - GET DIAGNOSTICS ctx = PG_CONTEXT; - fn := ( - SELECT regexp_replace(line, '^PL/pgSQL function ([^(]+\([^)]*\)).*$', '\1') - FROM regexp_split_to_table(ctx, E'\n') AS line - WHERE line LIKE 'PL/pgSQL function %' - AND line NOT ILIKE '%pg_log(%' - AND line NOT ILIKE '%pg_debug(%' - LIMIT 1 - ); - IF fn IS NULL THEN - fn := 'unknown'; - END IF; - - -- Trim overly large payloads to avoid noisy logs - IF length(coalesce(input::text, '{}')) > 2000 THEN - input := jsonb_build_object('truncated', true); - END IF; - - RAISE LOG 'RLS LOG: fn=%, decision=%, uid=%, role=%, req_id=%, input=%' - , fn - , decision - , uid - , coalesce(role, 'null') - , coalesce(req_id, 'null') - , coalesce(input::text, '{}'); -EXCEPTION WHEN OTHERS THEN - -- Never let logging break execution paths - NULL; -END; -$$; - -ALTER FUNCTION public.pg_log (text, jsonb) OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.pg_log (text, jsonb) -FROM - PUBLIC; - --- Centralize deny logging inside core rights helpers used by RLS --- A) check_min_rights overload without user_id (delegates to the one below) -CREATE OR REPLACE FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - allowed boolean; -BEGIN - allowed := check_min_rights(min_right, (select auth.uid()), org_id, app_id, channel_id); - RETURN allowed; -END; -$$; - --- B) check_min_rights with explicit user_id -CREATE OR REPLACE FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - user_right_record RECORD; -BEGIN - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_NO_UID', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text)); - RETURN false; - END IF; - - FOR user_right_record IN - SELECT org_users.user_right, org_users.app_id, org_users.channel_id - FROM public.org_users - WHERE org_users.org_id = check_min_rights.org_id AND org_users.user_id = check_min_rights.user_id - LOOP - IF (user_right_record.user_right >= min_right AND user_right_record.app_id IS NULL AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights.app_id AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights.app_id AND user_right_record.channel_id = check_min_rights.channel_id) - THEN - RETURN true; - END IF; - END LOOP; - - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); - RETURN false; -END; -$$; - --- C) has_app_right_userid – log when rights check fails -CREATE OR REPLACE FUNCTION "public"."has_app_right_userid" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid" -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_id uuid; - allowed boolean; -Begin - org_id := public.get_user_main_org_id_by_app_id(appid); - - allowed := public.check_min_rights("right", userid, org_id, "appid", NULL::bigint); - IF NOT allowed THEN - PERFORM public.pg_log('deny: HAS_APP_RIGHT_USERID', jsonb_build_object('appid', appid, 'org_id', org_id, 'right', "right"::text, 'userid', userid)); - END IF; - RETURN allowed; -End; -$$; - --- D) has_app_right_apikey – log when api key/org/app restrictions deny or rights deny -CREATE OR REPLACE FUNCTION "public"."has_app_right_apikey" ( - "appid" character varying, - "right" "public"."user_min_right", - "userid" "uuid", - "apikey" "text" -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_id uuid; - api_key record; - allowed boolean; -Begin - org_id := public.get_user_main_org_id_by_app_id(appid); - - SELECT * FROM public.apikeys WHERE key = apikey INTO api_key; - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: APIKEY_ORG_RESTRICT', jsonb_build_object('org_id', org_id, 'appid', appid)); - RETURN false; - END IF; - END IF; - - IF api_key.limited_to_apps IS DISTINCT FROM '{}' THEN - IF NOT (appid = ANY(api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: APIKEY_APP_RESTRICT', jsonb_build_object('appid', appid)); - RETURN false; - END IF; - END IF; - - allowed := public.check_min_rights("right", userid, org_id, "appid", NULL::bigint); - IF NOT allowed THEN - PERFORM public.pg_log('deny: HAS_APP_RIGHT_APIKEY', jsonb_build_object('appid', appid, 'org_id', org_id, 'right', "right"::text, 'userid', userid)); - END IF; - RETURN allowed; -End; -$$; - --- E) get_identity_org_allowed – log when identity resolution fails/denies -CREATE OR REPLACE FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode" [], "org_id" "uuid") RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key record; -Begin - SELECT auth.uid() into auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT "public"."get_apikey_header"() into api_key_text; - - -- No api key found in headers, return - IF api_key_text IS NULL THEN - PERFORM public.pg_log('deny: IDENTITY_ORG_NO_AUTH', jsonb_build_object('org_id', org_id)); - RETURN NULL; - END IF; - - -- Fetch the api key - select * FROM public.apikeys - where key=api_key_text AND - mode=ANY(keymode) - limit 1 into api_key; - - if api_key IS DISTINCT FROM NULL THEN - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: IDENTITY_ORG_UNALLOWED', jsonb_build_object('org_id', org_id)); - RETURN NULL; - END IF; - END IF; - RETURN api_key.user_id; - END IF; - - PERFORM public.pg_log('deny: IDENTITY_ORG_NO_MATCH', jsonb_build_object('org_id', org_id)); - RETURN NULL; -End; -$$; - --- F) get_identity_org_appid – log when identity resolution fails/denies -CREATE OR REPLACE FUNCTION "public"."get_identity_org_appid" ( - "keymode" "public"."key_mode" [], - "org_id" "uuid", - "app_id" character varying -) RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key record; -Begin - SELECT auth.uid() into auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT "public"."get_apikey_header"() into api_key_text; - - -- No api key found in headers, return - IF api_key_text IS NULL THEN - PERFORM public.pg_log('deny: IDENTITY_APP_NO_AUTH', jsonb_build_object('org_id', org_id, 'app_id', app_id)); - RETURN NULL; - END IF; - - -- Fetch the api key - select * FROM public.apikeys - where key=api_key_text AND - mode=ANY(keymode) - limit 1 into api_key; - - if api_key IS DISTINCT FROM NULL THEN - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: IDENTITY_APP_ORG_UNALLOWED', jsonb_build_object('org_id', org_id, 'app_id', app_id)); - RETURN NULL; - END IF; - END IF; - IF api_key.limited_to_apps IS DISTINCT FROM '{}' THEN - IF NOT (app_id = ANY(api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: IDENTITY_APP_UNALLOWED', jsonb_build_object('app_id', app_id)); - RETURN NULL; - END IF; - END IF; - - RETURN api_key.user_id; - END IF; - - PERFORM public.pg_log('deny: IDENTITY_APP_NO_MATCH', jsonb_build_object('org_id', org_id, 'app_id', app_id)); - RETURN NULL; -End; -$$; - --- Optional: drop old helper if it was previously created via seeds --- (Safe even if it does not exist.) -DROP FUNCTION IF EXISTS public.pg_debug (text, jsonb); - --- Instrument selected functions to log on deny/auth failures --- 1) public.get_org_members(guild_id uuid) – log before NO_RIGHTS -CREATE OR REPLACE FUNCTION "public"."get_org_members" ("guild_id" "uuid") RETURNS TABLE ( - "aid" bigint, - "uid" "uuid", - "email" character varying, - "image_url" character varying, - "role" "public"."user_min_right", - "is_tmp" boolean -) LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -begin - IF NOT (public.check_min_rights('read'::public.user_min_right, (select auth.uid()), get_org_members.guild_id, NULL::character varying, NULL::bigint)) THEN - PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('guild_id', get_org_members.guild_id, 'uid', auth.uid())); - raise exception 'NO_RIGHTS'; - END IF; - - return query select * from public.get_org_members((select auth.uid()), get_org_members.guild_id); -End; -$$; - --- 2) public.get_org_owner_id(apikey text, app_id text) – log before NO_RIGHTS -CREATE OR REPLACE FUNCTION "public"."get_org_owner_id" ("apikey" "text", "app_id" "text") RETURNS "uuid" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -Declare - org_owner_id uuid; - real_user_id uuid; - org_id uuid; -Begin - SELECT apps.user_id FROM public.apps WHERE apps.app_id=get_org_owner_id.app_id into org_owner_id; - SELECT public.get_user_main_org_id_by_app_id(app_id) INTO org_id; - - SELECT user_id - INTO real_user_id - FROM public.apikeys - WHERE key=apikey; - - IF (public.is_member_of_org(real_user_id, org_id) IS FALSE) - THEN - PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('app_id', get_org_owner_id.app_id, 'org_id', org_id, 'real_user_id', real_user_id)); - raise exception 'NO_RIGHTS'; - END IF; - - RETURN org_owner_id; -End; -$$; - --- 3) public.get_org_perm_for_apikey(apikey text, app_id text) – log on invalid/none -CREATE OR REPLACE FUNCTION "public"."get_org_perm_for_apikey" ("apikey" "text", "app_id" "text") RETURNS "text" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -<> -Declare - apikey_user_id uuid; - org_id uuid; - user_perm "public"."user_min_right"; -BEGIN - SELECT public.get_user_id(apikey) into apikey_user_id; - - IF apikey_user_id IS NULL THEN - PERFORM public.pg_log('deny: INVALID_APIKEY', jsonb_build_object('app_id', get_org_perm_for_apikey.app_id)); - return 'INVALID_APIKEY'; - END IF; - - SELECT owner_org FROM public.apps - INTO org_id - WHERE apps.app_id=get_org_perm_for_apikey.app_id - limit 1; - - IF org_id IS NULL THEN - PERFORM public.pg_log('deny: NO_APP', jsonb_build_object('app_id', get_org_perm_for_apikey.app_id)); - return 'NO_APP'; - END IF; - - SELECT user_right FROM public.org_users - INTO user_perm - WHERE user_id=apikey_user_id - AND org_users.org_id=get_org_perm_for_apikey.org_id; - - IF user_perm IS NULL THEN - PERFORM public.pg_log('deny: perm_none', jsonb_build_object('org_id', org_id, 'apikey_user_id', apikey_user_id)); - return 'perm_none'; - END IF; - - -- For compatibility reasons if you are a super_admin we will return "owner" - -- The old cli relies on this behaviour, on get_org_perm_for_apikey_v2 we will change that - IF user_perm='super_admin'::"public"."user_min_right" THEN - return 'perm_owner'; - END IF; - - RETURN format('perm_%s', user_perm); -END;$$; - --- 6) public.invite_user_to_org – log when permission checks fail -CREATE OR REPLACE FUNCTION "public"."invite_user_to_org" ( - "email" character varying, - "org_id" "uuid", - "invite_type" "public"."user_min_right" -) RETURNS character varying LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -Declare - org record; - invited_user record; - current_record record; - current_tmp_user record; -Begin - SELECT * FROM public.orgs - INTO org - WHERE public.orgs.id=invite_user_to_org.org_id; - - IF org IS NULL THEN - return 'NO_ORG'; - END IF; - - if NOT (public.check_min_rights('admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], invite_user_to_org.org_id)), invite_user_to_org.org_id, NULL::character varying, NULL::bigint)) THEN - PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('org_id', invite_user_to_org.org_id, 'email', invite_user_to_org.email, 'invite_type', invite_user_to_org.invite_type)); - return 'NO_RIGHTS'; - END IF; - - - if NOT (public.check_min_rights('super_admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], invite_user_to_org.org_id)), invite_user_to_org.org_id, NULL::character varying, NULL::bigint) AND (invite_type is distinct from 'super_admin'::"public"."user_min_right" or invite_type is distinct from 'invite_super_admin'::"public"."user_min_right")) THEN - PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('org_id', invite_user_to_org.org_id, 'email', invite_user_to_org.email, 'invite_type', invite_user_to_org.invite_type)); - return 'NO_RIGHTS'; - END IF; - - SELECT public.users.id FROM public.users - INTO invited_user - WHERE public.users.email=invite_user_to_org.email; - - IF FOUND THEN - -- INSERT INTO org_users (user_id, org_id, user_right) - -- VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - - SELECT public.org_users.id from public.org_users - INTO current_record - WHERE public.org_users.user_id=invited_user.id - AND public.org_users.org_id=invite_user_to_org.org_id; - - IF FOUND THEN - RETURN 'ALREADY_INVITED'; - ELSE - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - - RETURN 'OK'; - END IF; - ELSE - SELECT * FROM public.tmp_users - INTO current_tmp_user - WHERE public.tmp_users.email=invite_user_to_org.email - AND public.tmp_users.org_id=invite_user_to_org.org_id; - - IF FOUND THEN - IF current_tmp_user.cancelled_at IS NOT NULL THEN - -- Check if cancelled less than 3 hours ago - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; -- Allow reinvitation after 3 hours - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - return 'NO_EMAIL'; -- This is expected. the frontend expects this response. - END IF; - - return 'NO_EMAIL'; - END IF; -End; -$$; - --- 7) public.rescind_invitation – log when permission checks fail -CREATE OR REPLACE FUNCTION "public"."rescind_invitation" ("email" TEXT, "org_id" UUID) RETURNS character varying LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - tmp_user record; - org record; -BEGIN - -- Check if org exists - SELECT * FROM public.orgs - INTO org - WHERE public.orgs.id = rescind_invitation.org_id; - - IF NOT FOUND THEN - RETURN 'NO_ORG'; - END IF; - - -- Check if user has admin rights - IF NOT (public.check_min_rights('admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], rescind_invitation.org_id)), rescind_invitation.org_id, NULL::character varying, NULL::bigint)) THEN - PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('org_id', rescind_invitation.org_id, 'email', rescind_invitation.email)); - RETURN 'NO_RIGHTS'; - END IF; - - -- Find the temporary user - SELECT * FROM public.tmp_users - INTO tmp_user - WHERE public.tmp_users.email = rescind_invitation.email - AND public.tmp_users.org_id = rescind_invitation.org_id; - - IF NOT FOUND THEN - RETURN 'NO_INVITATION'; - END IF; - - -- Check if already cancelled - IF tmp_user.cancelled_at IS NOT NULL THEN - RETURN 'ALREADY_CANCELLED'; - END IF; - - -- Update the cancelled_at field - UPDATE public.tmp_users - SET cancelled_at = CURRENT_TIMESTAMP - WHERE public.tmp_users.id = tmp_user.id; - - RETURN 'OK'; -END; -$$; - --- 8) public.modify_permissions_tmp – log when permission checks fail -CREATE OR REPLACE FUNCTION "public"."modify_permissions_tmp" ( - "email" TEXT, - "org_id" UUID, - "new_role" "public"."user_min_right" -) RETURNS character varying LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - tmp_user record; - org record; - non_invite_role "public"."user_min_right"; -BEGIN - -- Convert the role to non-invite format for permission checks - non_invite_role := public.transform_role_to_non_invite(new_role); - - -- Check if org exists - SELECT * FROM public.orgs - INTO org - WHERE public.orgs.id = modify_permissions_tmp.org_id; - - IF NOT FOUND THEN - RETURN 'NO_ORG'; - END IF; - - -- Check if user has admin rights - IF NOT (public.check_min_rights('admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], modify_permissions_tmp.org_id)), modify_permissions_tmp.org_id, NULL::character varying, NULL::bigint)) THEN - PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('org_id', modify_permissions_tmp.org_id, 'email', modify_permissions_tmp.email, 'new_role', modify_permissions_tmp.new_role)); - RETURN 'NO_RIGHTS'; - END IF; - - -- Special permission check for super_admin roles - IF (non_invite_role = 'super_admin'::public.user_min_right) THEN - IF NOT (public.check_min_rights('super_admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::"public"."key_mode"[], modify_permissions_tmp.org_id)), modify_permissions_tmp.org_id, NULL::character varying, NULL::bigint)) THEN - PERFORM public.pg_log('deny: NO_RIGHTS_FOR_SUPER_ADMIN', jsonb_build_object('org_id', modify_permissions_tmp.org_id, 'email', modify_permissions_tmp.email)); - RETURN 'NO_RIGHTS_FOR_SUPER_ADMIN'; - END IF; - END IF; - - -- Find the temporary user - SELECT * FROM public.tmp_users - INTO tmp_user - WHERE public.tmp_users.email = modify_permissions_tmp.email - AND public.tmp_users.org_id = modify_permissions_tmp.org_id; - - IF NOT FOUND THEN - RETURN 'NO_INVITATION'; - END IF; - - -- Check if invitation has been cancelled - IF tmp_user.cancelled_at IS NOT NULL THEN - RETURN 'INVITATION_CANCELLED'; - END IF; - - -- Make sure we store the non-invite role (we store the raw roles in tmp_users) - UPDATE public.tmp_users - SET role = non_invite_role, - updated_at = CURRENT_TIMESTAMP - WHERE public.tmp_users.id = tmp_user.id; - - RETURN 'OK'; -END; -$$; - --- 9) public.get_organization_cli_warnings – log when API key lacks read access -CREATE OR REPLACE FUNCTION "public"."get_organization_cli_warnings" ("orgid" "uuid", "cli_version" "text") RETURNS "jsonb" [] LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - messages jsonb[] := '{}'; - has_read_access boolean; -BEGIN - -- Check if API key has read access - SELECT public.check_min_rights('read'::"public"."user_min_right", public.get_identity_apikey_only('{write,all,upload,read}'::"public"."key_mode"[]), orgid, NULL::character varying, NULL::bigint) INTO has_read_access; - - IF NOT has_read_access THEN - PERFORM public.pg_log('deny: API_KEY_NO_READ', jsonb_build_object('org_id', orgid)); - messages := array_append(messages, jsonb_build_object( - 'message', 'API key does not have read access to this organization', - 'fatal', true - )); - RETURN messages; - END IF; - - -- test the user plan - IF (public.is_paying_and_good_plan_org_action(orgid, ARRAY['mau']::"public"."action_type"[]) = true AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::"public"."action_type"[]) = true AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::"public"."action_type"[]) = false) THEN - messages := array_append(messages, jsonb_build_object( - 'message', 'You have exceeded your storage limit.\nUpload will fail, but you can still download your data.\nMAU and bandwidth limits are not exceeded.\nIn order to upload your data, please upgrade your plan here: https://console.capgo.app/settings/plans.', - 'fatal', true - )); - END IF; - - RETURN messages; -END; -$$; - --- 10) public.transfer_app – log when rights checks fail -CREATE OR REPLACE FUNCTION "public"."transfer_app" ( - "p_app_id" character varying, - "p_new_org_id" "uuid" -) RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - v_old_org_id uuid; - v_user_id uuid; - v_last_transfer jsonb; - v_last_transfer_date timestamp; -BEGIN - -- Get the current owner_org - SELECT owner_org, transfer_history[array_length(transfer_history, 1)] - INTO v_old_org_id, v_last_transfer - FROM public.apps - WHERE app_id = p_app_id; - - -- Check if app exists - IF v_old_org_id IS NULL THEN - RAISE EXCEPTION 'App % not found', p_app_id; - END IF; - - -- Get the current user ID - v_user_id := (select auth.uid()); - - IF NOT (public.check_min_rights('super_admin'::"public"."user_min_right", v_user_id, v_old_org_id, NULL::character varying, NULL::bigint)) THEN - PERFORM public.pg_log('deny: TRANSFER_OLD_ORG_RIGHTS', jsonb_build_object('app_id', p_app_id, 'old_org_id', v_old_org_id, 'new_org_id', p_new_org_id, 'uid', v_user_id)); - RAISE EXCEPTION 'You are not authorized to transfer this app. (You don''t have super_admin rights on the old organization)'; - END IF; - - IF NOT (public.check_min_rights('super_admin'::"public"."user_min_right", v_user_id, p_new_org_id, NULL::character varying, NULL::bigint)) THEN - PERFORM public.pg_log('deny: TRANSFER_NEW_ORG_RIGHTS', jsonb_build_object('app_id', p_app_id, 'old_org_id', v_old_org_id, 'new_org_id', p_new_org_id, 'uid', v_user_id)); - RAISE EXCEPTION 'You are not authorized to transfer this app. (You don''t have super_admin rights on the new organization)'; - END IF; - - -- Check if enough time has passed since last transfer - IF v_last_transfer IS NOT NULL THEN - v_last_transfer_date := (v_last_transfer->>'transferred_at')::timestamp; - IF v_last_transfer_date + interval '32 days' > NOW() THEN - RAISE EXCEPTION 'Cannot transfer app. Must wait at least 32 days between transfers. Last transfer was on %', v_last_transfer_date; - END IF; - END IF; - - -- Update the app's owner_org and user_id - UPDATE public.apps - SET - owner_org = p_new_org_id, - updated_at = NOW(), - transfer_history = COALESCE(transfer_history, '{}') || jsonb_build_object( - 'transferred_at', NOW(), - 'transferred_from', v_old_org_id, - 'transferred_to', p_new_org_id, - 'initiated_by', v_user_id - )::jsonb - WHERE app_id = p_app_id; - - -- Update app_versions owner_org - UPDATE public.app_versions - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - -- Update app_versions_meta owner_org - UPDATE public.app_versions_meta - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - -- Update channel_devices owner_org - UPDATE public.channel_devices - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - -- Update channels owner_org - UPDATE public.channels - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - -- Update notifications owner_org - UPDATE public.notifications - SET owner_org = p_new_org_id - WHERE owner_org = v_old_org_id; -END; -$$; - --- 4) public.get_orgs_v6() – log on auth failures -CREATE OR REPLACE FUNCTION "public"."get_orgs_v6" () RETURNS TABLE ( - "gid" "uuid", - "created_by" "uuid", - "logo" "text", - "name" "text", - "role" character varying, - "paying" boolean, - "trial_left" integer, - "can_use_more" boolean, - "is_canceled" boolean, - "app_count" bigint, - "subscription_start" timestamp with time zone, - "subscription_end" timestamp with time zone, - "management_email" "text", - "is_yearly" boolean -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT "public"."get_apikey_header"() into api_key_text; - user_id := NULL; - - -- Check for API key first - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.apikeys WHERE key=api_key_text into api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - user_id := api_key.user_id; - - -- Check limited_to_orgs only if api_key exists and has restrictions - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - return query select orgs.* FROM public.get_orgs_v6(user_id) orgs - where orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - -- If no valid API key user_id yet, try to get FROM public.identity - IF user_id IS NULL THEN - SELECT public.get_identity() into user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - return query select * FROM public.get_orgs_v6(user_id); -END; -$$; - --- 5) public.public.check_org_user_privileges() – log on privilege escalation -CREATE OR REPLACE FUNCTION "public"."check_org_user_privileges" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' AS $$BEGIN - - -- here we check if the user is a service role in order to bypass this permission check - IF (((SELECT auth.jwt() ->> 'role')='service_role') OR ((select current_user) IS NOT DISTINCT FROM 'postgres')) THEN - RETURN NEW; - END IF; - - IF ("public"."check_min_rights"('super_admin'::"public"."user_min_right", (select auth.uid()), NEW.org_id, NULL::character varying, NULL::bigint)) - THEN - RETURN NEW; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'super_admin'::"public"."user_min_right" - THEN - PERFORM public.pg_log('deny: ELEVATE_SUPER_ADMIN', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'invite_super_admin'::"public"."user_min_right" - THEN - PERFORM public.pg_log('deny: ELEVATE_INVITE_SUPER_ADMIN', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - RETURN NEW; -END;$$; diff --git a/supabase/migrations/20250909094709_better_account_delete.sql b/supabase/migrations/20250909094709_better_account_delete.sql deleted file mode 100644 index 083dbcb444..0000000000 --- a/supabase/migrations/20250909094709_better_account_delete.sql +++ /dev/null @@ -1,178 +0,0 @@ --- Create to_delete_accounts table -CREATE TABLE public.to_delete_accounts ( - id SERIAL PRIMARY KEY, - account_id UUID NOT NULL REFERENCES public.users (id) ON DELETE CASCADE, - removed_data JSONB, - removal_date TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Ensure only one pending delete per account and efficient scheduling -CREATE UNIQUE INDEX IF NOT EXISTS to_delete_accounts_account_id_key ON public.to_delete_accounts (account_id); - -CREATE INDEX IF NOT EXISTS to_delete_accounts_removal_date_idx ON public.to_delete_accounts (removal_date); - --- Enable Row Level Security -ALTER TABLE public.to_delete_accounts ENABLE ROW LEVEL SECURITY; - --- Create RLS policy that denies access to all users --- Only service_role or bypassing RLS can access this table -CREATE POLICY "Deny all access" ON public.to_delete_accounts FOR ALL USING (false) -WITH - CHECK (false); - --- Grant permissions to service_role for system operations -GRANT ALL ON TABLE public.to_delete_accounts TO service_role; - -GRANT ALL ON SEQUENCE public.to_delete_accounts_id_seq TO service_role; - --- Function to check if an account is disabled (marked for deletion) -CREATE OR REPLACE FUNCTION public.is_account_disabled (user_id UUID) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -BEGIN - -- Check if the user_id exists in the to_delete_accounts table - RETURN EXISTS ( - SELECT 1 - FROM public.to_delete_accounts - WHERE account_id = user_id - ); -END; -$$; - --- Function to get the removal date for a disabled account -CREATE OR REPLACE FUNCTION public.get_account_removal_date (user_id UUID) RETURNS TIMESTAMPTZ LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - removal_date TIMESTAMPTZ; -BEGIN - -- Get the removal_date for the user_id - SELECT to_delete_accounts.removal_date INTO removal_date - FROM public.to_delete_accounts - WHERE account_id = user_id; - - -- Throw exception if account is not in the table - IF removal_date IS NULL THEN - RAISE EXCEPTION 'Account with ID % is not marked for deletion', user_id; - END IF; - - RETURN removal_date; -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - user_id_fn uuid; - user_email text; -BEGIN - -- Get the current user ID and email - SELECT "auth"."uid"() INTO user_id_fn; - SELECT "email" INTO user_email FROM "auth"."users" WHERE "id" = user_id_fn; - - -- Trigger the queue-based deletion process - -- This cancels the subscriptions of the user's organizations - PERFORM "pgmq"."send"( - 'on_user_delete'::text, - "jsonb_build_object"( - 'user_id', user_id_fn, - 'email', user_email - ) - ); - - -- Mark the user for deletion - INSERT INTO "public"."to_delete_accounts" ( - "account_id", - "removal_date", - "removed_data" - ) VALUES - ( - user_id_fn, - NOW() + INTERVAL '30 days', - "jsonb_build_object"('email', user_email, 'apikeys', (SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn)) - ); - - -- Delete the API keys - DELETE FROM "public"."apikeys" WHERE "public"."apikeys"."user_id" = user_id_fn; -END; -$$; - --- Function to permanently delete accounts that have passed their removal_date --- This function can only be called by PostgreSQL/cron jobs, not by users -CREATE OR REPLACE FUNCTION "public"."delete_accounts_marked_for_deletion" () RETURNS TABLE (deleted_count INTEGER, deleted_user_ids UUID[]) LANGUAGE "plpgsql" SECURITY DEFINER AS $$ -DECLARE - account_record RECORD; - deleted_users UUID[] := '{}'; - total_deleted INTEGER := 0; -BEGIN - -- Loop through all accounts marked for deletion where removal_date has passed - FOR account_record IN - SELECT "account_id", "removal_date", "removed_data" - FROM "public"."to_delete_accounts" - WHERE "removal_date" < NOW() - LOOP - BEGIN - -- A: Delete from public.users table - DELETE FROM "public"."users" WHERE "id" = account_record.account_id; - - -- B: Delete from auth.users table - DELETE FROM "auth"."users" WHERE "id" = account_record.account_id; - - -- C: Remove from to_delete_accounts table - DELETE FROM "public"."to_delete_accounts" WHERE "account_id" = account_record.account_id; - - -- Track the deleted user - deleted_users := "array_append"(deleted_users, account_record.account_id); - total_deleted := total_deleted + 1; - - -- Log the deletion (optional) - RAISE NOTICE 'Successfully deleted account: % (removal date: %)', - account_record.account_id, account_record.removal_date; - - EXCEPTION - WHEN OTHERS THEN - -- Log the error but continue with other accounts - RAISE WARNING 'Failed to delete account %: %', account_record.account_id, SQLERRM; - END; - END LOOP; - - -- Return results - deleted_count := total_deleted; - deleted_user_ids := deleted_users; - RETURN NEXT; - - RAISE NOTICE 'Deletion process completed. Total accounts deleted: %', total_deleted; -END; -$$; - --- Revoke all permissions from public (no one can execute by default) --- Revoke all permissions from public (default), anon, and authenticated users -REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion" () -FROM - PUBLIC; - -REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion" () -FROM - anon; - -REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion" () -FROM - authenticated; - --- Grant execution permission only to postgres superuser and service_role -GRANT -EXECUTE ON FUNCTION "public"."delete_accounts_marked_for_deletion" () TO postgres; - -GRANT -EXECUTE ON FUNCTION "public"."delete_accounts_marked_for_deletion" () TO service_role; - --- Create a cron job to run the account deletion function every minute --- This will process and permanently delete accounts that have passed their removal_date -SELECT - "cron"."schedule" ( - 'delete-expired-accounts', -- job name - '* * * * *', -- cron expression (every minute) - 'SELECT "public"."delete_accounts_marked_for_deletion"();' -- SQL command - ); diff --git a/supabase/migrations/20250913161225_lint_warning_fixes_followup.sql b/supabase/migrations/20250913161225_lint_warning_fixes_followup.sql deleted file mode 100644 index 0c9f493b59..0000000000 --- a/supabase/migrations/20250913161225_lint_warning_fixes_followup.sql +++ /dev/null @@ -1,360 +0,0 @@ --- Consolidated lint fixes for public schema --- A) check_min_rights (4-arg) call overload explicitly -CREATE OR REPLACE FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - allowed boolean; -BEGIN - allowed := public.check_min_rights(min_right, (select auth.uid()), org_id, app_id, channel_id); - RETURN allowed; -END; -$$; - --- B) check_revert_to_builtin_version: qualify INSERT target -CREATE OR REPLACE FUNCTION "public"."check_revert_to_builtin_version" ("appid" character varying) RETURNS integer LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - DECLARE - version_id INTEGER; - BEGIN - SELECT id INTO version_id FROM public.app_versions WHERE name = 'builtin' AND app_id = appid; - IF NOT FOUND THEN - INSERT INTO public.app_versions(name, app_id, storage_provider) - VALUES ('builtin', appid, 'r2') - RETURNING id INTO version_id; - END IF; - RETURN version_id; - END; -END; -$$; - --- C) get_plan_usage_percent_detailed(orgid, cycle_start, cycle_end): composite via SELECT INTO -CREATE OR REPLACE FUNCTION "public"."get_plan_usage_percent_detailed" ( - "orgid" "uuid", - "cycle_start" "date", - "cycle_end" "date" -) RETURNS TABLE ( - "total_percent" double precision, - "mau_percent" double precision, - "bandwidth_percent" double precision, - "storage_percent" double precision -) LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - current_plan_max public.stats_table; - total_stats public.stats_table; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; -BEGIN - SELECT * INTO current_plan_max FROM public.get_current_plan_max_org(orgid); - SELECT mau, bandwidth, storage INTO total_stats FROM public.get_total_metrics(orgid, cycle_start, cycle_end); - percent_mau := public.convert_number_to_percent(total_stats.mau, current_plan_max.mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, current_plan_max.bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, current_plan_max.storage); - RETURN QUERY SELECT GREATEST(percent_mau, percent_bandwidth, percent_storage), percent_mau, percent_bandwidth, percent_storage; -END; -$$; - --- D) exist_app_versions: mark unused param -CREATE OR REPLACE FUNCTION "public"."exist_app_versions" ( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - PERFORM apikey; - RETURN (SELECT EXISTS (SELECT 1 FROM public.app_versions WHERE app_id=appid AND name=name_version)); -END; -$$; - --- E) get_metered_usage(orgid): select only stats_table attributes -CREATE OR REPLACE FUNCTION "public"."get_metered_usage" ("orgid" "uuid") RETURNS "public"."stats_table" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - current_usage public.stats_table; - max_plan public.stats_table; - result public.stats_table; -BEGIN - SELECT mau, bandwidth, storage INTO current_usage FROM public.get_total_metrics(orgid); - SELECT mau, bandwidth, storage INTO max_plan FROM public.get_current_plan_max_org(orgid); - result.mau := GREATEST(current_usage.mau - max_plan.mau, 0); - result.bandwidth := GREATEST(current_usage.bandwidth - max_plan.bandwidth, 0); - result.storage := GREATEST(current_usage.storage - max_plan.storage, 0); - RETURN result; -END; -$$; - --- F) get_next_cron_time: remove unused day/month/dow patterns -CREATE OR REPLACE FUNCTION "public"."get_next_cron_time" ("p_schedule" "text", "p_timestamp" timestamptz) RETURNS timestamptz LANGUAGE plpgsql -SET - search_path = '' AS $$ -DECLARE - parts text[]; - minute_pattern text; - hour_pattern text; - next_minute int; - next_hour int; - next_time timestamptz; -BEGIN - parts := regexp_split_to_array(p_schedule, '\s+'); - minute_pattern := parts[1]; - hour_pattern := parts[2]; - next_minute := public.get_next_cron_value(minute_pattern, EXTRACT(MINUTE FROM p_timestamp)::int, 60); - next_hour := public.get_next_cron_value(hour_pattern, EXTRACT(HOUR FROM p_timestamp)::int, 24); - next_time := date_trunc('hour', p_timestamp) + make_interval(hours => next_hour - EXTRACT(HOUR FROM p_timestamp)::int, mins => next_minute); - IF next_time <= p_timestamp THEN - IF hour_pattern LIKE '*/%' THEN - next_time := next_time + make_interval(hours => public.parse_step_pattern(hour_pattern)); - ELSIF minute_pattern LIKE '*/%' THEN - next_time := next_time + make_interval(mins => public.parse_step_pattern(minute_pattern)); - ELSE - next_time := next_time + interval '1 day'; - END IF; - END IF; - RETURN next_time; -END; -$$; - --- G) get_next_cron_value: remove unused variable -CREATE OR REPLACE FUNCTION "public"."get_next_cron_value" ("pattern" text, "current_val" int, "max_val" int) RETURNS int LANGUAGE plpgsql -SET - search_path = '' AS $$ -BEGIN - IF pattern = '*' THEN - RETURN current_val; - ELSIF pattern LIKE '*/%' THEN - DECLARE step int := public.parse_step_pattern(pattern); - temp_next int := current_val + (step - (current_val % step)); - BEGIN - IF temp_next >= max_val THEN RETURN step; ELSE RETURN temp_next; END IF; - END; - ELSE - RETURN pattern::int; - END IF; -END; -$$; - --- H) get_user_id(apikey, app_id): mark app_id used -CREATE OR REPLACE FUNCTION "public"."get_user_id" ("apikey" text, "app_id" text) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE real_user_id uuid; -BEGIN - PERFORM app_id; - SELECT public.get_user_id(apikey) INTO real_user_id; - RETURN real_user_id; -END; -$$; - --- I) is_admin(userid): cast secret to jsonb -CREATE OR REPLACE FUNCTION "public"."is_admin" ("userid" uuid) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE admin_ids_jsonb jsonb; is_admin_flag boolean; mfa_verified boolean; -BEGIN - SELECT decrypted_secret::jsonb INTO admin_ids_jsonb FROM vault.decrypted_secrets WHERE name = 'admin_users'; - is_admin_flag := (admin_ids_jsonb ? userid::text); - SELECT public.verify_mfa() INTO mfa_verified; - RETURN is_admin_flag AND mfa_verified; -END; -$$; - --- J) is_allowed_action: mark apikey used -CREATE OR REPLACE FUNCTION "public"."is_allowed_action" ("apikey" text, "appid" text) RETURNS boolean LANGUAGE plpgsql -SET - search_path = '' AS $$ -BEGIN - PERFORM apikey; - RETURN public.is_allowed_action_org((select owner_org FROM public.apps where app_id=appid)); -END; -$$; - --- J.1) get_weekly_stats: avoid shadowing OUT params -CREATE OR REPLACE FUNCTION "public"."get_weekly_stats" ("app_id" character varying) RETURNS TABLE ( - "all_updates" bigint, - "failed_updates" bigint, - "open_app" bigint -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE seven_days_ago DATE; -BEGIN - seven_days_ago := CURRENT_DATE - INTERVAL '7 days'; - SELECT COALESCE(SUM(install), 0) INTO all_updates FROM public.daily_version WHERE date BETWEEN seven_days_ago AND CURRENT_DATE AND public.daily_version.app_id = get_weekly_stats.app_id; - SELECT COALESCE(SUM(fail), 0) INTO failed_updates FROM public.daily_version WHERE date BETWEEN seven_days_ago AND CURRENT_DATE AND public.daily_version.app_id = get_weekly_stats.app_id; - SELECT COALESCE(SUM(get), 0) INTO open_app FROM public.daily_version WHERE date BETWEEN seven_days_ago AND CURRENT_DATE AND public.daily_version.app_id = get_weekly_stats.app_id; - RETURN QUERY SELECT all_updates, failed_updates, open_app; -END; -$$; - --- K) process_admin_stats: remove unused var -CREATE OR REPLACE FUNCTION "public"."process_admin_stats" () RETURNS void LANGUAGE plpgsql -SET - search_path = '' AS $$ -BEGIN - PERFORM pgmq.send('admin_stats', jsonb_build_object('function_name','logsnag_insights','function_type','cloudflare','payload',jsonb_build_object())); -END; -$$; - --- M) process_function_queue: return bigint that matches signature -CREATE OR REPLACE FUNCTION "public"."process_function_queue" ("queue_name" text) RETURNS bigint LANGUAGE plpgsql -SET - search_path = '' AS $$ -DECLARE headers jsonb; url text; queue_size bigint; calls_needed int; -BEGIN - EXECUTE format('SELECT count(*) FROM pgmq.q_%I', queue_name) INTO queue_size; - IF queue_size > 0 THEN - headers := jsonb_build_object('Content-Type','application/json','apisecret', public.get_apikey()); - url := public.get_db_url() || '/functions/v1/triggers/queue_consumer/sync'; - calls_needed := least(ceil(queue_size / 1000.0)::int, 10); - FOR i IN 1..calls_needed LOOP - PERFORM net.http_post(url := url, headers := headers, body := jsonb_build_object('queue_name', queue_name), timeout_milliseconds := 15000); - END LOOP; - RETURN queue_size; - END IF; - RETURN 0; -END; -$$; - --- N) get_organization_cli_warnings: array init, mark cli_version used -CREATE OR REPLACE FUNCTION "public"."get_organization_cli_warnings" ("orgid" uuid, "cli_version" text) RETURNS jsonb[] LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE messages jsonb[] := ARRAY[]::jsonb[]; has_read_access boolean; -BEGIN - PERFORM cli_version; - SELECT public.check_min_rights('read'::public.user_min_right, public.get_identity_apikey_only('{write,all,upload,read}'::public.key_mode[]), orgid, NULL::varchar, NULL::bigint) INTO has_read_access; - IF NOT has_read_access THEN - messages := array_append(messages, jsonb_build_object('message','API key does not have read access to this organization','fatal',true)); - RETURN messages; - END IF; - IF (public.is_paying_and_good_plan_org_action(orgid, ARRAY['mau']::public.action_type[]) = true AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::public.action_type[]) = true AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::public.action_type[]) = false) THEN - messages := array_append(messages, jsonb_build_object('message','You have exceeded your storage limit.\nUpload will fail, but you can still download your data.\nMAU and bandwidth limits are not exceeded.\nIn order to upload your plan, please upgrade your plan here: https://console.capgo.app/settings/plans.','fatal',true)); - END IF; - RETURN messages; -END; -$$; - --- O) delete_accounts_marked_for_deletion: correct array init -CREATE OR REPLACE FUNCTION "public"."delete_accounts_marked_for_deletion" () RETURNS TABLE (deleted_count integer, deleted_user_ids uuid[]) LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE account_record RECORD; deleted_users uuid[] := ARRAY[]::uuid[]; total_deleted integer := 0; -BEGIN - FOR account_record IN SELECT account_id, removal_date, removed_data FROM public.to_delete_accounts WHERE removal_date < NOW() LOOP - BEGIN - DELETE FROM public.users WHERE id = account_record.account_id; - DELETE FROM auth.users WHERE id = account_record.account_id; - DELETE FROM public.to_delete_accounts WHERE account_id = account_record.account_id; - deleted_users := array_append(deleted_users, account_record.account_id); - total_deleted := total_deleted + 1; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'Failed to delete account %: %', account_record.account_id, SQLERRM; - END; - END LOOP; - deleted_count := total_deleted; deleted_user_ids := deleted_users; RETURN NEXT; -END; -$$; - --- R) invite_user_to_org: read current_record instead of FOUND; remove unreachable -CREATE OR REPLACE FUNCTION "public"."invite_user_to_org" ( - "email" varchar, - "org_id" uuid, - "invite_type" public.user_min_right -) RETURNS varchar LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE org record; invited_user record; current_record record; current_tmp_user record; -BEGIN - SELECT * INTO org FROM public.orgs WHERE public.orgs.id=invite_user_to_org.org_id; - IF org IS NULL THEN RETURN 'NO_ORG'; END IF; - IF NOT (public.check_min_rights('admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id)), invite_user_to_org.org_id, NULL::varchar, NULL::bigint)) THEN RETURN 'NO_RIGHTS'; END IF; - IF NOT (public.check_min_rights('super_admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id)), invite_user_to_org.org_id, NULL::varchar, NULL::bigint) AND (invite_type is distinct from 'super_admin'::public.user_min_right or invite_type is distinct from 'invite_super_admin'::public.user_min_right)) THEN RETURN 'NO_RIGHTS'; END IF; - SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email=invite_user_to_org.email; - IF invited_user IS NOT NULL THEN - SELECT public.org_users.id INTO current_record FROM public.org_users WHERE public.org_users.user_id=invited_user.id AND public.org_users.org_id=invite_user_to_org.org_id; - IF current_record IS NOT NULL THEN RETURN 'ALREADY_INVITED'; - ELSE INSERT INTO public.org_users (user_id, org_id, user_right) VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); RETURN 'OK'; END IF; - ELSE - SELECT * INTO current_tmp_user FROM public.tmp_users WHERE public.tmp_users.email=invite_user_to_org.email AND public.tmp_users.org_id=invite_user_to_org.org_id; - IF current_tmp_user IS NOT NULL THEN - IF current_tmp_user.cancelled_at IS NOT NULL THEN - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN RETURN 'TOO_RECENT_INVITATION_CANCELATION'; ELSE RETURN 'NO_EMAIL'; END IF; - ELSE RETURN 'ALREADY_INVITED'; END IF; - ELSE RETURN 'NO_EMAIL'; END IF; - END IF; -END; -$$; - --- S) rescind_invitation: remove unused org var via PERFORM -CREATE OR REPLACE FUNCTION "public"."rescind_invitation" ("email" TEXT, "org_id" UUID) RETURNS varchar LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE tmp_user record; -BEGIN - PERFORM 1 FROM public.orgs WHERE public.orgs.id = rescind_invitation.org_id; IF NOT FOUND THEN RETURN 'NO_ORG'; END IF; - IF NOT (public.check_min_rights('admin'::public.user_min_right, (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], rescind_invitation.org_id)), rescind_invitation.org_id, NULL::varchar, NULL::bigint)) THEN RETURN 'NO_RIGHTS'; END IF; - SELECT * INTO tmp_user FROM public.tmp_users WHERE public.tmp_users.email = rescind_invitation.email AND public.tmp_users.org_id = rescind_invitation.org_id; - IF NOT FOUND THEN RETURN 'NO_INVITATION'; END IF; - IF tmp_user.cancelled_at IS NOT NULL THEN RETURN 'ALREADY_CANCELLED'; END IF; - UPDATE public.tmp_users SET cancelled_at = CURRENT_TIMESTAMP WHERE public.tmp_users.id = tmp_user.id; - RETURN 'OK'; -END; -$$; - --- T) modify_permissions_tmp: remove unused org var via PERFORM -CREATE OR REPLACE FUNCTION "public"."modify_permissions_tmp" ( - "email" TEXT, - "org_id" UUID, - "new_role" public.user_min_right -) RETURNS varchar LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE tmp_user record; non_invite_role public.user_min_right; -BEGIN - non_invite_role := public.transform_role_to_non_invite(new_role); - PERFORM 1 FROM public.orgs WHERE public.orgs.id = modify_permissions_tmp.org_id; IF NOT FOUND THEN RETURN 'NO_ORG'; END IF; - IF NOT (public.check_min_rights('admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], modify_permissions_tmp.org_id)), modify_permissions_tmp.org_id, NULL::varchar, NULL::bigint)) THEN RETURN 'NO_RIGHTS'; END IF; - IF (non_invite_role = 'super_admin'::public.user_min_right) THEN - IF NOT (public.check_min_rights('super_admin'::public.user_min_right, (select public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], modify_permissions_tmp.org_id)), modify_permissions_tmp.org_id, NULL::varchar, NULL::bigint)) THEN RETURN 'NO_RIGHTS_FOR_SUPER_ADMIN'; END IF; - END IF; - SELECT * INTO tmp_user FROM public.tmp_users WHERE public.tmp_users.email = modify_permissions_tmp.email AND public.tmp_users.org_id = modify_permissions_tmp.org_id; - IF NOT FOUND THEN RETURN 'NO_INVITATION'; END IF; - IF tmp_user.cancelled_at IS NOT NULL THEN RETURN 'INVITATION_CANCELLED'; END IF; - UPDATE public.tmp_users SET role = non_invite_role, updated_at = CURRENT_TIMESTAMP WHERE public.tmp_users.id = tmp_user.id; - RETURN 'OK'; -END; -$$; - --- U) get_org_members(user_id, guild_id): align to 6 columns; mark user_id used -DROP FUNCTION IF EXISTS public.get_org_members (uuid, uuid); - -CREATE FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) RETURNS TABLE ( - "aid" bigint, - "uid" uuid, - "email" varchar, - "image_url" varchar, - "role" public.user_min_right, - "is_tmp" boolean -) LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -BEGIN - PERFORM user_id; - RETURN QUERY SELECT o.id, users.id, users.email, users.image_url, o.user_right, false - FROM public.org_users o JOIN public.users ON users.id = o.user_id - WHERE o.org_id=get_org_members.guild_id AND public.is_member_of_org(users.id, o.org_id); -END; -$$; diff --git a/supabase/migrations/20250916032824_fix_retention.sql b/supabase/migrations/20250916032824_fix_retention.sql deleted file mode 100644 index 0d9758328e..0000000000 --- a/supabase/migrations/20250916032824_fix_retention.sql +++ /dev/null @@ -1,43 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."update_app_versions_retention" () RETURNS void LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - -- Use a more efficient approach with direct timestamp comparison - UPDATE public.app_versions - SET deleted = true - WHERE app_versions.deleted = false - AND (SELECT retention FROM public.apps WHERE apps.app_id = app_versions.app_id) >= 0 - AND (SELECT retention FROM public.apps WHERE apps.app_id = app_versions.app_id) < 63113904 - AND app_versions.created_at < ( - SELECT NOW() - make_interval(secs => apps.retention) - FROM public.apps - WHERE apps.app_id = app_versions.app_id - ) - AND NOT EXISTS ( - SELECT 1 - FROM public.channels - WHERE channels.app_id = app_versions.app_id - AND channels.version = app_versions.id - ); -END; -$$; - -ALTER FUNCTION "public"."update_app_versions_retention" () OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."update_app_versions_retention" () -FROM - PUBLIC; - -REVOKE ALL ON FUNCTION "public"."update_app_versions_retention" () -FROM - anon; - -REVOKE ALL ON FUNCTION "public"."update_app_versions_retention" () -FROM - authenticated; - -GRANT -EXECUTE ON FUNCTION "public"."update_app_versions_retention" () TO postgres; - -GRANT -EXECUTE ON FUNCTION "public"."update_app_versions_retention" () TO service_role; diff --git a/supabase/migrations/20250920120000_remove_legal_and_update_notification_defaults.sql b/supabase/migrations/20250920120000_remove_legal_and_update_notification_defaults.sql deleted file mode 100644 index 382ea0d7b2..0000000000 --- a/supabase/migrations/20250920120000_remove_legal_and_update_notification_defaults.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Rename notification settings columns to snake_case, drop legacy legal flag, --- and ensure defaults align with the new onboarding flow. -alter table public.users -rename column "enableNotifications" to enable_notifications; - -alter table public.users -rename column "optForNewsletters" to opt_for_newsletters; - -alter table public.users -alter column enable_notifications set default true; - -alter table public.users -alter column opt_for_newsletters set default true; - -update public.users -set enable_notifications = true -where enable_notifications is distinct from true; - -update public.users -set opt_for_newsletters = true -where opt_for_newsletters is distinct from true; - -alter table public.users -drop column if exists "legalAccepted"; - -update auth.users -set - raw_user_meta_data - = coalesce(raw_user_meta_data, '{}'::jsonb) - 'activation' -where raw_user_meta_data ? 'activation'; - -update auth.users -set raw_user_meta_data = '{}'::jsonb -where raw_user_meta_data is null; diff --git a/supabase/migrations/20250920120001_remove_old_version_meta.sql b/supabase/migrations/20250920120001_remove_old_version_meta.sql deleted file mode 100644 index 8262df77c5..0000000000 --- a/supabase/migrations/20250920120001_remove_old_version_meta.sql +++ /dev/null @@ -1,8 +0,0 @@ -alter table public.app_versions_meta -drop column if exists fails; - -alter table public.app_versions_meta -drop column if exists installs; - -alter table public.app_versions_meta -drop column if exists uninstalls; diff --git a/supabase/migrations/20250921120000_device_version_name.sql b/supabase/migrations/20250921120000_device_version_name.sql deleted file mode 100644 index fa79387cbe..0000000000 --- a/supabase/migrations/20250921120000_device_version_name.sql +++ /dev/null @@ -1,86 +0,0 @@ --- Replace device version ID storage with version name and update stats logs accordingly -BEGIN; - -ALTER TABLE public.devices -ADD COLUMN IF NOT EXISTS version_name text; - -DROP INDEX IF EXISTS idx_app_id_version_devices; - -UPDATE public.devices d -SET - version_name = av.name -FROM - public.app_versions AS av -WHERE - av.id = d.version - AND ( - d.version_name IS NULL - OR d.version_name = '' - ); - -UPDATE public.devices -SET - version_name = COALESCE(NULLIF(version_name, ''), 'unknown') -WHERE - version_name IS NULL - OR version_name = ''; - -ALTER TABLE public.devices -ALTER COLUMN version_name -SET DEFAULT 'unknown'; - -ALTER TABLE public.devices -ALTER COLUMN version_name -SET NOT NULL; - --- TODO: remove the old version column in a future migration --- ALTER TABLE public.devices --- DROP COLUMN IF EXISTS version; -ALTER TABLE public.devices -ALTER COLUMN version -DROP NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_app_id_version_name_devices ON public.devices ( - app_id, version_name -); - -ALTER TABLE public.stats -ADD COLUMN IF NOT EXISTS version_name text; - -DROP INDEX IF EXISTS idx_stats_app_id_version; - -UPDATE public.stats s -SET - version_name = av.name -FROM - public.app_versions AS av -WHERE - av.id = s.version - AND ( - s.version_name IS NULL - OR s.version_name = '' - ); - -UPDATE public.stats -SET - version_name = COALESCE(NULLIF(version_name, ''), 'unknown') -WHERE - version_name IS NULL - OR version_name = ''; - -ALTER TABLE public.stats -ALTER COLUMN version_name -SET DEFAULT 'unknown'; - -ALTER TABLE public.stats -ALTER COLUMN version_name -SET NOT NULL; - -ALTER TABLE public.stats -DROP COLUMN IF EXISTS version; - -CREATE INDEX IF NOT EXISTS idx_stats_app_id_version_name ON public.stats ( - app_id, version_name -); - -COMMIT; diff --git a/supabase/migrations/20250927082020_better_app_metrics.sql b/supabase/migrations/20250927082020_better_app_metrics.sql deleted file mode 100644 index 52258d426e..0000000000 --- a/supabase/migrations/20250927082020_better_app_metrics.sql +++ /dev/null @@ -1,239 +0,0 @@ -CREATE TABLE IF NOT EXISTS public.app_metrics_cache ( - id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - org_id uuid NOT NULL REFERENCES public.orgs (id), - start_date date NOT NULL, - end_date date NOT NULL, - response jsonb NOT NULL, - cached_at timestamp with time zone NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX IF NOT EXISTS app_metrics_cache_org_id_key ON public.app_metrics_cache ( - org_id -); - -ALTER TABLE public.app_metrics_cache ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Deny all" ON public.app_metrics_cache FOR ALL USING (false) -WITH -CHECK (false); - -CREATE OR REPLACE FUNCTION public.seed_get_app_metrics_caches( - p_org_id uuid, p_start_date date, p_end_date date -) RETURNS public.app_metrics_cache LANGUAGE plpgsql SECURITY DEFINER -SET -search_path TO '' AS $function$ -DECLARE - metrics_json jsonb; - cache_record public.app_metrics_cache%ROWTYPE; -BEGIN - WITH DateSeries AS ( - SELECT generate_series(p_start_date, p_end_date, '1 day'::interval)::date AS date - ), - all_apps AS ( - SELECT apps.app_id, apps.owner_org - FROM public.apps - WHERE apps.owner_org = p_org_id - UNION - SELECT deleted_apps.app_id, deleted_apps.owner_org - FROM public.deleted_apps - WHERE deleted_apps.owner_org = p_org_id - ), - deleted_metrics AS ( - SELECT - deleted_apps.app_id, - deleted_apps.deleted_at::date AS date, - COUNT(*) AS deleted_count - FROM public.deleted_apps - WHERE deleted_apps.owner_org = p_org_id - AND deleted_apps.deleted_at::date BETWEEN p_start_date AND p_end_date - GROUP BY deleted_apps.app_id, deleted_apps.deleted_at::date - ), - metrics AS ( - SELECT - aa.app_id, - ds.date::date, - COALESCE(dm.mau, 0) AS mau, - COALESCE(dst.storage, 0) AS storage, - COALESCE(db.bandwidth, 0) AS bandwidth, - COALESCE(SUM(dv.get)::bigint, 0) AS get, - COALESCE(SUM(dv.fail)::bigint, 0) AS fail, - COALESCE(SUM(dv.install)::bigint, 0) AS install, - COALESCE(SUM(dv.uninstall)::bigint, 0) AS uninstall - FROM - all_apps aa - CROSS JOIN - DateSeries ds - LEFT JOIN - public.daily_mau dm ON aa.app_id = dm.app_id AND ds.date = dm.date - LEFT JOIN - public.daily_storage dst ON aa.app_id = dst.app_id AND ds.date = dst.date - LEFT JOIN - public.daily_bandwidth db ON aa.app_id = db.app_id AND ds.date = db.date - LEFT JOIN - public.daily_version dv ON aa.app_id = dv.app_id AND ds.date = dv.date - LEFT JOIN - deleted_metrics del ON aa.app_id = del.app_id AND ds.date = del.date - GROUP BY - aa.app_id, ds.date, dm.mau, dst.storage, db.bandwidth, del.deleted_count - ) - SELECT COALESCE( - jsonb_agg(row_to_json(metrics) ORDER BY metrics.app_id, metrics.date), - '[]'::jsonb - ) - INTO metrics_json - FROM metrics; - - INSERT INTO public.app_metrics_cache (org_id, start_date, end_date, response, cached_at) - VALUES (p_org_id, p_start_date, p_end_date, metrics_json, clock_timestamp()) - ON CONFLICT (org_id) DO UPDATE - SET start_date = EXCLUDED.start_date, - end_date = EXCLUDED.end_date, - response = EXCLUDED.response, - cached_at = EXCLUDED.cached_at - RETURNING * INTO cache_record; - - RETURN cache_record; -END; -$function$; - -REVOKE ALL ON FUNCTION public.seed_get_app_metrics_caches(uuid, date, date) -FROM -public; - -REVOKE ALL ON FUNCTION public.seed_get_app_metrics_caches(uuid, date, date) -FROM -anon; - -REVOKE ALL ON FUNCTION public.seed_get_app_metrics_caches(uuid, date, date) -FROM -authenticated; - -REVOKE ALL ON FUNCTION public.seed_get_app_metrics_caches(uuid, date, date) -FROM -service_role; - -CREATE OR REPLACE FUNCTION public.get_app_metrics(org_id uuid) RETURNS TABLE ( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql -SET -search_path TO '' AS $function$ -DECLARE - cycle_start timestamp with time zone; - cycle_end timestamp with time zone; -BEGIN - SELECT subscription_anchor_start, subscription_anchor_end - INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - - RETURN QUERY - SELECT * FROM public.get_app_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$function$; - -REVOKE -EXECUTE ON FUNCTION public.get_app_metrics(org_id uuid) -FROM -public, -anon, -authenticated; - -GRANT -EXECUTE ON FUNCTION public.get_app_metrics(org_id uuid) TO service_role; - -DROP FUNCTION IF EXISTS public.get_app_metrics( - org_id uuid, start_date date, end_date date -); - -CREATE OR REPLACE FUNCTION public.get_app_metrics( - p_org_id uuid, p_start_date date, p_end_date date -) RETURNS TABLE ( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql SECURITY DEFINER -SET -search_path TO '' AS $function$ -DECLARE - cache_entry public.app_metrics_cache%ROWTYPE; - org_exists boolean; -BEGIN - SELECT EXISTS ( - SELECT 1 FROM public.orgs WHERE id = p_org_id - ) INTO org_exists; - - IF NOT org_exists THEN - RETURN; - END IF; - - SELECT * - INTO cache_entry - FROM public.app_metrics_cache - WHERE org_id = p_org_id; - - IF cache_entry.id IS NULL - OR cache_entry.start_date IS DISTINCT FROM p_start_date - OR cache_entry.end_date IS DISTINCT FROM p_end_date - OR cache_entry.cached_at IS NULL - OR cache_entry.cached_at < (NOW() - interval '5 minutes') THEN - cache_entry := public.seed_get_app_metrics_caches(p_org_id, p_start_date, p_end_date); - END IF; - - IF cache_entry.response IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT - metrics.app_id, - metrics.date, - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM jsonb_to_recordset(cache_entry.response) AS metrics( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint - ) - ORDER BY metrics.app_id, metrics.date; -END; -$function$; - -REVOKE -EXECUTE ON FUNCTION public.get_app_metrics(uuid, date, date) -FROM -public, -anon, -authenticated; - -GRANT -EXECUTE ON FUNCTION public.get_app_metrics(uuid, date, date) TO service_role; - -ALTER FUNCTION public.get_app_metrics( - "org_id" uuid, - "start_date" date, - "end_date" date -) OWNER TO "postgres"; diff --git a/supabase/migrations/20250928145642_orgs_last_stats_updated.sql b/supabase/migrations/20250928145642_orgs_last_stats_updated.sql deleted file mode 100644 index e626541b75..0000000000 --- a/supabase/migrations/20250928145642_orgs_last_stats_updated.sql +++ /dev/null @@ -1,211 +0,0 @@ --- Add a nullable column to track when org stats were last refreshed -ALTER TABLE public.orgs -ADD COLUMN stats_updated_at timestamp without time zone; - -ALTER TABLE public.orgs -ADD COLUMN last_stats_updated_at timestamp without time zone; - --- Expose stats_updated_at via get_orgs_v6 helpers -DROP FUNCTION IF EXISTS public.get_orgs_v6(); -DROP FUNCTION IF EXISTS public.get_orgs_v6(uuid); - -CREATE OR REPLACE FUNCTION public.get_orgs_v6() RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamp with time zone, - subscription_end timestamp with time zone, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamp with time zone -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.apikeys WHERE key = api_key_text INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v6(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v6(user_id); -END; -$$; - -CREATE OR REPLACE FUNCTION public.get_orgs_v6(userid uuid) RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamp with time zone, - subscription_end timestamp with time zone, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamp with time zone -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN QUERY - SELECT - sub.id AS gid, - sub.created_by, - sub.logo, - sub.name, - org_users.user_right::varchar AS role, - public.is_paying_org(sub.id) AS paying, - public.is_trial_org(sub.id) AS trial_left, - public.is_allowed_action_org(sub.id) AS can_use_more, - public.is_canceled_org(sub.id) AS is_canceled, - (SELECT count(*) FROM public.apps WHERE owner_org = sub.id) AS app_count, - (sub.f).subscription_anchor_start AS subscription_start, - (sub.f).subscription_anchor_end AS subscription_end, - sub.management_email, - public.is_org_yearly(sub.id) AS is_yearly, - sub.stats_updated_at, - public.get_next_stats_update_date(sub.id) AS next_stats_update_at - FROM ( - SELECT public.get_cycle_info_org(o.id) AS f, o.* FROM public.orgs AS o - ) AS sub - JOIN public.org_users - ON org_users.user_id = userid - AND sub.id = org_users.org_id; -END; -$$; - -GRANT ALL ON FUNCTION public.get_orgs_v6() TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v6() TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v6() TO service_role; -GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO service_role; - --- Refresh cron job frequency for cron stats queue processing -SELECT cron.unschedule('process_cron_stats_queue'); -SELECT cron.schedule( - 'process_cron_stats_queue', - '*/4 * * * *', - 'SELECT public.process_function_queue(''cron_stats'')' -); - --- Ensure subscribed orgs are processed in deterministic (UUID ascending) order -CREATE OR REPLACE FUNCTION public.process_subscribed_orgs() RETURNS void LANGUAGE plpgsql -SET search_path = '' AS $$ -DECLARE - org_record RECORD; -BEGIN - FOR org_record IN ( - SELECT o.id, o.customer_id - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE si.status = 'succeeded' - ORDER BY o.id ASC - ) - LOOP - PERFORM pgmq.send('cron_plan', - jsonb_build_object( - 'function_name', 'cron_plan', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'orgId', org_record.id, - 'customerId', org_record.customer_id - ) - ) - ); - END LOOP; -END; -$$; - -ALTER FUNCTION public.process_subscribed_orgs() OWNER TO postgres; - --- Predict next stats update window for an organization. --- NOTE: supabase postgres operates in UTC, matching pg_cron's timezone expectations. -CREATE OR REPLACE FUNCTION public.get_next_stats_update_date(org uuid) -RETURNS timestamp with time zone LANGUAGE plpgsql -SET search_path = '' AS $$ -DECLARE - cron_schedule constant text := '0 3 * * *'; - next_run timestamptz; - preceding_count integer := 0; - is_target boolean := false; -BEGIN - next_run := public.get_next_cron_time(cron_schedule, NOW()); - WITH paying_orgs AS ( - SELECT o.id - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - -- Paying customers with active subscription - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > next_run) - AND si.subscription_anchor_end > next_run) - -- Trial customers - OR si.trial_at > next_run - ) - ORDER BY o.id ASC - ) - SELECT - COUNT(*) FILTER (WHERE id < org)::int, - COALESCE(BOOL_OR(id = org), false) - INTO preceding_count, is_target - FROM paying_orgs; - - IF NOT is_target THEN - RETURN NULL; - END IF; - - RETURN next_run + make_interval(mins => preceding_count * 4); -END; -$$; - -ALTER FUNCTION public.get_next_stats_update_date(org uuid) OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.process_subscribed_orgs() FROM public, -anon, -authenticated; -GRANT EXECUTE ON FUNCTION public.process_subscribed_orgs() TO service_role; -GRANT ALL ON FUNCTION public.get_next_stats_update_date(uuid) TO anon; -GRANT ALL ON FUNCTION public.get_next_stats_update_date(uuid) TO authenticated; -GRANT ALL ON FUNCTION public.get_next_stats_update_date(uuid) TO service_role; diff --git a/supabase/migrations/20251007132214_global_stats_registers_storage.sql b/supabase/migrations/20251007132214_global_stats_registers_storage.sql deleted file mode 100644 index 2b336fa389..0000000000 --- a/supabase/migrations/20251007132214_global_stats_registers_storage.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Add daily registrations and bundle storage metrics to global_stats -ALTER TABLE public.global_stats -ADD COLUMN registers_today bigint DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN bundle_storage_gb double precision DEFAULT 0 NOT NULL; - --- Helper function to compute total bundle storage in bytes -CREATE OR REPLACE FUNCTION "public"."total_bundle_storage_bytes"() RETURNS bigint - LANGUAGE "sql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ - SELECT ( - -- Sum of bundle sizes from app_versions_meta - COALESCE( - (SELECT SUM(size) FROM public.app_versions_meta), - 0 - ) + - -- Sum of manifest file sizes for non-deleted versions - COALESCE( - (SELECT SUM(m.file_size) - FROM public.manifest m - WHERE EXISTS ( - SELECT 1 - FROM public.app_versions av - WHERE av.id = m.app_version_id - AND av.deleted = false - )), - 0 - ) - )::bigint; -$$; - -COMMENT ON FUNCTION "public"."total_bundle_storage_bytes"() IS 'Returns total storage in bytes including both bundle sizes (app_versions_meta.size) and manifest file sizes'; - -REVOKE ALL ON FUNCTION public.total_bundle_storage_bytes() -FROM -public; - -GRANT -EXECUTE ON FUNCTION public.total_bundle_storage_bytes() TO service_role; - --- Backfill registers_today using historical user signup data -WITH -user_counts AS ( - SELECT - TO_CHAR(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date_id, - COUNT(*)::bigint AS register_count - FROM - public.users - WHERE - created_at IS NOT NULL - GROUP BY - 1 -) - -UPDATE public.global_stats AS gs -SET - registers_today = uc.register_count -FROM - user_counts AS uc -WHERE - gs.date_id = uc.date_id; diff --git a/supabase/migrations/20251007134349_cron_plan_from_stats_backend.sql b/supabase/migrations/20251007134349_cron_plan_from_stats_backend.sql deleted file mode 100644 index ddbd9f9b2b..0000000000 --- a/supabase/migrations/20251007134349_cron_plan_from_stats_backend.sql +++ /dev/null @@ -1,61 +0,0 @@ --- Remove the daily process_subscribed_orgs cron job -SELECT cron.unschedule('process_subscribed_orgs'); - --- Remove the current process_cron_plan_queue job -SELECT cron.unschedule('process_cron_plan_queue'); - --- Reschedule process_cron_plan_queue to run every minute instead of every 2 hours -SELECT cron.schedule( - 'process_cron_plan_queue', - '* * * * *', - 'SELECT public.process_function_queue(''cron_plan'')' -); - --- Add column to track when plan was last calculated -ALTER TABLE public.stripe_info ADD COLUMN IF NOT EXISTS plan_calculated_at timestamp with time zone; - --- Update the queue function to check if plan was calculated in the last hour -CREATE OR REPLACE FUNCTION public.queue_cron_plan_for_org( - org_id uuid, customer_id text -) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - last_calculated timestamptz; -BEGIN - -- Check when plan was last calculated for this customer - SELECT plan_calculated_at INTO last_calculated - FROM public.stripe_info - WHERE stripe_info.customer_id = queue_cron_plan_for_org.customer_id; - - -- Only queue if plan wasn't calculated in the last hour - IF last_calculated IS NULL OR last_calculated < NOW() - INTERVAL '1 hour' THEN - PERFORM pgmq.send('cron_plan', - jsonb_build_object( - 'function_name', 'cron_plan', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'orgId', org_id, - 'customerId', customer_id - ) - ) - ); - END IF; -END; -$$; - - -ALTER FUNCTION public.queue_cron_plan_for_org(uuid, text) OWNER TO postgres; - --- Revoke all permissions first, then grant only to service_role -REVOKE ALL ON FUNCTION public.queue_cron_plan_for_org(uuid, text) FROM public; -REVOKE ALL ON FUNCTION public.queue_cron_plan_for_org(uuid, text) FROM anon; -REVOKE ALL ON FUNCTION public.queue_cron_plan_for_org( - uuid, text -) FROM authenticated; -GRANT ALL ON FUNCTION public.queue_cron_plan_for_org( - uuid, text -) TO service_role; diff --git a/supabase/migrations/20251014105957_rename_plan_cron.sql b/supabase/migrations/20251014105957_rename_plan_cron.sql deleted file mode 100644 index 437ec31673..0000000000 --- a/supabase/migrations/20251014105957_rename_plan_cron.sql +++ /dev/null @@ -1,81 +0,0 @@ --- Simple renaming of cron_stats to cron_stat_app and cron_plan to cron_stat_org - --- Unschedule existing cron jobs -SELECT cron.unschedule('process_cron_stats_queue'); -SELECT cron.unschedule('process_cron_stats_jobs'); -SELECT cron.unschedule('process_cron_plan_queue'); - --- Rename the message queues -SELECT pgmq.drop_queue('cron_stats'); -SELECT pgmq.drop_queue('cron_plan'); -SELECT pgmq.create('cron_stat_app'); -SELECT pgmq.create('cron_stat_org'); - --- Reschedule the cron jobs with new queue names -SELECT cron.schedule( - 'process_cron_stat_app_jobs', - '0 */6 * * *', - 'SELECT process_cron_stats_jobs();' -); - -SELECT cron.schedule( - 'process_cron_stat_app_queue', - '* * * * *', - 'SELECT public.process_function_queue(''cron_stat_app'')' -); - -SELECT cron.schedule( - 'process_cron_stat_org_queue', - '* * * * *', - 'SELECT public.process_function_queue(''cron_stat_org'')' -); - --- Update the queue_cron_stat_org_for_org function to use the new queue name -CREATE OR REPLACE FUNCTION public.queue_cron_stat_org_for_org( - org_id uuid, customer_id text -) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - last_calculated timestamptz; -BEGIN - -- Check when plan was last calculated for this customer - SELECT plan_calculated_at INTO last_calculated - FROM public.stripe_info - WHERE stripe_info.customer_id = queue_cron_stat_org_for_org.customer_id; - - -- Only queue if plan wasn't calculated in the last hour - IF last_calculated IS NULL OR last_calculated < NOW() - INTERVAL '1 hour' THEN - PERFORM pgmq.send('cron_stat_org', - jsonb_build_object( - 'function_name', 'cron_stat_org', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'orgId', org_id, - 'customerId', customer_id - ) - ) - ); - END IF; -END; -$$; - -ALTER FUNCTION public.queue_cron_stat_org_for_org(uuid, text) OWNER TO postgres; - --- Revoke all permissions first, then grant only to service_role -REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org( - uuid, text -) FROM public; -REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid, text) FROM anon; -REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org( - uuid, text -) FROM authenticated; -GRANT ALL ON FUNCTION public.queue_cron_stat_org_for_org( - uuid, text -) TO service_role; - --- Drop the old function that is no longer needed -DROP FUNCTION IF EXISTS public.queue_cron_plan_for_org(uuid, text); diff --git a/supabase/migrations/20251014120000_add_batch_size_to_process_function_queue.sql b/supabase/migrations/20251014120000_add_batch_size_to_process_function_queue.sql deleted file mode 100644 index b251886740..0000000000 --- a/supabase/migrations/20251014120000_add_batch_size_to_process_function_queue.sql +++ /dev/null @@ -1,48 +0,0 @@ --- Add batch_size parameter to process_function_queue function -CREATE OR REPLACE FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer DEFAULT 950) RETURNS bigint LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - request_id text; - headers jsonb; - url text; - queue_size bigint; - calls_needed int; - i int; -BEGIN - -- Check if the queue has elements - EXECUTE format('SELECT count(*) FROM pgmq.q_%I', queue_name) INTO queue_size; - - -- Only make the HTTP request if the queue is not empty - IF queue_size > 0 THEN - headers := jsonb_build_object( - 'Content-Type', 'application/json', - 'apisecret', public.get_apikey() - ); - url := public.get_db_url() || '/functions/v1/triggers/queue_consumer/sync'; - - -- Calculate how many times to call the sync endpoint (1 call per batch_size items, max 10 calls) - calls_needed := least(ceil(queue_size / batch_size::float)::int, 10); - - -- Call the endpoint multiple times if needed - FOR i IN 1..calls_needed LOOP - SELECT INTO request_id net.http_post( - url := url, - headers := headers, - body := jsonb_build_object('queue_name', queue_name, 'batch_size', batch_size), - timeout_milliseconds := 15000 - ); - END LOOP; - - RETURN request_id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) TO "anon"; -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) TO "authenticated"; -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) TO "service_role"; diff --git a/supabase/migrations/20251014135440_add_cron_sync_sub.sql b/supabase/migrations/20251014135440_add_cron_sync_sub.sql deleted file mode 100644 index afcc56a8fa..0000000000 --- a/supabase/migrations/20251014135440_add_cron_sync_sub.sql +++ /dev/null @@ -1,134 +0,0 @@ --- Add cron_sync_sub queue and scheduling system --- Secure process_function_queue function to only allow privileged users --- Remove public access and only allow service_role and postgres --- Ensure the function is SECURITY DEFINER so it runs with elevated privileges -CREATE OR REPLACE FUNCTION "public"."process_function_queue" ( - "queue_name" "text", - "batch_size" integer DEFAULT 950 -) RETURNS bigint LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - headers jsonb; - url text; - queue_size bigint; - calls_needed int; -BEGIN - -- Check if the queue has elements - EXECUTE format('SELECT count(*) FROM pgmq.q_%I', queue_name) INTO queue_size; - - -- Only make the HTTP request if the queue is not empty - IF queue_size > 0 THEN - headers := jsonb_build_object( - 'Content-Type', 'application/json', - 'apisecret', public.get_apikey() - ); - url := public.get_db_url() || '/functions/v1/triggers/queue_consumer/sync'; - - -- Calculate how many times to call the sync endpoint (1 call per batch_size items, max 10 calls) - calls_needed := least(ceil(queue_size / batch_size::float)::int, 10); - - -- Call the endpoint multiple times if needed - FOR i IN 1..calls_needed LOOP - PERFORM net.http_post( - url := url, - headers := headers, - body := jsonb_build_object('queue_name', queue_name, 'batch_size', batch_size), - timeout_milliseconds := 15000 - ); - END LOOP; - - -- Return the number of calls made - RETURN calls_needed::bigint; - END IF; - - RETURN 0::bigint; -END; -$$; - -ALTER FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) OWNER TO "postgres"; - --- Revoke all existing permissions -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) -FROM - PUBLIC; - -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) -FROM - "anon"; - -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) -FROM - "authenticated"; - --- Grant access only to service_role and postgres -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) TO "service_role"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) TO "postgres"; - --- Create new message queue for cron_sync_sub -SELECT - pgmq.create ('cron_sync_sub'); - --- Create function to process all organizations for cron_sync_sub -CREATE OR REPLACE FUNCTION "public"."process_cron_sync_sub_jobs" () RETURNS "void" LANGUAGE "plpgsql" -SET - "search_path" TO '' AS $$ -DECLARE - org_record RECORD; -BEGIN - -- Process each organization that has a customer_id (paying customers only) - FOR org_record IN - SELECT DISTINCT o.id, si.customer_id - FROM public.orgs o - INNER JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE o.customer_id IS NOT NULL - AND si.customer_id IS NOT NULL - LOOP - -- Queue sync_sub processing for this organization - PERFORM pgmq.send('cron_sync_sub', - json_build_object( - 'function_name', 'cron_sync_sub', - 'orgId', org_record.id, - 'customerId', org_record.customer_id - )::jsonb - ); - END LOOP; -END; -$$; - --- Set permissions for the new function -ALTER FUNCTION public.process_cron_sync_sub_jobs () OWNER TO postgres; - --- Revoke all existing permissions first -REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs () -FROM - PUBLIC; - -REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs () -FROM - anon; - -REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs () -FROM - authenticated; - --- Grant only EXECUTE permission to service_role -GRANT -EXECUTE ON FUNCTION public.process_cron_sync_sub_jobs () TO service_role; - --- Create cron job for cron_sync_sub scheduling (daily at 4am) -SELECT - cron.schedule ( - 'cron_sync_sub_scheduler', - '0 4 * * *', - 'SELECT public.process_cron_sync_sub_jobs();' - ); - --- Create cron job for processing cron_sync_sub queue (every minute) with batch size of 10 -SELECT - cron.schedule ( - 'process_cron_sync_sub_queue', - '* * * * *', - 'SELECT public.process_function_queue(''cron_sync_sub'', 10)' - ); diff --git a/supabase/migrations/20251019123107_fix_stats.sql b/supabase/migrations/20251019123107_fix_stats.sql deleted file mode 100644 index 790113297c..0000000000 --- a/supabase/migrations/20251019123107_fix_stats.sql +++ /dev/null @@ -1,74 +0,0 @@ --- Note: already applied to production -DROP FUNCTION IF EXISTS public.process_function_queue (text); - -CREATE OR REPLACE FUNCTION "public"."process_cron_stats_jobs" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - app_record RECORD; -BEGIN - FOR app_record IN ( - SELECT DISTINCT av.app_id, av.owner_org - FROM public.app_versions av - WHERE av.created_at >= NOW() - INTERVAL '30 days' - - UNION - - SELECT DISTINCT dm.app_id, av.owner_org - FROM public.daily_mau dm - JOIN public.app_versions av ON dm.app_id = av.app_id - WHERE dm.date >= NOW() - INTERVAL '30 days' AND dm.mau > 0 - ) - LOOP - PERFORM pgmq.send('cron_stat_app', - jsonb_build_object( - 'function_name', 'cron_stat_app', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'appId', app_record.app_id, - 'orgId', app_record.owner_org, - 'todayOnly', false - ) - ) - ); - END LOOP; -END; -$$; - -SELECT - cron.unschedule ('process_cron_stat_app_queue'); - -SELECT - cron.schedule ( - 'process_cron_stat_app_queue', - '* * * * *', - 'SELECT public.process_function_queue(''cron_stat_app'', 10)' - ); - -CREATE OR REPLACE FUNCTION public.queue_cron_stat_org_for_org (org_id uuid, customer_id text) RETURNS void LANGUAGE plpgsql SECURITY DEFINER -SET - search_path = '' AS $$ -BEGIN - - PERFORM pgmq.send('cron_stat_org', - jsonb_build_object( - 'function_name', 'cron_stat_org', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'orgId', org_id, - 'customerId', customer_id - ) - ) - ); -END; -$$; - -SELECT - cron.unschedule ('process_cron_stat_org_queue'); - -SELECT - cron.schedule ( - 'process_cron_stat_org_queue', - '*/5 * * * *', - 'SELECT public.process_function_queue(''cron_stat_org'', 10)' - ); diff --git a/supabase/migrations/20251021141631_add_usage_credit_system.sql b/supabase/migrations/20251021141631_add_usage_credit_system.sql deleted file mode 100644 index f217cd9ab2..0000000000 --- a/supabase/migrations/20251021141631_add_usage_credit_system.sql +++ /dev/null @@ -1,633 +0,0 @@ --- Add entities to support usage-based credits and overage handling - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_type t - JOIN pg_namespace n ON n.oid = t.typnamespace - WHERE t.typname = 'credit_metric_type' - AND n.nspname = 'public' - ) THEN - CREATE TYPE public.credit_metric_type AS ENUM ('mau', 'bandwidth', 'storage'); - END IF; -END; -$$; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_type t - JOIN pg_namespace n ON n.oid = t.typnamespace - WHERE t.typname = 'credit_transaction_type' - AND n.nspname = 'public' - ) THEN - CREATE TYPE public.credit_transaction_type AS ENUM ('grant', 'purchase', 'manual_grant', 'deduction', 'expiry', 'refund'); - END IF; -END; -$$; - -CREATE TABLE IF NOT EXISTS public.usage_credit_grants ( - id uuid DEFAULT extensions.uuid_generate_v4() PRIMARY KEY, - org_id uuid NOT NULL REFERENCES public.orgs (id) ON DELETE CASCADE, - credits_total numeric(18, 6) NOT NULL CHECK (credits_total >= 0), - credits_consumed numeric(18, 6) DEFAULT 0 NOT NULL CHECK ( - credits_consumed >= 0 - ), - granted_at timestamptz DEFAULT NOW() NOT NULL, - expires_at timestamptz DEFAULT (NOW() + interval '1 year') NOT NULL, - source text DEFAULT 'manual'::text NOT NULL, - source_ref jsonb, - notes text, - CHECK (credits_consumed <= credits_total) -); - -COMMENT ON TABLE public.usage_credit_grants IS 'Records every block of credits granted to an org, tracking totals, consumption and expiry.'; - -CREATE INDEX IF NOT EXISTS idx_usage_credit_grants_org_expires ON public.usage_credit_grants ( - org_id, expires_at -); -CREATE INDEX IF NOT EXISTS idx_usage_credit_grants_org_remaining ON public.usage_credit_grants ( - org_id, (credits_total - credits_consumed) -); - -CREATE TABLE IF NOT EXISTS public.usage_credit_transactions ( - id bigserial PRIMARY KEY, - org_id uuid NOT NULL REFERENCES public.orgs (id) ON DELETE CASCADE, - grant_id uuid REFERENCES public.usage_credit_grants (id) ON DELETE SET NULL, - transaction_type public.credit_transaction_type NOT NULL, - amount numeric(18, 6) NOT NULL, - balance_after numeric(18, 6), - occurred_at timestamptz DEFAULT NOW() NOT NULL, - description text, - source_ref jsonb -); - -COMMENT ON TABLE public.usage_credit_transactions IS 'General ledger of credit movements (grants, purchases, deductions, expiries, refunds) with running balances.'; - -CREATE INDEX IF NOT EXISTS idx_usage_credit_transactions_org_time ON public.usage_credit_transactions ( - org_id, occurred_at DESC -); -CREATE INDEX IF NOT EXISTS idx_usage_credit_transactions_grant ON public.usage_credit_transactions ( - grant_id, occurred_at DESC -); - -CREATE TABLE IF NOT EXISTS public.usage_overage_events ( - id uuid DEFAULT extensions.uuid_generate_v4() PRIMARY KEY, - org_id uuid NOT NULL REFERENCES public.orgs (id) ON DELETE CASCADE, - metric public.credit_metric_type NOT NULL, - overage_amount numeric(20, 6) NOT NULL CHECK (overage_amount >= 0), - credits_estimated numeric(18, 6) NOT NULL CHECK (credits_estimated >= 0), - credits_debited numeric(18, 6) DEFAULT 0 NOT NULL CHECK ( - credits_debited >= 0 - ), - credit_step_id bigint REFERENCES public.capgo_credits_steps ( - id - ) ON DELETE SET NULL, - billing_cycle_start date, - billing_cycle_end date, - created_at timestamptz DEFAULT NOW() NOT NULL, - details jsonb -); - -COMMENT ON TABLE public.usage_overage_events IS 'Snapshots of detected plan overages, capturing usage, credits applied, and linkage back to pricing tiers.'; - -CREATE INDEX IF NOT EXISTS idx_usage_overage_events_org_time ON public.usage_overage_events ( - org_id, created_at DESC -); -CREATE INDEX IF NOT EXISTS idx_usage_overage_events_metric ON public.usage_overage_events ( - metric -); - -CREATE TABLE IF NOT EXISTS public.usage_credit_consumptions ( - id bigserial PRIMARY KEY, - grant_id uuid NOT NULL REFERENCES public.usage_credit_grants ( - id - ) ON DELETE CASCADE, - org_id uuid NOT NULL REFERENCES public.orgs (id) ON DELETE CASCADE, - overage_event_id uuid REFERENCES public.usage_overage_events ( - id - ) ON DELETE SET NULL, - metric public.credit_metric_type NOT NULL, - credits_used numeric(18, 6) NOT NULL CHECK (credits_used > 0), - applied_at timestamptz DEFAULT NOW() NOT NULL -); - -COMMENT ON TABLE public.usage_credit_consumptions IS 'Detailed allocation records showing which grants covered each overage event and how many credits were used.'; - -CREATE INDEX IF NOT EXISTS idx_usage_credit_consumptions_org_time ON public.usage_credit_consumptions ( - org_id, applied_at DESC -); -CREATE INDEX IF NOT EXISTS idx_usage_credit_consumptions_grant ON public.usage_credit_consumptions ( - grant_id, applied_at DESC -); - -CREATE OR REPLACE FUNCTION public.calculate_credit_cost( - p_metric public.credit_metric_type, - p_overage_amount numeric -) RETURNS TABLE ( - credit_step_id bigint, - credit_cost_per_unit numeric, - credits_required numeric -) LANGUAGE plpgsql -SET search_path = '' AS $$ -DECLARE - v_step public.capgo_credits_steps%ROWTYPE; - v_highest public.capgo_credits_steps%ROWTYPE; - v_remaining numeric; - v_applied_range numeric; - v_units numeric; - v_total_credits numeric := 0; - v_last_step_id bigint := NULL; - v_unit_factor numeric; -BEGIN - IF p_overage_amount IS NULL OR p_overage_amount <= 0 THEN - RETURN QUERY SELECT NULL::bigint, 0::numeric, 0::numeric; - RETURN; - END IF; - - v_remaining := p_overage_amount; - - SELECT * - INTO v_highest - FROM public.capgo_credits_steps - WHERE type = p_metric::text - ORDER BY step_max DESC, step_min DESC - LIMIT 1; - - IF NOT FOUND THEN - RAISE WARNING 'No pricing steps found for metric: %', p_metric::text; - RETURN QUERY SELECT NULL::bigint, 0::numeric, 0::numeric; - RETURN; - END IF; - - FOR v_step IN - SELECT * - FROM public.capgo_credits_steps - WHERE type = p_metric::text - ORDER BY step_min ASC - LOOP - EXIT WHEN v_remaining <= 0; - - IF p_overage_amount < v_step.step_min THEN - EXIT; - END IF; - - v_applied_range := LEAST( - v_remaining, - (v_step.step_max - v_step.step_min)::numeric - ); - - IF v_applied_range <= 0 THEN - CONTINUE; - END IF; - - v_unit_factor := GREATEST(NULLIF(v_step.unit_factor, 0), 1)::numeric; - v_units := CEILING(v_applied_range / v_unit_factor); - - IF v_units <= 0 THEN - CONTINUE; - END IF; - - v_total_credits := v_total_credits + (v_units * v_step.price_per_unit::numeric); - v_remaining := v_remaining - v_applied_range; - v_last_step_id := v_step.id; - END LOOP; - - IF v_remaining > 0 THEN - v_unit_factor := GREATEST(NULLIF(v_highest.unit_factor, 0), 1)::numeric; - v_units := CEILING(v_remaining / v_unit_factor); - - IF v_units > 0 THEN - v_total_credits := v_total_credits + (v_units * v_highest.price_per_unit::numeric); - v_last_step_id := v_highest.id; - END IF; - END IF; - - RETURN QUERY SELECT - v_last_step_id::bigint, - CASE WHEN p_overage_amount > 0 THEN v_total_credits / p_overage_amount ELSE 0 END, - v_total_credits; -END; -$$; - -CREATE OR REPLACE FUNCTION public.apply_usage_overage( - p_org_id uuid, - p_metric public.credit_metric_type, - p_overage_amount numeric, - p_billing_cycle_start timestamptz, - p_billing_cycle_end timestamptz, - p_details jsonb DEFAULT NULL -) RETURNS TABLE ( - overage_amount numeric, - credits_required numeric, - credits_applied numeric, - credits_remaining numeric, - credit_step_id bigint, - overage_covered numeric, - overage_unpaid numeric, - overage_event_id uuid -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - v_calc RECORD; - v_event_id uuid; - v_remaining numeric := 0; - v_applied numeric := 0; - v_per_unit numeric := 0; - v_available numeric; - v_use numeric; - v_balance numeric; - v_overage_paid numeric := 0; - grant_rec public.usage_credit_grants%ROWTYPE; -BEGIN - IF p_overage_amount IS NULL OR p_overage_amount <= 0 THEN - RETURN QUERY SELECT 0::numeric, 0::numeric, 0::numeric, 0::numeric, NULL::bigint, 0::numeric, 0::numeric, NULL::uuid; - RETURN; - END IF; - - SELECT * - INTO v_calc - FROM public.calculate_credit_cost(p_metric, p_overage_amount) - LIMIT 1; - - IF v_calc.credit_step_id IS NULL THEN - INSERT INTO public.usage_overage_events ( - org_id, - metric, - overage_amount, - credits_estimated, - credits_debited, - credit_step_id, - billing_cycle_start, - billing_cycle_end, - details - ) - VALUES ( - p_org_id, - p_metric, - p_overage_amount, - 0, - 0, - NULL, - p_billing_cycle_start, - p_billing_cycle_end, - p_details - ) - RETURNING id INTO v_event_id; - - RETURN QUERY SELECT p_overage_amount, 0::numeric, 0::numeric, 0::numeric, NULL::bigint, 0::numeric, p_overage_amount, v_event_id; - RETURN; - END IF; - - v_per_unit := v_calc.credit_cost_per_unit; - v_remaining := v_calc.credits_required; - - INSERT INTO public.usage_overage_events ( - org_id, - metric, - overage_amount, - credits_estimated, - credits_debited, - credit_step_id, - billing_cycle_start, - billing_cycle_end, - details - ) - VALUES ( - p_org_id, - p_metric, - p_overage_amount, - v_calc.credits_required, - 0, - v_calc.credit_step_id, - p_billing_cycle_start, - p_billing_cycle_end, - p_details - ) - RETURNING id INTO v_event_id; - - FOR grant_rec IN - SELECT * - FROM public.usage_credit_grants - WHERE org_id = p_org_id - AND expires_at >= NOW() - AND credits_consumed < credits_total - ORDER BY expires_at ASC, granted_at ASC - FOR UPDATE - LOOP - EXIT WHEN v_remaining <= 0; - - v_available := grant_rec.credits_total - grant_rec.credits_consumed; - IF v_available <= 0 THEN - CONTINUE; - END IF; - - v_use := LEAST(v_available, v_remaining); - v_remaining := v_remaining - v_use; - v_applied := v_applied + v_use; - - UPDATE public.usage_credit_grants - SET credits_consumed = credits_consumed + v_use - WHERE id = grant_rec.id; - - INSERT INTO public.usage_credit_consumptions ( - grant_id, - org_id, - overage_event_id, - metric, - credits_used - ) - VALUES ( - grant_rec.id, - p_org_id, - v_event_id, - p_metric, - v_use - ); - - SELECT COALESCE(SUM(GREATEST(credits_total - credits_consumed, 0)), 0) - INTO v_balance - FROM public.usage_credit_grants - WHERE org_id = p_org_id - AND expires_at >= NOW(); - - INSERT INTO public.usage_credit_transactions ( - org_id, - grant_id, - transaction_type, - amount, - balance_after, - occurred_at, - description, - source_ref - ) - VALUES ( - p_org_id, - grant_rec.id, - 'deduction', - -v_use, - v_balance, - NOW(), - format('Overage deduction for %s usage', p_metric::text), - jsonb_build_object('overage_event_id', v_event_id, 'metric', p_metric::text) - ); - END LOOP; - - UPDATE public.usage_overage_events - SET credits_debited = v_applied - WHERE id = v_event_id; - - IF v_per_unit > 0 THEN - v_overage_paid := LEAST(p_overage_amount, v_applied / v_per_unit); - ELSE - v_overage_paid := p_overage_amount; - END IF; - - RETURN QUERY SELECT - p_overage_amount, - v_calc.credits_required, - v_applied, - v_remaining, - v_calc.credit_step_id, - v_overage_paid, - GREATEST(p_overage_amount - v_overage_paid, 0), - v_event_id; -END; -$$; - - -CREATE VIEW public.usage_credit_balances AS -SELECT - org_id, - SUM(GREATEST(credits_total, 0)) AS total_credits, - SUM( - GREATEST( - CASE - WHEN - expires_at >= NOW() - THEN credits_total - credits_consumed - ELSE 0 - END, - 0 - ) - ) AS available_credits, - MIN(CASE WHEN credits_total - credits_consumed > 0 THEN expires_at END) - AS next_expiration -FROM public.usage_credit_grants -GROUP BY org_id; - -COMMENT ON VIEW public.usage_credit_balances IS 'Aggregated balance view per org: total credits granted, remaining unexpired credits, and the closest upcoming expiry.'; - -GRANT SELECT ON public.usage_credit_balances TO service_role; - -GRANT EXECUTE ON FUNCTION public.calculate_credit_cost( - public.credit_metric_type, numeric -) TO service_role; -GRANT EXECUTE ON FUNCTION public.apply_usage_overage( - uuid, public.credit_metric_type, numeric, timestamptz, timestamptz, jsonb -) TO service_role; - -DROP FUNCTION IF EXISTS public.get_orgs_v6(); -DROP FUNCTION IF EXISTS public.get_orgs_v6(userid uuid); - -CREATE OR REPLACE FUNCTION public.get_orgs_v6() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.apikeys WHERE key = api_key_text INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v6(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v6(user_id); -END; -$$; - -CREATE OR REPLACE FUNCTION public.get_orgs_v6(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN QUERY - SELECT - sub.id AS gid, - sub.created_by, - sub.logo, - sub.name, - org_users.user_right::varchar AS role, - public.is_paying_org(sub.id) AS paying, - public.is_trial_org(sub.id) AS trial_left, - public.is_allowed_action_org(sub.id) AS can_use_more, - public.is_canceled_org(sub.id) AS is_canceled, - (SELECT count(*) FROM public.apps WHERE owner_org = sub.id) AS app_count, - (sub.f).subscription_anchor_start AS subscription_start, - (sub.f).subscription_anchor_end AS subscription_end, - sub.management_email, - public.is_org_yearly(sub.id) AS is_yearly, - sub.stats_updated_at, - public.get_next_stats_update_date(sub.id) AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration - FROM ( - SELECT public.get_cycle_info_org(o.id) AS f, o.* - FROM public.orgs AS o - ) AS sub - JOIN public.org_users - ON org_users.user_id = userid - AND sub.id = org_users.org_id - LEFT JOIN public.usage_credit_balances ucb - ON ucb.org_id = sub.id; -END; -$$; - -GRANT ALL ON FUNCTION public.get_orgs_v6() TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v6() TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v6() TO service_role; -GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO service_role; - -CREATE OR REPLACE FUNCTION public.expire_usage_credits() -RETURNS bigint LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - grant_rec public.usage_credit_grants%ROWTYPE; - credits_to_expire numeric; - balance_after numeric; - expired_count bigint := 0; -BEGIN - FOR grant_rec IN - SELECT * - FROM public.usage_credit_grants - WHERE expires_at < NOW() - AND credits_total > credits_consumed - ORDER BY expires_at ASC - FOR UPDATE - LOOP - credits_to_expire := grant_rec.credits_total - grant_rec.credits_consumed; - - UPDATE public.usage_credit_grants - SET credits_consumed = credits_total - WHERE id = grant_rec.id; - - SELECT COALESCE(SUM(GREATEST(credits_total - credits_consumed, 0)), 0) - INTO balance_after - FROM public.usage_credit_grants - WHERE org_id = grant_rec.org_id - AND expires_at >= NOW(); - - INSERT INTO public.usage_credit_transactions ( - org_id, - grant_id, - transaction_type, - amount, - balance_after, - occurred_at, - description, - source_ref - ) - VALUES ( - grant_rec.org_id, - grant_rec.id, - 'expiry', - -credits_to_expire, - balance_after, - NOW(), - 'Expired usage credits', - jsonb_build_object('reason', 'expiry', 'expires_at', grant_rec.expires_at) - ); - - expired_count := expired_count + 1; - END LOOP; - - RETURN expired_count; -END; -$$; - -GRANT EXECUTE ON FUNCTION public.expire_usage_credits() TO service_role; - -DO $$ -BEGIN - PERFORM cron.unschedule('usage_credit_expiry'); -EXCEPTION - WHEN OTHERS THEN - NULL; -END; -$$; -SELECT cron.schedule( - 'usage_credit_expiry', - '0 3 * * *', - 'SELECT public.expire_usage_credits()' -); diff --git a/supabase/migrations/20251024153920_update_capgo_credits_steps_org.sql b/supabase/migrations/20251024153920_update_capgo_credits_steps_org.sql deleted file mode 100644 index e24fcf523e..0000000000 --- a/supabase/migrations/20251024153920_update_capgo_credits_steps_org.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Add org ownership to credit steps and drop legacy stripe references - -BEGIN; - -ALTER TABLE public.capgo_credits_steps -ADD COLUMN org_id uuid REFERENCES public.orgs (id) ON DELETE SET NULL; - -COMMENT ON COLUMN capgo_credits_steps.org_id IS 'Optional organization owner for this pricing tier'; - -ALTER TABLE public.capgo_credits_steps -DROP COLUMN stripe_id; - -COMMIT; diff --git a/supabase/migrations/20251024230753_fix_org_delete_cascade.sql b/supabase/migrations/20251024230753_fix_org_delete_cascade.sql deleted file mode 100644 index 3eb6b9b74d..0000000000 --- a/supabase/migrations/20251024230753_fix_org_delete_cascade.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Drop the existing foreign key constraint for app_metrics_cache -ALTER TABLE public.app_metrics_cache -DROP CONSTRAINT IF EXISTS app_metrics_cache_org_id_fkey; - --- Add it back with ON DELETE CASCADE -ALTER TABLE public.app_metrics_cache -ADD CONSTRAINT app_metrics_cache_org_id_fkey FOREIGN KEY ( - org_id -) REFERENCES public.orgs (id) ON DELETE CASCADE; - --- Drop the existing foreign key constraint for tmp_users -ALTER TABLE public.tmp_users -DROP CONSTRAINT IF EXISTS tmp_users_org_id_fkey; - --- Add it back with ON DELETE CASCADE -ALTER TABLE public.tmp_users -ADD CONSTRAINT tmp_users_org_id_fkey FOREIGN KEY ( - org_id -) REFERENCES public.orgs (id) ON DELETE CASCADE; diff --git a/supabase/migrations/20251026165357_add_missing_queue_cron_jobs.sql b/supabase/migrations/20251026165357_add_missing_queue_cron_jobs.sql deleted file mode 100644 index 5925828762..0000000000 --- a/supabase/migrations/20251026165357_add_missing_queue_cron_jobs.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Add missing cron jobs for queues that were created but never had processing scheduled --- This fixes the issue where on_user_delete and cron_clear_versions queues would accumulate --- messages but never process them. --- Schedule cron job to process on_user_delete queue --- This queue handles cleanup when users are deleted (cancel subscriptions, unsubscribe from Bento) --- Running every 10 seconds like other user-related queues -SELECT - cron.schedule( - 'process_user_delete_queue', - '10 seconds', - 'SELECT public.process_function_queue(''on_user_delete'')' - ); - --- Schedule cron job to process cron_clear_versions queue --- This queue handles cleanup of old versions --- Running every 2 hours like other cleanup tasks -SELECT - cron.schedule( - 'process_cron_clear_versions_queue', - '0 */2 * * *', - 'SELECT public.process_function_queue(''cron_clear_versions'')' - ); diff --git a/supabase/migrations/20251031202034_fix_usage_credit_rls.sql b/supabase/migrations/20251031202034_fix_usage_credit_rls.sql deleted file mode 100644 index 733c452222..0000000000 --- a/supabase/migrations/20251031202034_fix_usage_credit_rls.sql +++ /dev/null @@ -1,133 +0,0 @@ --- Fix RLS and security issues for usage credit system tables --- Enable RLS on all usage credit tables -ALTER TABLE public.usage_credit_grants ENABLE ROW LEVEL SECURITY; - -ALTER TABLE public.usage_credit_transactions ENABLE ROW LEVEL SECURITY; - -ALTER TABLE public.usage_overage_events ENABLE ROW LEVEL SECURITY; - -ALTER TABLE public.usage_credit_consumptions ENABLE ROW LEVEL SECURITY; - --- Drop existing view to recreate without SECURITY DEFINER -DROP VIEW IF EXISTS public.usage_credit_balances; - --- Create RLS policies for usage_credit_grants --- Service role has full access (needed for backend operations) -CREATE POLICY "Allow service_role full access" ON public.usage_credit_grants FOR ALL TO service_role USING ( - true -) -WITH -CHECK (true); - --- Org admins can read their org's grants -CREATE POLICY "Allow read for org admin" ON public.usage_credit_grants FOR -SELECT -TO authenticated USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity(), - org_id, - null::character varying, - null::bigint - ) -); - --- Create RLS policies for usage_credit_transactions --- Service role has full access (needed for backend operations) -CREATE POLICY "Allow service_role full access" ON public.usage_credit_transactions FOR ALL TO service_role USING ( - true -) -WITH -CHECK (true); - --- Org admins can read their org's transactions -CREATE POLICY "Allow read for org admin" ON public.usage_credit_transactions FOR -SELECT -TO authenticated USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity(), - org_id, - null::character varying, - null::bigint - ) -); - --- Create RLS policies for usage_overage_events --- Service role has full access (needed for backend operations) -CREATE POLICY "Allow service_role full access" ON public.usage_overage_events FOR ALL TO service_role USING ( - true -) -WITH -CHECK (true); - --- Org admins can read their org's overage events -CREATE POLICY "Allow read for org admin" ON public.usage_overage_events FOR -SELECT -TO authenticated USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity(), - org_id, - null::character varying, - null::bigint - ) -); - --- Create RLS policies for usage_credit_consumptions --- Service role has full access (needed for backend operations) -CREATE POLICY "Allow service_role full access" ON public.usage_credit_consumptions FOR ALL TO service_role USING ( - true -) -WITH -CHECK (true); - --- Org admins can read their org's consumptions -CREATE POLICY "Allow read for org admin" ON public.usage_credit_consumptions FOR -SELECT -TO authenticated USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity(), - org_id, - null::character varying, - null::bigint - ) -); - --- Recreate view with SECURITY INVOKER (uses calling user's permissions) --- The view will respect RLS policies on the underlying table -CREATE VIEW public.usage_credit_balances -WITH (security_invoker = true) AS -SELECT - org_id, - SUM(GREATEST(credits_total, 0)) AS total_credits, - SUM( - GREATEST( - CASE - WHEN expires_at >= NOW() THEN credits_total - credits_consumed - ELSE 0 - END, - 0 - ) - ) AS available_credits, - MIN( - CASE - WHEN credits_total - credits_consumed > 0 THEN expires_at - END - ) AS next_expiration -FROM - public.usage_credit_grants -GROUP BY - org_id; - -COMMENT ON VIEW public.usage_credit_balances IS 'Aggregated balance view per org: total credits granted, remaining unexpired credits, and the closest upcoming expiry. Respects RLS policies.'; - --- Grant permissions on the view -GRANT -SELECT -ON public.usage_credit_balances TO authenticated; - -GRANT -SELECT -ON public.usage_credit_balances TO service_role; diff --git a/supabase/migrations/20251103134045_add_download_stats_actions.sql b/supabase/migrations/20251103134045_add_download_stats_actions.sql deleted file mode 100644 index 3cf2533725..0000000000 --- a/supabase/migrations/20251103134045_add_download_stats_actions.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Add new download stats actions to the stats_action enum --- These actions track different stages of download (manifest/delta and full zip) --- Success stats -ALTER TYPE public.stats_action -ADD VALUE IF NOT EXISTS 'backend_refusal'; - -ALTER TYPE public.stats_action -ADD VALUE IF NOT EXISTS 'download_manifest_start'; - -ALTER TYPE public.stats_action -ADD VALUE IF NOT EXISTS 'download_manifest_complete'; - -ALTER TYPE public.stats_action -ADD VALUE IF NOT EXISTS 'download_zip_start'; - -ALTER TYPE public.stats_action -ADD VALUE IF NOT EXISTS 'download_zip_complete'; - --- Failure stats (with filename in version_name as version:filename) --- Example: version_name = '1.2.3:main.js' or '1.2.3:assets/logo.png' -ALTER TYPE public.stats_action -ADD VALUE IF NOT EXISTS 'download_manifest_file_fail'; - -ALTER TYPE public.stats_action -ADD VALUE IF NOT EXISTS 'download_manifest_checksum_fail'; - -ALTER TYPE public.stats_action -ADD VALUE IF NOT EXISTS 'download_manifest_brotli_fail'; diff --git a/supabase/migrations/20251106024103_add_default_channel_to_devices.sql b/supabase/migrations/20251106024103_add_default_channel_to_devices.sql deleted file mode 100644 index 37bcf3b8a0..0000000000 --- a/supabase/migrations/20251106024103_add_default_channel_to_devices.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Add default_channel column to devices table to track which channel the device is configured to use -ALTER TABLE public.devices -ADD COLUMN default_channel character varying(255); - --- Add comment to explain the column -COMMENT ON COLUMN public.devices.default_channel IS 'The default channel name that the device is configured to request updates from'; - --- Create index for better query performance -CREATE INDEX IF NOT EXISTS idx_devices_default_channel ON public.devices ( - default_channel -); diff --git a/supabase/migrations/20251107001223_channel_device_counts.sql b/supabase/migrations/20251107001223_channel_device_counts.sql deleted file mode 100644 index 43eaf62f07..0000000000 --- a/supabase/migrations/20251107001223_channel_device_counts.sql +++ /dev/null @@ -1,132 +0,0 @@ --- Add a running count of channel devices per app -ALTER TABLE public.apps -ADD COLUMN channel_device_count bigint NOT NULL DEFAULT 0; - --- Backfill the counter based on current channel_devices data -WITH device_counts AS ( - SELECT - app_id, - COUNT(*)::bigint AS device_count - FROM public.channel_devices - GROUP BY app_id -) - -UPDATE public.apps AS a -SET channel_device_count = dc.device_count -FROM device_counts AS dc -WHERE dc.app_id = a.app_id; - --- Create dedicated queue for channel device count deltas -SELECT pgmq.create('channel_device_counts'); - --- Trigger helper to enqueue +/-1 events when channel_devices changes -CREATE OR REPLACE FUNCTION public.enqueue_channel_device_counts() RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - v_delta integer; - v_app_id text; - v_owner uuid; - v_device text; -BEGIN - IF TG_OP = 'INSERT' THEN - v_delta := 1; - v_app_id := NEW.app_id; - v_owner := NEW.owner_org; - v_device := NEW.device_id; - ELSIF TG_OP = 'DELETE' THEN - v_delta := -1; - v_app_id := OLD.app_id; - v_owner := OLD.owner_org; - v_device := OLD.device_id; - ELSE - RETURN NEW; - END IF; - - PERFORM pgmq.send( - 'channel_device_counts', - jsonb_build_object( - 'app_id', v_app_id, - 'owner_org', v_owner, - 'device_id', v_device, - 'delta', v_delta - ) - ); - - RETURN COALESCE(NEW, OLD); -END; -$$; - -ALTER FUNCTION public.enqueue_channel_device_counts() OWNER TO postgres; - --- Ensure trigger exists exactly once -DROP TRIGGER IF EXISTS channel_device_count_enqueue ON public.channel_devices; - -CREATE TRIGGER channel_device_count_enqueue -AFTER INSERT OR DELETE ON public.channel_devices -FOR EACH ROW -EXECUTE FUNCTION public.enqueue_channel_device_counts(); - --- Worker that drains the queue and updates app counters -CREATE OR REPLACE FUNCTION public.process_channel_device_counts_queue( - batch_size integer DEFAULT 1000 -) RETURNS bigint -LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - message_record RECORD; - v_payload jsonb; - v_app_id text; - v_delta integer; - msg_ids bigint[] := ARRAY[]::bigint[]; - processed bigint := 0; -BEGIN - IF batch_size IS NULL OR batch_size < 1 THEN - batch_size := 100; - END IF; - - FOR message_record IN - SELECT * - FROM pgmq.read('channel_device_counts', 60, batch_size) - LOOP - v_payload := message_record.message; - v_app_id := v_payload ->> 'app_id'; - v_delta := COALESCE((v_payload ->> 'delta')::integer, 0); - - IF v_app_id IS NULL OR v_delta = 0 THEN - msg_ids := array_append(msg_ids, message_record.msg_id); - CONTINUE; - END IF; - - UPDATE public.apps - SET channel_device_count = GREATEST(channel_device_count + v_delta, 0), - updated_at = NOW() - WHERE app_id = v_app_id; - - processed := processed + 1; - msg_ids := array_append(msg_ids, message_record.msg_id); - END LOOP; - - IF array_length(msg_ids, 1) IS NOT NULL THEN - PERFORM pgmq.delete('channel_device_counts', msg_ids); - END IF; - - RETURN processed; -END; -$$; - -ALTER FUNCTION public.process_channel_device_counts_queue( - batch_size integer -) OWNER TO postgres; - -GRANT EXECUTE ON FUNCTION public.process_channel_device_counts_queue( - batch_size integer -) TO service_role; - --- Schedule continuous processing of the new queue -SELECT - cron.schedule( - 'process_channel_device_counts_queue', - '10 seconds', - 'SELECT public.process_channel_device_counts_queue(1000);' - ); diff --git a/supabase/migrations/20251107153019_manifest_bundle_counts.sql b/supabase/migrations/20251107153019_manifest_bundle_counts.sql deleted file mode 100644 index 224d998782..0000000000 --- a/supabase/migrations/20251107153019_manifest_bundle_counts.sql +++ /dev/null @@ -1,170 +0,0 @@ --- Track manifest-capable bundles per app - -ALTER TABLE public.apps -ADD COLUMN manifest_bundle_count bigint NOT NULL DEFAULT 0; - --- Backfill based on existing manifest data -WITH manifest_counts AS ( - SELECT - av.app_id, - COUNT(DISTINCT av.id)::bigint AS bundle_count - FROM public.app_versions AS av - WHERE - EXISTS ( - SELECT 1 - FROM public.manifest AS m - WHERE m.app_version_id = av.id - ) - GROUP BY av.app_id -) - -UPDATE public.apps AS a -SET manifest_bundle_count = mc.bundle_count -FROM manifest_counts AS mc -WHERE mc.app_id = a.app_id; - --- Dedicated queue for manifest bundle deltas -SELECT pgmq.create('manifest_bundle_counts'); - -CREATE OR REPLACE FUNCTION public.enqueue_manifest_bundle_counts() RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - v_delta integer := 0; - v_app_id text; - v_owner uuid; - v_app_version_id bigint; - v_has_other boolean; -BEGIN - IF TG_OP = 'INSERT' THEN - v_app_version_id := NEW.app_version_id; - ELSIF TG_OP = 'DELETE' THEN - v_app_version_id := OLD.app_version_id; - ELSE - RETURN NEW; - END IF; - - SELECT av.app_id, av.owner_org - INTO v_app_id, v_owner - FROM public.app_versions av - WHERE av.id = v_app_version_id - LIMIT 1; - - IF v_app_id IS NULL THEN - RETURN COALESCE(NEW, OLD); - END IF; - - IF TG_OP = 'INSERT' THEN - SELECT EXISTS ( - SELECT 1 - FROM public.manifest - WHERE app_version_id = v_app_version_id - AND id <> NEW.id - ) - INTO v_has_other; - - IF NOT v_has_other THEN - v_delta := 1; - END IF; - ELSIF TG_OP = 'DELETE' THEN - SELECT EXISTS ( - SELECT 1 - FROM public.manifest - WHERE app_version_id = v_app_version_id - AND id <> OLD.id - ) - INTO v_has_other; - - IF NOT v_has_other THEN - v_delta := -1; - END IF; - END IF; - - IF v_delta = 0 THEN - RETURN COALESCE(NEW, OLD); - END IF; - - PERFORM pgmq.send( - 'manifest_bundle_counts', - jsonb_build_object( - 'app_id', v_app_id, - 'owner_org', v_owner, - 'app_version_id', v_app_version_id, - 'delta', v_delta - ) - ); - - RETURN COALESCE(NEW, OLD); -END; -$$; - -ALTER FUNCTION public.enqueue_manifest_bundle_counts() OWNER TO postgres; - -DROP TRIGGER IF EXISTS manifest_bundle_count_enqueue ON public.manifest; - -CREATE TRIGGER manifest_bundle_count_enqueue -AFTER INSERT OR DELETE ON public.manifest -FOR EACH ROW -EXECUTE FUNCTION public.enqueue_manifest_bundle_counts(); - -CREATE OR REPLACE FUNCTION public.process_manifest_bundle_counts_queue( - batch_size integer DEFAULT 1000 -) RETURNS bigint -LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - message_record RECORD; - v_payload jsonb; - v_app_id text; - v_delta integer; - msg_ids bigint[] := ARRAY[]::bigint[]; - processed bigint := 0; -BEGIN - IF batch_size IS NULL OR batch_size < 1 THEN - batch_size := 100; - END IF; - - FOR message_record IN - SELECT * - FROM pgmq.read('manifest_bundle_counts', 60, batch_size) - LOOP - v_payload := message_record.message; - v_app_id := v_payload ->> 'app_id'; - v_delta := COALESCE((v_payload ->> 'delta')::integer, 0); - - IF v_app_id IS NULL OR v_delta = 0 THEN - msg_ids := array_append(msg_ids, message_record.msg_id); - CONTINUE; - END IF; - - UPDATE public.apps - SET manifest_bundle_count = GREATEST(manifest_bundle_count + v_delta, 0), - updated_at = NOW() - WHERE app_id = v_app_id; - - processed := processed + 1; - msg_ids := array_append(msg_ids, message_record.msg_id); - END LOOP; - - IF array_length(msg_ids, 1) IS NOT NULL THEN - PERFORM pgmq.delete('manifest_bundle_counts', msg_ids); - END IF; - - RETURN processed; -END; -$$; - -ALTER FUNCTION public.process_manifest_bundle_counts_queue( - batch_size integer -) OWNER TO postgres; - -GRANT EXECUTE ON FUNCTION public.process_manifest_bundle_counts_queue( - batch_size integer -) TO service_role; - -SELECT - cron.schedule( - 'process_manifest_bundle_counts_queue', - '20 seconds', - 'SELECT public.process_manifest_bundle_counts_queue(1000);' - ); diff --git a/supabase/migrations/20251113041643_transfer_ownership_before_user_deletion.sql b/supabase/migrations/20251113041643_transfer_ownership_before_user_deletion.sql deleted file mode 100644 index 6945548942..0000000000 --- a/supabase/migrations/20251113041643_transfer_ownership_before_user_deletion.sql +++ /dev/null @@ -1,158 +0,0 @@ --- Migration: Transfer ownership of apps, app_versions, and deploy_history before user deletion --- Logic: --- 1. For each user being deleted, get all their orgs --- 2. For each org, check if they are the last super_admin --- 3. If last super_admin: DELETE all org resources (apps, app_versions, deploy_history, channels) --- 4. If NOT last super_admin: TRANSFER ownership to another super_admin in the org - --- Update the delete_accounts_marked_for_deletion function to handle ownership properly -CREATE OR REPLACE FUNCTION "public"."delete_accounts_marked_for_deletion" () -RETURNS TABLE (deleted_count INTEGER, deleted_user_ids UUID[]) -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - account_record RECORD; - org_record RECORD; - deleted_users UUID[] := ARRAY[]::UUID[]; - total_deleted INTEGER := 0; - other_super_admins_count INTEGER; - replacement_owner_id UUID; -BEGIN - -- Loop through all accounts marked for deletion where removal_date has passed - FOR account_record IN - SELECT "account_id", "removal_date", "removed_data" - FROM "public"."to_delete_accounts" - WHERE "removal_date" < NOW() - LOOP - BEGIN - -- Process each org the user belongs to - FOR org_record IN - SELECT DISTINCT "org_id", "user_right" - FROM "public"."org_users" - WHERE "user_id" = account_record.account_id - LOOP - -- Reset replacement_owner_id for each org - replacement_owner_id := NULL; - - -- Check if user is a super_admin in this org - IF org_record.user_right = 'super_admin'::"public"."user_min_right" THEN - -- Count other super_admins in this org (excluding the user being deleted) - SELECT COUNT(*) INTO other_super_admins_count - FROM "public"."org_users" - WHERE "org_id" = org_record.org_id - AND "user_id" != account_record.account_id - AND "user_right" = 'super_admin'::"public"."user_min_right"; - - IF other_super_admins_count = 0 THEN - -- User is the last super_admin: DELETE all org resources - RAISE NOTICE 'User % is last super_admin of org %. Deleting all org resources.', - account_record.account_id, org_record.org_id; - - -- Delete deploy_history for this org - DELETE FROM "public"."deploy_history" WHERE "owner_org" = org_record.org_id; - - -- Delete channel_devices for this org - DELETE FROM "public"."channel_devices" WHERE "owner_org" = org_record.org_id; - - -- Delete channels for this org - DELETE FROM "public"."channels" WHERE "owner_org" = org_record.org_id; - - -- Delete app_versions for this org - DELETE FROM "public"."app_versions" WHERE "owner_org" = org_record.org_id; - - -- Delete apps for this org - DELETE FROM "public"."apps" WHERE "owner_org" = org_record.org_id; - - -- Delete the org itself since user is last super_admin - DELETE FROM "public"."orgs" WHERE "id" = org_record.org_id; - - -- Skip ownership transfer since all resources are deleted - CONTINUE; - END IF; - END IF; - - -- If we reach here, we need to transfer ownership (either non-super_admin or non-last super_admin) - -- Find a super_admin to transfer ownership to - SELECT "user_id" INTO replacement_owner_id - FROM "public"."org_users" - WHERE "org_id" = org_record.org_id - AND "user_id" != account_record.account_id - AND "user_right" = 'super_admin'::"public"."user_min_right" - LIMIT 1; - - IF replacement_owner_id IS NOT NULL THEN - RAISE NOTICE 'Transferring ownership from user % to user % in org %', - account_record.account_id, replacement_owner_id, org_record.org_id; - - -- Transfer app ownership - UPDATE "public"."apps" - SET "user_id" = replacement_owner_id, "updated_at" = NOW() - WHERE "user_id" = account_record.account_id AND "owner_org" = org_record.org_id; - - -- Transfer app_versions ownership - UPDATE "public"."app_versions" - SET "user_id" = replacement_owner_id, "updated_at" = NOW() - WHERE "user_id" = account_record.account_id AND "owner_org" = org_record.org_id; - - -- Transfer channels ownership - UPDATE "public"."channels" - SET "created_by" = replacement_owner_id, "updated_at" = NOW() - WHERE "created_by" = account_record.account_id AND "owner_org" = org_record.org_id; - - -- Transfer deploy_history ownership - UPDATE "public"."deploy_history" - SET "created_by" = replacement_owner_id, "updated_at" = NOW() - WHERE "created_by" = account_record.account_id AND "owner_org" = org_record.org_id; - - -- Transfer org ownership if user created it - UPDATE "public"."orgs" - SET "created_by" = replacement_owner_id, "updated_at" = NOW() - WHERE "id" = org_record.org_id AND "created_by" = account_record.account_id; - ELSE - RAISE WARNING 'No super_admin found to transfer ownership in org % for user %', - org_record.org_id, account_record.account_id; - END IF; - END LOOP; - - -- Delete from public.users table - DELETE FROM "public"."users" WHERE "id" = account_record.account_id; - - -- Delete from auth.users table - DELETE FROM "auth"."users" WHERE "id" = account_record.account_id; - - -- Remove from to_delete_accounts table - DELETE FROM "public"."to_delete_accounts" WHERE "account_id" = account_record.account_id; - - -- Track the deleted user - deleted_users := "array_append"(deleted_users, account_record.account_id); - total_deleted := total_deleted + 1; - - -- Log the deletion - RAISE NOTICE 'Successfully deleted account: % (removal date: %)', - account_record.account_id, account_record.removal_date; - - EXCEPTION - WHEN OTHERS THEN - -- Log the error but continue with other accounts - RAISE WARNING 'Failed to delete account %: %', account_record.account_id, SQLERRM; - END; - END LOOP; - - -- Return results - deleted_count := total_deleted; - deleted_user_ids := deleted_users; - RETURN NEXT; - - RAISE NOTICE 'Deletion process completed. Total accounts deleted: %', total_deleted; -END; -$$; - --- Ensure permissions remain the same (only service_role and postgres can execute) -REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion" () FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion" () FROM anon; -REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion" () FROM authenticated; - -GRANT EXECUTE ON FUNCTION "public"."delete_accounts_marked_for_deletion" () TO postgres; -GRANT EXECUTE ON FUNCTION "public"."delete_accounts_marked_for_deletion" () TO service_role; diff --git a/supabase/migrations/20251113140646_consolidate_cron_job.sql b/supabase/migrations/20251113140646_consolidate_cron_job.sql deleted file mode 100644 index 4cc9b3475c..0000000000 --- a/supabase/migrations/20251113140646_consolidate_cron_job.sql +++ /dev/null @@ -1,474 +0,0 @@ --- Add support for processing multiple queues in a single function call --- This allows consolidating multiple cron jobs into fewer jobs --- Overloaded function that accepts an array of queue names --- Uses exception handling to ensure one queue failure doesn't block others --- Drop old function signatures if they exist (changing return type from bigint to void) --- Only drop process_function_queue overloads that existed before this migration -DROP FUNCTION IF EXISTS "public"."process_function_queue" ("queue_name" "text", "batch_size" integer); - -CREATE OR REPLACE FUNCTION "public"."process_function_queue" ( - "queue_names" "text" [], - "batch_size" integer DEFAULT 950 -) RETURNS void LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - queue_name text; -BEGIN - -- Process each queue in the array with individual exception handling - FOREACH queue_name IN ARRAY queue_names - LOOP - BEGIN - -- Call the existing single-queue function (fire-and-forget) - PERFORM public.process_function_queue(queue_name, batch_size); - EXCEPTION WHEN OTHERS THEN - -- Log the error but continue processing other queues - RAISE WARNING 'process_function_queue failed for queue "%": %', queue_name, SQLERRM; - END; - END LOOP; -END; -$$; - -ALTER FUNCTION "public"."process_function_queue" ("queue_names" "text" [], "batch_size" integer) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_names" "text" [], "batch_size" integer) TO "anon"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_names" "text" [], "batch_size" integer) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_names" "text" [], "batch_size" integer) TO "service_role"; - --- Update the single-queue function to use 8-second timeout for better pg_net throughput --- Original had 15 seconds which was risky given pg_net's 200 req/s limit --- Fire-and-forget: uses PERFORM instead of SELECT INTO for true non-blocking behavior -CREATE OR REPLACE FUNCTION "public"."process_function_queue" ( - "queue_name" "text", - "batch_size" integer DEFAULT 950 -) RETURNS void LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -DECLARE - headers jsonb; - url text; - queue_size bigint; - calls_needed int; -BEGIN - -- Check if the queue has elements - EXECUTE format('SELECT count(*) FROM pgmq.q_%I', queue_name) INTO queue_size; - - -- Only make the HTTP request if the queue is not empty - IF queue_size > 0 THEN - headers := jsonb_build_object( - 'Content-Type', 'application/json', - 'apisecret', public.get_apikey() - ); - url := public.get_db_url() || '/functions/v1/triggers/queue_consumer/sync'; - - -- Calculate how many times to call the sync endpoint (1 call per batch_size items, max 10 calls) - calls_needed := least(ceil(queue_size / batch_size::float)::int, 10); - - -- Call the endpoint multiple times if needed (fire-and-forget) - FOR i IN 1..calls_needed LOOP - PERFORM net.http_post( - url := url, - headers := headers, - body := jsonb_build_object('queue_name', queue_name, 'batch_size', batch_size), - timeout_milliseconds := 8000 - ); - END LOOP; - END IF; -END; -$$; - -ALTER FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) TO "anon"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) TO "authenticated"; - -GRANT ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) TO "service_role"; - --- Consolidate cron jobs from 37 to ~15 jobs using the new multi-queue processing function --- This reduces the number of cron jobs to stay within Supabase's recommended limits --- First, unschedule all existing jobs that will be consolidated --- High frequency (10s) queue jobs to be consolidated -SELECT - cron.unschedule ('process_channel_update_queue'); - -SELECT - cron.unschedule ('process_user_create_queue'); - -SELECT - cron.unschedule ('process_user_update_queue'); - -SELECT - cron.unschedule ('process_version_delete_queue'); - -SELECT - cron.unschedule ('process_version_update_queue'); - -SELECT - cron.unschedule ('process_app_delete_queue'); - -SELECT - cron.unschedule ('process_organization_create_queue'); - -SELECT - cron.unschedule ('process_user_delete_queue'); - -SELECT - cron.unschedule ('process_channel_device_counts_queue'); - --- Every 2 hours queue jobs to be consolidated -SELECT - cron.unschedule ('process_admin_stats'); - -SELECT - cron.unschedule ('process_cron_email_queue'); - -SELECT - cron.unschedule ('process_app_create_queue'); - -SELECT - cron.unschedule ('process_version_create_queue'); - -SELECT - cron.unschedule ('process_organization_delete_queue'); - -SELECT - cron.unschedule ('process_deploy_history_create_queue'); - -SELECT - cron.unschedule ('process_cron_clear_versions_queue'); - --- Per-minute queue jobs to be consolidated -SELECT - cron.unschedule ('delete-expired-accounts'); - -SELECT - cron.unschedule ('process_cron_sync_sub_queue'); - -SELECT - cron.unschedule ('process_cron_stat_app_queue'); - -SELECT - cron.unschedule ('process_manifest_create_queue'); - --- Every 5 minutes job to be consolidated -SELECT - cron.unschedule ('process_cron_stat_org_queue'); - --- Daily and hourly maintenance jobs to be consolidated -SELECT - cron.unschedule ('process_free_trial_expired'); - -SELECT - cron.unschedule ('cleanup_queue_messages'); - -SELECT - cron.unschedule ('delete_old_deleted_apps'); - -SELECT - cron.unschedule ('Remove old jobs'); - -SELECT - cron.unschedule ('create_admin_stats'); - -SELECT - cron.unschedule ('usage_credit_expiry'); - -SELECT - cron.unschedule ('cron_sync_sub_scheduler'); - -SELECT - cron.unschedule ('Delete old app version'); - -SELECT - cron.unschedule ('delete-job-run-details'); - -SELECT - cron.unschedule ('Cleanup frequent job details'); - -SELECT - cron.unschedule ('process_cron_stat_app_jobs'); - --- Email jobs to be consolidated -SELECT - cron.unschedule ('Send stats email every month'); - -SELECT - cron.unschedule ('Send stats email every week'); - --- High-frequency jobs to be consolidated -SELECT - cron.unschedule ('process_manifest_bundle_counts_queue'); - --- Create a single consolidated function that runs every second and intelligently decides what to execute --- Uses exception handling to prevent one task from blocking others -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks () RETURNS void LANGUAGE plpgsql -SET - search_path = '' AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; -BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - - -- Every 10 seconds: High-frequency queues (at :00, :10, :20, :30, :40, :50) - IF current_second % 10 = 0 THEN - -- Process high-frequency queues with default batch size (950) - BEGIN - PERFORM public.process_function_queue(ARRAY['on_channel_update', 'on_user_create', 'on_user_update', 'on_version_delete', 'on_version_update', 'on_app_delete', 'on_organization_create', 'on_user_delete', 'on_app_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (high-frequency) failed: %', SQLERRM; - END; - - -- Process channel device counts with batch size 1000 - BEGIN - PERFORM public.process_channel_device_counts_queue(1000); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_channel_device_counts_queue failed: %', SQLERRM; - END; - - -- Process manifest bundle counts with batch size 1000 - BEGIN - PERFORM public.process_manifest_bundle_counts_queue(1000); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_manifest_bundle_counts_queue failed: %', SQLERRM; - END; - END IF; - - -- Every minute (at :00 seconds): Per-minute tasks - IF current_second = 0 THEN - BEGIN - PERFORM public.delete_accounts_marked_for_deletion(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_accounts_marked_for_deletion failed: %', SQLERRM; - END; - - -- Process with batch size 10 - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_sync_sub', 'cron_stat_app'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (per-minute) failed: %', SQLERRM; - END; - - -- on_manifest_create uses default batch size - BEGIN - PERFORM public.process_function_queue(ARRAY['on_manifest_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (manifest_create) failed: %', SQLERRM; - END; - END IF; - - -- Every 5 minutes (at :00 seconds): Org stats with batch size 10 - IF current_minute % 5 = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_stat_org'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (cron_stat_org) failed: %', SQLERRM; - END; - END IF; - - -- Every hour (at :00:00): Hourly cleanup - IF current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_frequent_job_details(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_frequent_job_details failed: %', SQLERRM; - END; - END IF; - - -- Every 2 hours (at :00:00): Low-frequency queues with default batch size - IF current_hour % 2 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['admin_stats', 'cron_email', 'on_version_create', 'on_organization_delete', 'on_deploy_history_create', 'cron_clear_versions']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (low-frequency) failed: %', SQLERRM; - END; - END IF; - - -- Every 6 hours (at :00:00): Stats jobs - IF current_hour % 6 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_stats_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_stats_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:00:00 - Midnight tasks - IF current_hour = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_queue_messages(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_queue_messages failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.delete_old_deleted_apps(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_old_deleted_apps failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.remove_old_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'remove_old_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:40:00 - Old app version retention - IF current_hour = 0 AND current_minute = 40 AND current_second = 0 THEN - BEGIN - PERFORM public.update_app_versions_retention(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'update_app_versions_retention failed: %', SQLERRM; - END; - END IF; - - -- Daily at 01:01:00 - Admin stats creation - IF current_hour = 1 AND current_minute = 1 AND current_second = 0 THEN - BEGIN - PERFORM public.process_admin_stats(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_admin_stats failed: %', SQLERRM; - END; - END IF; - - -- Daily at 03:00:00 - Free trial and credits - IF current_hour = 3 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_free_trial_expired(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_free_trial_expired failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.expire_usage_credits(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'expire_usage_credits failed: %', SQLERRM; - END; - END IF; - - -- Daily at 04:00:00 - Sync sub scheduler - IF current_hour = 4 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_sync_sub_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_sync_sub_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 12:00:00 - Noon tasks - IF current_hour = 12 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup job_run_details failed: %', SQLERRM; - END; - - -- Weekly stats email (every Saturday at noon) - IF EXTRACT(DOW FROM NOW()) = 6 THEN - BEGIN - PERFORM public.process_stats_email_weekly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_weekly failed: %', SQLERRM; - END; - END IF; - - -- Monthly stats email (1st of month at noon) - IF EXTRACT(DAY FROM NOW()) = 1 THEN - BEGIN - PERFORM public.process_stats_email_monthly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_monthly failed: %', SQLERRM; - END; - END IF; - END IF; -END; -$$; - --- Now create the single consolidated job --- This single job runs every second and intelligently handles ALL cron tasks based on time -SELECT - cron.schedule ( - 'process_all_cron_tasks', - '10 seconds', - $$SELECT public.process_all_cron_tasks();$$ - ); - --- Summary of consolidation: --- BEFORE: 37 cron jobs --- AFTER: 1 cron job (ultimate consolidation!) --- --- Single consolidated job: --- process_all_cron_tasks (1 seconds) - Runs every second and intelligently handles ALL tasks: --- --- Every second: --- - replica queue batch processing --- --- Every 10 seconds: --- - 9 high-frequency queues (on_channel_update, on_user_create, on_user_update, --- on_version_delete, on_version_update, on_app_delete, on_organization_create, --- on_user_delete, on_app_create) with default batch size 950 --- - Channel device counts (batch size 1000) --- - Manifest bundle counts (batch size 1000) --- --- Every minute: --- - Delete accounts marked for deletion --- - 2 queues with batch size 10 (cron_sync_sub, cron_stat_app) --- - 1 queue with default batch size (on_manifest_create) --- --- Every 5 minutes: --- - Org stats queue (batch size 10) --- --- Every hour: --- - Cleanup frequent job details --- --- Every 2 hours: --- - 6 low-frequency queues with default batch size (admin_stats, cron_email, on_version_create, --- on_organization_delete, on_deploy_history_create, cron_clear_versions) --- --- Every 6 hours: --- - Process cron stats jobs --- --- Daily schedules: --- - 00:00 - Cleanup queue messages, delete old deleted apps, remove old jobs --- - 00:40 - Update app versions retention --- - 01:01 - Process admin stats --- - 03:00 - Process free trial expired, expire usage credits --- - 04:00 - Process cron sync sub jobs --- - 12:00 - Cleanup job run details --- --- Weekly schedule: --- - Saturdays at 12:00 - Send stats email --- --- Monthly schedule: --- - 1st of month at 12:00 - Send stats email --- --- This brings the total from 37 down to 1 job - the ultimate consolidation! --- Well under Supabase's recommended limit of 8 jobs! --- --- IMPORTANT NOTES: --- 1. Exception handling ensures individual task failures don't block subsequent tasks --- 2. Each queue in array processing has its own exception handling --- 3. Batch sizes are preserved from original configuration: --- - Default (950): Most queues --- - 10: cron_sync_sub, cron_stat_app, cron_stat_org --- - 1000: channel_device_counts, manifest_bundle_counts --- 4. pg_net limitations (200 req/s) are respected: --- - Each queue can make up to 10 HTTP calls --- - Peak load: ~110-140 requests per 10-second window --- - Sequential processing prevents overwhelming pg_net --- 5. Tasks execute sequentially within time slots (as per original design) --- 6. Response data is stored in unlogged tables (6-hour retention) --- 7. HTTP requests are true fire-and-forget: --- - Uses PERFORM instead of SELECT INTO (discards request_id for true non-blocking) --- - net.http_post returns immediately after queuing the request --- - Actual HTTP calls happen asynchronously in background --- - "Blocking" only occurs during: queue size check, request queuing, sequential array processing --- - All functions now return void for cleaner fire-and-forget semantics diff --git a/supabase/migrations/20251119001844_add_missing_foreign_key_indexes.sql b/supabase/migrations/20251119001844_add_missing_foreign_key_indexes.sql deleted file mode 100644 index 7e5f1c8db1..0000000000 --- a/supabase/migrations/20251119001844_add_missing_foreign_key_indexes.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Add missing indexes for foreign keys to improve query performance - --- Index for capgo_credits_steps.org_id foreign key -CREATE INDEX IF NOT EXISTS idx_capgo_credits_steps_org_id -ON public.capgo_credits_steps (org_id); - --- Index for usage_credit_consumptions.overage_event_id foreign key -CREATE INDEX IF NOT EXISTS idx_usage_credit_consumptions_overage_event_id -ON public.usage_credit_consumptions (overage_event_id); - --- Index for usage_overage_events.credit_step_id foreign key -CREATE INDEX IF NOT EXISTS idx_usage_overage_events_credit_step_id -ON public.usage_overage_events (credit_step_id); diff --git a/supabase/migrations/20251119001847_add_native_build_system.sql b/supabase/migrations/20251119001847_add_native_build_system.sql deleted file mode 100644 index 7e26f8dd67..0000000000 --- a/supabase/migrations/20251119001847_add_native_build_system.sql +++ /dev/null @@ -1,750 +0,0 @@ --- Complete Native Build System --- This single migration adds ALL native build functionality: --- 1. Build time tracking (seconds-based, credit system integration) --- 2. Build requests table for upload/build workflows --- 3. Database functions (RPC) for build operations --- 4. Updated plan functions to include build_time_percent -BEGIN; - --- ================================================== --- PART 1: BUILD TIME TRACKING --- ================================================== --- Add build_time_unit to plans -ALTER TABLE public.plans -ADD COLUMN build_time_unit bigint DEFAULT 0 NOT NULL; - -COMMENT ON COLUMN public.plans.build_time_unit IS 'Maximum build time in seconds per billing cycle'; - --- Add build_time_exceeded flag to stripe_info -ALTER TABLE public.stripe_info -ADD COLUMN build_time_exceeded boolean DEFAULT false; - -COMMENT ON COLUMN public.stripe_info.build_time_exceeded IS 'Organization exceeded build time limit'; - --- Extend enums for build_time -ALTER TYPE public.credit_metric_type -ADD VALUE IF NOT EXISTS 'build_time'; - -ALTER TYPE public.action_type -ADD VALUE IF NOT EXISTS 'build_time'; - --- Build logs - BILLING ONLY: tracks build time for charging orgs --- Platform multipliers: android=1x, ios=2x -CREATE TABLE IF NOT EXISTS public.build_logs ( - id uuid DEFAULT extensions.uuid_generate_v4() NOT NULL, - created_at timestamp with time zone DEFAULT NOW() NOT NULL, - org_id uuid NOT NULL, - user_id uuid, - build_id character varying NOT NULL, - platform character varying NOT NULL, - billable_seconds bigint NOT NULL, - build_time_unit bigint NOT NULL, - CONSTRAINT build_logs_billable_seconds_check CHECK ( - (billable_seconds >= 0) - ), - CONSTRAINT build_logs_build_time_unit_check CHECK ( - (build_time_unit >= 0) - ), - CONSTRAINT build_logs_platform_check CHECK ( - ( - (platform)::text - = ANY( - ( - ARRAY[ - 'ios'::character varying, 'android'::character varying - ] - )::text [] - ) - ) - ) -); - -CREATE INDEX idx_build_logs_org_created ON public.build_logs ( - org_id, created_at DESC -); - --- Unique constraint for ON CONFLICT in record_build_time function -ALTER TABLE public.build_logs -ADD CONSTRAINT build_logs_build_id_org_id_unique UNIQUE (build_id, org_id); - -ALTER TABLE public.build_logs ENABLE ROW LEVEL SECURITY; - --- Users can view: --- 1. Their own builds --- 2. All org builds if they're admin/super_admin -CREATE POLICY "Users read own or org admin builds" ON public.build_logs FOR -SELECT -TO authenticated USING ( - user_id = ( - SELECT auth.uid() - ) - OR EXISTS ( - SELECT 1 - FROM - public.org_users - WHERE - org_users.org_id = build_logs.org_id - AND org_users.user_id = ( - SELECT auth.uid() - ) - AND org_users.user_right IN ('super_admin', 'admin') - ) -); - --- Only service role can write (backend records builds) -CREATE POLICY "Service role manages build logs" ON public.build_logs FOR ALL TO service_role USING ( - true -) -WITH -CHECK (true); - --- Daily build time aggregates per app/day for reporting -CREATE TABLE IF NOT EXISTS public.daily_build_time ( - app_id character varying NOT NULL REFERENCES public.apps ( - app_id - ) ON DELETE CASCADE, - date date NOT NULL, - build_time_unit bigint NOT NULL DEFAULT 0 CHECK (build_time_unit >= 0), - build_count bigint NOT NULL DEFAULT 0 CHECK (build_count >= 0), - PRIMARY KEY (app_id, date) -); - -CREATE INDEX idx_daily_build_time_app_date ON public.daily_build_time ( - app_id, date -); - -ALTER TABLE public.daily_build_time ENABLE ROW LEVEL SECURITY; - --- Users can view build time data for apps in their organization -CREATE POLICY "Users read own org build time" ON public.daily_build_time FOR -SELECT -TO authenticated USING ( - EXISTS ( - SELECT 1 - FROM - public.apps - WHERE - apps.app_id = daily_build_time.app_id - AND EXISTS ( - SELECT 1 - FROM - public.org_users - WHERE - org_users.org_id = apps.owner_org - AND org_users.user_id = ( - SELECT auth.uid() - ) - ) - ) -); - --- Only service role can write (backend records build metrics) -CREATE POLICY "Service role manages build time" ON public.daily_build_time FOR ALL TO service_role USING ( - true -) -WITH -CHECK (true); - --- Build requests - stores native build jobs requested via API -CREATE TABLE IF NOT EXISTS public.build_requests ( - id uuid DEFAULT extensions.uuid_generate_v4() PRIMARY KEY, - created_at timestamptz DEFAULT NOW() NOT NULL, - updated_at timestamptz DEFAULT NOW() NOT NULL, - app_id character varying NOT NULL REFERENCES public.apps ( - app_id - ) ON DELETE CASCADE, - owner_org uuid NOT NULL REFERENCES public.orgs (id) ON DELETE CASCADE, - requested_by uuid NOT NULL REFERENCES auth.users (id) ON DELETE SET NULL, - platform character varying NOT NULL CHECK ( - platform IN ('ios', 'android', 'both') - ), - build_mode character varying NOT NULL DEFAULT 'release', - build_config jsonb DEFAULT '{}'::jsonb, - status character varying NOT NULL DEFAULT 'pending', - builder_job_id character varying, - upload_session_key character varying NOT NULL, - upload_path character varying NOT NULL, - upload_url character varying NOT NULL, - upload_expires_at timestamptz NOT NULL, - last_error text -); - -CREATE INDEX idx_build_requests_app ON public.build_requests (app_id); - -CREATE INDEX idx_build_requests_org ON public.build_requests (owner_org); - -CREATE INDEX idx_build_requests_job ON public.build_requests (builder_job_id); - -ALTER TABLE public.build_requests ENABLE ROW LEVEL SECURITY; - --- Users can view build requests for apps in their organization -CREATE POLICY "Users read own org build requests" ON public.build_requests FOR -SELECT -TO authenticated USING ( - EXISTS ( - SELECT 1 - FROM - public.org_users - WHERE - org_users.org_id = build_requests.owner_org - AND org_users.user_id = ( - SELECT auth.uid() - ) - ) -); - -CREATE POLICY "Service role manages build requests" ON public.build_requests FOR ALL TO service_role USING ( - true -) -WITH -CHECK (true); - -CREATE TRIGGER handle_build_requests_updated_at BEFORE -UPDATE ON public.build_requests FOR EACH ROW -EXECUTE FUNCTION MODDATETIME('updated_at'); - --- Note: No daily aggregation needed - just query build_logs for billing --- Note: builder.capgo.app manages its own R2 storage; this table only stores metadata --- Grant permissions for PostgREST access -GRANT ALL ON public.build_logs TO postgres, -anon, -authenticated, -service_role; - -GRANT ALL ON public.daily_build_time TO postgres, -anon, -authenticated, -service_role; - -GRANT ALL ON public.build_requests TO postgres, -anon, -authenticated, -service_role; - -COMMIT; - --- ================================================== --- PART 3: RPC FUNCTIONS FOR BUILD OPERATIONS --- ================================================== --- Function: record_build_time - BILLING ONLY --- Applies platform multiplier: android=1x, ios=2x -CREATE OR REPLACE FUNCTION public.record_build_time( - p_org_id uuid, - p_user_id uuid, - p_build_id character varying, - p_platform character varying, - p_build_time_unit bigint -) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -DECLARE - v_build_log_id uuid; - v_multiplier numeric; - v_billable_seconds bigint; -BEGIN - IF p_build_time_unit < 0 THEN RAISE EXCEPTION 'Build time cannot be negative'; END IF; - IF p_platform NOT IN ('ios', 'android') THEN RAISE EXCEPTION 'Invalid platform: %', p_platform; END IF; - - -- Apply platform multiplier - v_multiplier := CASE p_platform - WHEN 'ios' THEN 2 - WHEN 'android' THEN 1 - ELSE 1 - END; - - v_billable_seconds := (p_build_time_unit * v_multiplier)::bigint; - - INSERT INTO public.build_logs (org_id, user_id, build_id, platform, build_time_unit, billable_seconds) - VALUES (p_org_id, p_user_id, p_build_id, p_platform, p_build_time_unit, v_billable_seconds) - ON CONFLICT (build_id, org_id) DO UPDATE SET - user_id = EXCLUDED.user_id, - platform = EXCLUDED.platform, - build_time_unit = EXCLUDED.build_time_unit, - billable_seconds = EXCLUDED.billable_seconds - RETURNING id INTO v_build_log_id; - - RETURN v_build_log_id; -END; -$$; - --- Function: get_org_build_time_unit -CREATE OR REPLACE FUNCTION public.get_org_build_time_unit( - p_org_id uuid, p_start_date date, p_end_date date -) RETURNS TABLE ( - total_build_time_unit bigint, total_builds bigint -) LANGUAGE plpgsql STABLE -SET -search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT COALESCE(SUM(dbt.build_time_unit), 0)::bigint, COALESCE(SUM(dbt.build_count), 0)::bigint - FROM public.daily_build_time dbt - INNER JOIN public.apps a ON a.app_id = dbt.app_id - WHERE a.owner_org = p_org_id AND dbt.date >= p_start_date AND dbt.date <= p_end_date; -END; -$$; - --- Function: is_build_time_exceeded_by_org -CREATE OR REPLACE FUNCTION public.is_build_time_exceeded_by_org( - org_id uuid -) RETURNS boolean LANGUAGE plpgsql STABLE -SET -search_path = '' AS $$ -BEGIN - RETURN (SELECT build_time_exceeded FROM public.stripe_info - WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = is_build_time_exceeded_by_org.org_id)); -END; -$$; - -GRANT ALL ON FUNCTION public.is_build_time_exceeded_by_org(uuid) TO anon, -authenticated, -service_role; - --- Function: set_build_time_exceeded_by_org -CREATE OR REPLACE FUNCTION public.set_build_time_exceeded_by_org( - org_id uuid, disabled boolean -) RETURNS void LANGUAGE plpgsql -SET -search_path = '' AS $$ -BEGIN - UPDATE public.stripe_info SET build_time_exceeded = disabled - WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = set_build_time_exceeded_by_org.org_id); -END; -$$; - -GRANT ALL ON FUNCTION public.set_build_time_exceeded_by_org( - uuid, boolean -) TO anon, -authenticated, -service_role; - --- Note: No create_build_request RPC needed - backend TypeScript handles builder.capgo.app API calls --- ================================================== --- PART 4: UPDATE EXISTING FUNCTIONS WITH build_time_unit --- ================================================== --- Update get_app_metrics -DROP FUNCTION IF EXISTS public.get_app_metrics(uuid); - -DROP FUNCTION IF EXISTS public.get_app_metrics(uuid, date, date); - -CREATE FUNCTION public.get_app_metrics(org_id uuid) RETURNS TABLE ( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql STABLE -SET -search_path = '' AS $$ -DECLARE cycle_start timestamptz; cycle_end timestamptz; -BEGIN - SELECT subscription_anchor_start, subscription_anchor_end INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - RETURN QUERY SELECT * FROM public.get_app_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$$; - --- Update get_total_metrics -DROP FUNCTION IF EXISTS public.get_total_metrics(uuid); - -DROP FUNCTION IF EXISTS public.get_total_metrics(uuid, date, date); - -CREATE FUNCTION public.get_total_metrics( - org_id uuid, start_date date, end_date date -) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql STABLE -SET -search_path = '' AS $$ -BEGIN - RETURN QUERY SELECT COALESCE(SUM(metrics.mau), 0)::bigint, - COALESCE(public.get_total_storage_size_org(org_id), 0)::bigint, - COALESCE(SUM(metrics.bandwidth), 0)::bigint, COALESCE(SUM(metrics.build_time_unit), 0)::bigint, - COALESCE(SUM(metrics.get), 0)::bigint, COALESCE(SUM(metrics.fail), 0)::bigint, - COALESCE(SUM(metrics.install), 0)::bigint, COALESCE(SUM(metrics.uninstall), 0)::bigint - FROM public.get_app_metrics(org_id, start_date, end_date) AS metrics; -END; -$$; - -CREATE FUNCTION public.get_total_metrics(org_id uuid) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql STABLE -SET -search_path = '' AS $$ -DECLARE cycle_start timestamptz; cycle_end timestamptz; -BEGIN - SELECT subscription_anchor_start, subscription_anchor_end INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - RETURN QUERY SELECT * FROM public.get_total_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$$; - --- Update find_fit_plan_v3 -DROP FUNCTION IF EXISTS public.find_fit_plan_v3(bigint, bigint, bigint); - -CREATE FUNCTION public.find_fit_plan_v3( - mau bigint, - bandwidth bigint, - storage bigint, - build_time_unit bigint DEFAULT 0 -) RETURNS TABLE (name character varying) LANGUAGE plpgsql STABLE -SET -search_path = '' AS $$ -BEGIN - RETURN QUERY (SELECT plans.name FROM public.plans - WHERE plans.mau >= find_fit_plan_v3.mau AND plans.storage >= find_fit_plan_v3.storage - AND plans.bandwidth >= find_fit_plan_v3.bandwidth AND plans.build_time_unit >= find_fit_plan_v3.build_time_unit - OR plans.name = 'Enterprise' - ORDER BY plans.mau); -END; -$$; - --- Update find_best_plan_v3 to account for build time -DROP FUNCTION IF EXISTS public.find_best_plan_v3( - bigint, double precision, double precision -); - -CREATE FUNCTION public.find_best_plan_v3( - mau bigint, - bandwidth double precision, - storage double precision, - build_time_unit bigint DEFAULT 0 -) RETURNS character varying LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -BEGIN - RETURN ( - SELECT name - FROM public.plans - WHERE ( - plans.mau >= find_best_plan_v3.mau - AND plans.storage >= find_best_plan_v3.storage - AND plans.bandwidth >= find_best_plan_v3.bandwidth - AND plans.build_time_unit >= find_best_plan_v3.build_time_unit - ) OR plans.name = 'Enterprise' - ORDER BY plans.mau - LIMIT 1 - ); -END; -$$; - -ALTER FUNCTION public.find_best_plan_v3( - bigint, - double precision, - double precision, - bigint -) OWNER TO "postgres"; - --- Update is_good_plan_v5_org -DROP FUNCTION IF EXISTS public.is_good_plan_v5_org(uuid); - -CREATE FUNCTION public.is_good_plan_v5_org( - orgid uuid -) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -DECLARE total_metrics RECORD; current_plan_name TEXT; -BEGIN - SELECT * INTO total_metrics FROM public.get_total_metrics(orgid, - (SELECT subscription_anchor_start::date FROM public.stripe_info si - INNER JOIN public.orgs o ON o.customer_id = si.customer_id WHERE o.id = orgid), - (SELECT subscription_anchor_end::date FROM public.stripe_info si - INNER JOIN public.orgs o ON o.customer_id = si.customer_id WHERE o.id = orgid)); - - current_plan_name := (SELECT public.get_current_plan_name_org(orgid)); - - RETURN EXISTS (SELECT 1 FROM public.find_fit_plan_v3(total_metrics.mau, total_metrics.bandwidth, - total_metrics.storage, total_metrics.build_time_unit) - WHERE find_fit_plan_v3.name = current_plan_name); -END; -$$; - --- Update is_paying_and_good_plan_org_action -CREATE OR REPLACE FUNCTION public.is_paying_and_good_plan_org_action( - orgid uuid, actions public.action_type [] -) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -DECLARE org_customer_id text; result boolean; -BEGIN - SELECT o.customer_id INTO org_customer_id FROM public.orgs o WHERE o.id = orgid; - SELECT (si.trial_at > NOW()) OR (si.status = 'succeeded' AND NOT ( - (si.mau_exceeded AND 'mau' = ANY(actions)) OR (si.storage_exceeded AND 'storage' = ANY(actions)) OR - (si.bandwidth_exceeded AND 'bandwidth' = ANY(actions)) OR (si.build_time_exceeded AND 'build_time' = ANY(actions)))) - INTO result FROM public.stripe_info si WHERE si.customer_id = org_customer_id LIMIT 1; - RETURN COALESCE(result, false); -END; -$$; - -GRANT ALL ON FUNCTION public.is_paying_and_good_plan_org_action( - uuid, public.action_type [] -) TO anon, -authenticated, -service_role; - --- Update get_current_plan_max_org to include build_time_unit -DROP FUNCTION IF EXISTS public.get_current_plan_max_org(uuid); - -CREATE FUNCTION public.get_current_plan_max_org(orgid uuid) RETURNS TABLE ( - mau bigint, - bandwidth bigint, - storage bigint, - build_time_unit bigint -) LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -Begin - RETURN QUERY - (SELECT plans.mau, plans.bandwidth, plans.storage, plans.build_time_unit - FROM public.plans - WHERE stripe_id=( - SELECT product_id - FROM public.stripe_info - where customer_id=( - SELECT customer_id - FROM public.orgs - where id=orgid) - )); -End; -$$; - --- Update get_plan_usage_percent_detailed -DROP FUNCTION IF EXISTS public.get_plan_usage_percent_detailed(uuid); - -DROP FUNCTION IF EXISTS public.get_plan_usage_percent_detailed( - uuid, date, date -); - -CREATE FUNCTION public.get_plan_usage_percent_detailed( - orgid uuid -) RETURNS TABLE ( - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) LANGUAGE plpgsql -SET -search_path = '' SECURITY DEFINER AS $$ -DECLARE current_plan_max RECORD; total_stats RECORD; - percent_mau double precision; percent_bandwidth double precision; percent_storage double precision; percent_build_time double precision; -BEGIN - SELECT * INTO current_plan_max FROM public.get_current_plan_max_org(orgid); - SELECT * INTO total_stats FROM public.get_total_metrics(orgid); - percent_mau := public.convert_number_to_percent(total_stats.mau, current_plan_max.mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, current_plan_max.bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, current_plan_max.storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, current_plan_max.build_time_unit); - RETURN QUERY SELECT GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, percent_bandwidth, percent_storage, percent_build_time; -END; -$$; - -CREATE FUNCTION public.get_plan_usage_percent_detailed( - orgid uuid, cycle_start date, cycle_end date -) RETURNS TABLE ( - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) LANGUAGE plpgsql -SET -search_path = '' SECURITY DEFINER AS $$ -DECLARE current_plan_max RECORD; total_stats RECORD; - percent_mau double precision; percent_bandwidth double precision; percent_storage double precision; percent_build_time double precision; -BEGIN - SELECT * INTO current_plan_max FROM public.get_current_plan_max_org(orgid); - SELECT * INTO total_stats FROM public.get_total_metrics(orgid, cycle_start, cycle_end); - percent_mau := public.convert_number_to_percent(total_stats.mau, current_plan_max.mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, current_plan_max.bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, current_plan_max.storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, current_plan_max.build_time_unit); - RETURN QUERY SELECT GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, percent_bandwidth, percent_storage, percent_build_time; -END; -$$; - --- ================================================== --- PART 5: UPDATE CACHE FUNCTIONS TO INCLUDE build_time_unit --- ================================================== --- The seed_get_app_metrics_caches function caches metrics data in JSONB format --- It needs to include build_time_unit in the cached data structure -CREATE OR REPLACE FUNCTION public.seed_get_app_metrics_caches( - p_org_id uuid, p_start_date date, p_end_date date -) RETURNS public.app_metrics_cache LANGUAGE plpgsql SECURITY DEFINER -SET -search_path TO '' AS $function$ -DECLARE - metrics_json jsonb; - cache_record public.app_metrics_cache%ROWTYPE; -BEGIN - WITH DateSeries AS ( - SELECT generate_series(p_start_date, p_end_date, '1 day'::interval)::date AS date - ), - all_apps AS ( - SELECT apps.app_id, apps.owner_org - FROM public.apps - WHERE apps.owner_org = p_org_id - UNION - SELECT deleted_apps.app_id, deleted_apps.owner_org - FROM public.deleted_apps - WHERE deleted_apps.owner_org = p_org_id - ), - deleted_metrics AS ( - SELECT - deleted_apps.app_id, - deleted_apps.deleted_at::date AS date, - COUNT(*) AS deleted_count - FROM public.deleted_apps - WHERE deleted_apps.owner_org = p_org_id - AND deleted_apps.deleted_at::date BETWEEN p_start_date AND p_end_date - GROUP BY deleted_apps.app_id, deleted_apps.deleted_at::date - ), - metrics AS ( - SELECT - aa.app_id, - ds.date::date, - COALESCE(dm.mau, 0) AS mau, - COALESCE(dst.storage, 0) AS storage, - COALESCE(db.bandwidth, 0) AS bandwidth, - COALESCE(dbt.build_time_unit, 0) AS build_time_unit, - COALESCE(SUM(dv.get)::bigint, 0) AS get, - COALESCE(SUM(dv.fail)::bigint, 0) AS fail, - COALESCE(SUM(dv.install)::bigint, 0) AS install, - COALESCE(SUM(dv.uninstall)::bigint, 0) AS uninstall - FROM - all_apps aa - CROSS JOIN - DateSeries ds - LEFT JOIN - public.daily_mau dm ON aa.app_id = dm.app_id AND ds.date = dm.date - LEFT JOIN - public.daily_storage dst ON aa.app_id = dst.app_id AND ds.date = dst.date - LEFT JOIN - public.daily_bandwidth db ON aa.app_id = db.app_id AND ds.date = db.date - LEFT JOIN - public.daily_build_time dbt ON aa.app_id = dbt.app_id AND ds.date = dbt.date - LEFT JOIN - public.daily_version dv ON aa.app_id = dv.app_id AND ds.date = dv.date - LEFT JOIN - deleted_metrics del ON aa.app_id = del.app_id AND ds.date = del.date - GROUP BY - aa.app_id, ds.date, dm.mau, dst.storage, db.bandwidth, dbt.build_time_unit, del.deleted_count - ) - SELECT COALESCE( - jsonb_agg(row_to_json(metrics) ORDER BY metrics.app_id, metrics.date), - '[]'::jsonb - ) - INTO metrics_json - FROM metrics; - - INSERT INTO public.app_metrics_cache (org_id, start_date, end_date, response, cached_at) - VALUES (p_org_id, p_start_date, p_end_date, metrics_json, clock_timestamp()) - ON CONFLICT (org_id) DO UPDATE - SET start_date = EXCLUDED.start_date, - end_date = EXCLUDED.end_date, - response = EXCLUDED.response, - cached_at = EXCLUDED.cached_at - RETURNING * INTO cache_record; - - RETURN cache_record; -END; -$function$; - --- Update get_app_metrics to properly extract build_time_unit from cache -DROP FUNCTION IF EXISTS public.get_app_metrics(uuid, date, date); - -CREATE FUNCTION public.get_app_metrics( - org_id uuid, start_date date, end_date date -) RETURNS TABLE ( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql SECURITY DEFINER -SET -search_path TO '' AS $function$ -DECLARE - cache_entry public.app_metrics_cache%ROWTYPE; - org_exists boolean; -BEGIN - SELECT EXISTS ( - SELECT 1 FROM public.orgs WHERE id = get_app_metrics.org_id - ) INTO org_exists; - - IF NOT org_exists THEN - RETURN; - END IF; - - SELECT * - INTO cache_entry - FROM public.app_metrics_cache - WHERE app_metrics_cache.org_id = get_app_metrics.org_id; - - IF cache_entry.id IS NULL - OR cache_entry.start_date IS DISTINCT FROM get_app_metrics.start_date - OR cache_entry.end_date IS DISTINCT FROM get_app_metrics.end_date - OR cache_entry.cached_at IS NULL - OR cache_entry.cached_at < (NOW() - interval '5 minutes') THEN - cache_entry := public.seed_get_app_metrics_caches(get_app_metrics.org_id, get_app_metrics.start_date, get_app_metrics.end_date); - END IF; - - IF cache_entry.response IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT - metrics.app_id, - metrics.date, - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM jsonb_to_recordset(cache_entry.response) AS metrics( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint - ) - ORDER BY metrics.app_id, metrics.date; -END; -$function$; - -COMMIT; diff --git a/supabase/migrations/20251120150750_simplify_manifest_bundle_counts.sql b/supabase/migrations/20251120150750_simplify_manifest_bundle_counts.sql deleted file mode 100644 index e8f00e7eb3..0000000000 --- a/supabase/migrations/20251120150750_simplify_manifest_bundle_counts.sql +++ /dev/null @@ -1,212 +0,0 @@ --- Simplify manifest bundle counts --- Remove complex queue-based system and track manifest file count per version --- The manifest_bundle_count in apps table will be updated directly by on_version_update --- Add manifest_count to track number of manifest files per version -ALTER TABLE public.app_versions -ADD COLUMN manifest_count integer NOT NULL DEFAULT 0; - --- Backfill manifest_count for existing versions -UPDATE public.app_versions av -SET - manifest_count = ( - SELECT COUNT(*)::integer - FROM - public.manifest AS m - WHERE - m.app_version_id = av.id - ); - --- Drop the old complex trigger and function -DROP TRIGGER IF EXISTS manifest_bundle_count_enqueue ON public.manifest; - -DROP FUNCTION IF EXISTS public.enqueue_manifest_bundle_counts(); - -DROP FUNCTION IF EXISTS public.process_manifest_bundle_counts_queue(integer); - --- Drop the queue (note: no schedule to drop as it was already removed in another migration) --- TODO: FIX IT IN PROD --- SELECT --- pgmq.drop_queue ('manifest_bundle_counts'); --- Create a single consolidated function that runs every second and intelligently decides what to execute --- Uses exception handling to prevent one task from blocking others -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks() RETURNS void LANGUAGE plpgsql -SET -search_path = '' AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; -BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - - -- Every 10 seconds: High-frequency queues (at :00, :10, :20, :30, :40, :50) - IF current_second % 10 = 0 THEN - -- Process high-frequency queues with default batch size (950) - BEGIN - PERFORM public.process_function_queue(ARRAY['on_channel_update', 'on_user_create', 'on_user_update', 'on_version_delete', 'on_version_update', 'on_app_delete', 'on_organization_create', 'on_user_delete', 'on_app_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (high-frequency) failed: %', SQLERRM; - END; - - -- Process channel device counts with batch size 1000 - BEGIN - PERFORM public.process_channel_device_counts_queue(1000); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_channel_device_counts_queue failed: %', SQLERRM; - END; - - END IF; - - -- Every minute (at :00 seconds): Per-minute tasks - IF current_second = 0 THEN - BEGIN - PERFORM public.delete_accounts_marked_for_deletion(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_accounts_marked_for_deletion failed: %', SQLERRM; - END; - - -- Process with batch size 10 - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_sync_sub', 'cron_stat_app'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (per-minute) failed: %', SQLERRM; - END; - - -- on_manifest_create uses default batch size - BEGIN - PERFORM public.process_function_queue(ARRAY['on_manifest_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (manifest_create) failed: %', SQLERRM; - END; - END IF; - - -- Every 5 minutes (at :00 seconds): Org stats with batch size 10 - IF current_minute % 5 = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_stat_org'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (cron_stat_org) failed: %', SQLERRM; - END; - END IF; - - -- Every hour (at :00:00): Hourly cleanup - IF current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_frequent_job_details(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_frequent_job_details failed: %', SQLERRM; - END; - END IF; - - -- Every 2 hours (at :00:00): Low-frequency queues with default batch size - IF current_hour % 2 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['admin_stats', 'cron_email', 'on_version_create', 'on_organization_delete', 'on_deploy_history_create', 'cron_clear_versions']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (low-frequency) failed: %', SQLERRM; - END; - END IF; - - -- Every 6 hours (at :00:00): Stats jobs - IF current_hour % 6 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_stats_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_stats_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:00:00 - Midnight tasks - IF current_hour = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_queue_messages(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_queue_messages failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.delete_old_deleted_apps(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_old_deleted_apps failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.remove_old_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'remove_old_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:40:00 - Old app version retention - IF current_hour = 0 AND current_minute = 40 AND current_second = 0 THEN - BEGIN - PERFORM public.update_app_versions_retention(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'update_app_versions_retention failed: %', SQLERRM; - END; - END IF; - - -- Daily at 01:01:00 - Admin stats creation - IF current_hour = 1 AND current_minute = 1 AND current_second = 0 THEN - BEGIN - PERFORM public.process_admin_stats(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_admin_stats failed: %', SQLERRM; - END; - END IF; - - -- Daily at 03:00:00 - Free trial and credits - IF current_hour = 3 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_free_trial_expired(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_free_trial_expired failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.expire_usage_credits(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'expire_usage_credits failed: %', SQLERRM; - END; - END IF; - - -- Daily at 04:00:00 - Sync sub scheduler - IF current_hour = 4 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_sync_sub_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_sync_sub_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 12:00:00 - Noon tasks - IF current_hour = 12 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup job_run_details failed: %', SQLERRM; - END; - - -- Weekly stats email (every Saturday at noon) - IF EXTRACT(DOW FROM NOW()) = 6 THEN - BEGIN - PERFORM public.process_stats_email_weekly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_weekly failed: %', SQLERRM; - END; - END IF; - - -- Monthly stats email (1st of month at noon) - IF EXTRACT(DAY FROM NOW()) = 1 THEN - BEGIN - PERFORM public.process_stats_email_monthly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_monthly failed: %', SQLERRM; - END; - END IF; - END IF; -END; -$$; diff --git a/supabase/migrations/20251204163538_drop_plans_overage_columns.sql b/supabase/migrations/20251204163538_drop_plans_overage_columns.sql deleted file mode 100644 index 7d57e26c05..0000000000 --- a/supabase/migrations/20251204163538_drop_plans_overage_columns.sql +++ /dev/null @@ -1,11 +0,0 @@ -BEGIN; - -ALTER TABLE public.plans -DROP COLUMN IF EXISTS storage_unit, -DROP COLUMN IF EXISTS bandwidth_unit, -DROP COLUMN IF EXISTS mau_unit, -DROP COLUMN IF EXISTS price_m_storage_id, -DROP COLUMN IF EXISTS price_m_bandwidth_id, -DROP COLUMN IF EXISTS price_m_mau_id; - -COMMIT; diff --git a/supabase/migrations/20251208175306_fix_user_delete_old_record.sql b/supabase/migrations/20251208175306_fix_user_delete_old_record.sql deleted file mode 100644 index bbc0900630..0000000000 --- a/supabase/migrations/20251208175306_fix_user_delete_old_record.sql +++ /dev/null @@ -1,51 +0,0 @@ --- Update delete_user function to fetch old_record using row_to_json query format -CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - user_id_fn uuid; - user_email text; - old_record_json jsonb; -BEGIN - -- Get the current user ID and email - SELECT "auth"."uid"() INTO user_id_fn; - SELECT "email" INTO user_email FROM "auth"."users" WHERE "id" = user_id_fn; - - -- Fetch the old_record using the specified query format - SELECT row_to_json(u)::jsonb INTO old_record_json - FROM ( - SELECT * - FROM public.users - WHERE id = user_id_fn - ) AS u; - - -- Trigger the queue-based deletion process - -- This cancels the subscriptions of the user's organizations - PERFORM "pgmq"."send"( - 'on_user_delete'::text, - "jsonb_build_object"( - 'payload', "jsonb_build_object"( - 'old_record', old_record_json, - 'table', 'users', - 'type', 'DELETE' - ), - 'function_name', 'on_user_delete' - ) - ); - - -- Mark the user for deletion - INSERT INTO "public"."to_delete_accounts" ( - "account_id", - "removal_date", - "removed_data" - ) VALUES - ( - user_id_fn, - NOW() + INTERVAL '30 days', - "jsonb_build_object"('email', user_email, 'apikeys', (SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn)) - ); - - -- Delete the API keys - DELETE FROM "public"."apikeys" WHERE "public"."apikeys"."user_id" = user_id_fn; -END; -$$; diff --git a/supabase/migrations/20251209184322_add_top_up_credits_system.sql b/supabase/migrations/20251209184322_add_top_up_credits_system.sql deleted file mode 100644 index df348a3aa7..0000000000 --- a/supabase/migrations/20251209184322_add_top_up_credits_system.sql +++ /dev/null @@ -1,886 +0,0 @@ -BEGIN; - -ALTER TABLE public.plans -ADD COLUMN IF NOT EXISTS credit_id text; - -UPDATE public.plans -SET credit_id = 'prod_TJRd2hFHZsBIPK' -WHERE credit_id IS NULL; - -ALTER TABLE public.plans -ALTER COLUMN credit_id SET NOT NULL; - -COMMENT ON COLUMN public.plans.credit_id IS 'Stripe product identifier used for purchasing additional credits.'; - -DROP TABLE IF EXISTS public.capgo_credit_products; - -DO $$ -DECLARE - allowed_sources CONSTANT text[] := ARRAY['manual', 'stripe_top_up']; - fallback_source CONSTANT text := allowed_sources[1]; - constraint_name CONSTANT text := 'usage_credit_grants_source_check'; - constraint_exists boolean; -BEGIN - UPDATE public.usage_credit_grants - SET source = fallback_source - WHERE source IS NULL OR NOT (source = ANY (allowed_sources)); - - SELECT EXISTS ( - SELECT 1 - FROM pg_constraint c - WHERE c.conname = constraint_name - AND c.conrelid = 'public.usage_credit_grants'::regclass - ) INTO constraint_exists; - - IF NOT constraint_exists THEN - EXECUTE format( - 'ALTER TABLE public.usage_credit_grants - ADD CONSTRAINT %I CHECK (source = ANY (%L::text[]))', - constraint_name, - allowed_sources - ); - END IF; -END; -$$; - -CREATE OR REPLACE FUNCTION public.top_up_usage_credits( - p_org_id uuid, - p_amount numeric, - p_expires_at timestamptz DEFAULT NULL, - p_source text DEFAULT 'manual', - p_source_ref jsonb DEFAULT NULL, - p_notes text DEFAULT NULL -) RETURNS TABLE ( - grant_id uuid, - transaction_id bigint, - available_credits numeric, - total_credits numeric, - next_expiration timestamptz -) LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - c_empty CONSTANT text := ''; - c_service_role CONSTANT text := 'service_role'; - c_default_source CONSTANT text := 'manual'; - c_purchase CONSTANT public.credit_transaction_type := 'purchase'::public.credit_transaction_type; - c_session_id_key CONSTANT text := 'sessionId'; - c_payment_intent_key CONSTANT text := 'paymentIntentId'; - v_request_role text := current_setting('request.jwt.claim.role', true); - v_effective_expires timestamptz := COALESCE(p_expires_at, NOW() + interval '1 year'); - v_source_ref jsonb := p_source_ref; - v_session_id text := NULLIF(v_source_ref ->> c_session_id_key, c_empty); - v_payment_intent_id text := NULLIF(v_source_ref ->> c_payment_intent_key, c_empty); - v_grant_id uuid; - v_transaction_id bigint; - v_available numeric := 0; - v_total numeric := 0; - v_next_expiration timestamptz; - v_existing_transaction_id bigint; - v_existing_grant_id uuid; -BEGIN - IF current_user <> 'postgres' AND COALESCE(v_request_role, c_empty) <> c_service_role THEN - RAISE EXCEPTION 'insufficient_privileges'; - END IF; - - IF p_org_id IS NULL THEN - RAISE EXCEPTION 'org_id is required'; - END IF; - - IF p_amount IS NULL OR p_amount <= 0 THEN - RAISE EXCEPTION 'amount must be positive'; - END IF; - - -- Guard the grant/transaction creation inside a subtransaction so we can detect - -- race-condition duplicates via the new unique indexes and return the existing - -- ledger row instead of creating another grant. - BEGIN - INSERT INTO public.usage_credit_grants ( - org_id, - credits_total, - credits_consumed, - granted_at, - expires_at, - source, - source_ref, - notes - ) - VALUES ( - p_org_id, - p_amount, - 0, - NOW(), - v_effective_expires, - COALESCE(NULLIF(p_source, c_empty), c_default_source), - v_source_ref, - p_notes - ) - RETURNING id INTO v_grant_id; - - SELECT - COALESCE(b.total_credits, 0), - COALESCE(b.available_credits, 0), - b.next_expiration - INTO v_total, v_available, v_next_expiration - FROM public.usage_credit_balances AS b - WHERE b.org_id = p_org_id; - - INSERT INTO public.usage_credit_transactions ( - org_id, - grant_id, - transaction_type, - amount, - balance_after, - description, - source_ref - ) - VALUES ( - p_org_id, - v_grant_id, - c_purchase, - p_amount, - v_available, - p_notes, - v_source_ref - ) - RETURNING id INTO v_transaction_id; - - EXCEPTION WHEN unique_violation THEN - IF v_session_id IS NULL AND v_payment_intent_id IS NULL THEN - RAISE; - END IF; - - SELECT t.id, t.grant_id - INTO v_existing_transaction_id, v_existing_grant_id - FROM public.usage_credit_transactions AS t - WHERE t.org_id = p_org_id - AND t.transaction_type = c_purchase - AND ( - (v_session_id IS NOT NULL AND t.source_ref ->> c_session_id_key = v_session_id) - OR (v_payment_intent_id IS NOT NULL AND t.source_ref ->> c_payment_intent_key = v_payment_intent_id) - ) - ORDER BY t.id DESC - LIMIT 1; - - IF NOT FOUND THEN - RAISE; - END IF; - - SELECT - COALESCE(b.total_credits, 0), - COALESCE(b.available_credits, 0), - b.next_expiration - INTO v_total, v_available, v_next_expiration - FROM public.usage_credit_balances AS b - WHERE b.org_id = p_org_id; - - v_grant_id := v_existing_grant_id; - v_transaction_id := v_existing_transaction_id; - END; - - grant_id := v_grant_id; - transaction_id := v_transaction_id; - available_credits := v_available; - total_credits := v_total; - next_expiration := v_next_expiration; - - RETURN NEXT; - RETURN; -END; -$$; - -COMMENT ON FUNCTION public.top_up_usage_credits( - uuid, - numeric, - timestamptz, - text, - jsonb, - text -) IS 'Grants credits to an organization, records the transaction ledger entry, and returns the updated balances.'; - -GRANT EXECUTE ON FUNCTION public.top_up_usage_credits( - uuid, numeric, timestamptz, text, jsonb, text -) TO service_role; - -DO $$ -DECLARE - duplicate_count int; - purchase_type CONSTANT text := 'purchase'; - session_id_key CONSTANT text := 'sessionId'; - payment_intent_key CONSTANT text := 'paymentIntentId'; - target_schema CONSTANT text := 'public'; - target_table CONSTANT text := 'usage_credit_transactions'; - qualified_table text := format('%I.%I', target_schema, target_table); - session_idx text := format('%I_purchase_session_id_idx', target_table); - payment_intent_idx text := format('%I_purchase_payment_intent_id_idx', target_table); -BEGIN - EXECUTE format( - 'SELECT COUNT(*) FROM ( - SELECT source_ref ->> %L AS session_id - FROM %s - WHERE transaction_type = %L - AND source_ref ->> %L IS NOT NULL - GROUP BY source_ref ->> %L - HAVING COUNT(*) > 1 - ) dup', - session_id_key, qualified_table, purchase_type, session_id_key, session_id_key - ) - INTO duplicate_count; - - IF duplicate_count > 0 THEN - RAISE EXCEPTION 'Found % duplicate Stripe checkout sessions – clean up the offending % before applying the uniqueness index.', duplicate_count, qualified_table; - END IF; - - EXECUTE format( - 'SELECT COUNT(*) FROM ( - SELECT source_ref ->> %L AS payment_intent_id - FROM %s - WHERE transaction_type = %L - AND source_ref ->> %L IS NOT NULL - GROUP BY source_ref ->> %L - HAVING COUNT(*) > 1 - ) dup', - payment_intent_key, qualified_table, purchase_type, payment_intent_key, payment_intent_key - ) - INTO duplicate_count; - - IF duplicate_count > 0 THEN - RAISE EXCEPTION 'Found % duplicate Stripe payment intents – clean up the offending % before applying the uniqueness index.', duplicate_count, qualified_table; - END IF; - - EXECUTE format( - 'CREATE UNIQUE INDEX IF NOT EXISTS %I - ON %s ((source_ref ->> %L)) - WHERE transaction_type = %L - AND source_ref ->> %L IS NOT NULL', - session_idx, qualified_table, session_id_key, purchase_type, session_id_key - ); - - EXECUTE format( - 'CREATE UNIQUE INDEX IF NOT EXISTS %I - ON %s ((source_ref ->> %L)) - WHERE transaction_type = %L - AND source_ref ->> %L IS NOT NULL', - payment_intent_idx, qualified_table, payment_intent_key, purchase_type, payment_intent_key - ); -END; -$$; - --- Prevent double-charging usage credits when the same overage is processed multiple times in a billing cycle -CREATE OR REPLACE FUNCTION public.apply_usage_overage( - p_org_id uuid, - p_metric public.credit_metric_type, - p_overage_amount numeric, - p_billing_cycle_start timestamptz, - p_billing_cycle_end timestamptz, - p_details jsonb DEFAULT NULL -) RETURNS TABLE ( - overage_amount numeric, - credits_required numeric, - credits_applied numeric, - credits_remaining numeric, - credit_step_id bigint, - overage_covered numeric, - overage_unpaid numeric, - overage_event_id uuid -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - v_calc RECORD; - v_event_id uuid; - v_remaining numeric := 0; - v_applied numeric := 0; - v_per_unit numeric := 0; - v_available numeric; - v_use numeric; - v_balance numeric; - v_overage_paid numeric := 0; - v_existing_credits numeric := 0; - v_required numeric := 0; - v_credits_to_apply numeric := 0; - grant_rec public.usage_credit_grants%ROWTYPE; -BEGIN - IF p_overage_amount IS NULL OR p_overage_amount <= 0 THEN - RETURN QUERY SELECT 0::numeric, 0::numeric, 0::numeric, 0::numeric, NULL::bigint, 0::numeric, 0::numeric, NULL::uuid; - RETURN; - END IF; - - SELECT * - INTO v_calc - FROM public.calculate_credit_cost(p_metric, p_overage_amount) - LIMIT 1; - - IF v_calc.credit_step_id IS NULL THEN - INSERT INTO public.usage_overage_events ( - org_id, - metric, - overage_amount, - credits_estimated, - credits_debited, - credit_step_id, - billing_cycle_start, - billing_cycle_end, - details - ) - VALUES ( - p_org_id, - p_metric, - p_overage_amount, - 0, - 0, - NULL, - p_billing_cycle_start, - p_billing_cycle_end, - p_details - ) - RETURNING id INTO v_event_id; - - RETURN QUERY SELECT p_overage_amount, 0::numeric, 0::numeric, 0::numeric, NULL::bigint, 0::numeric, p_overage_amount, v_event_id; - RETURN; - END IF; - - v_per_unit := v_calc.credit_cost_per_unit; - v_required := v_calc.credits_required; - - SELECT COALESCE(SUM(credits_debited), 0) - INTO v_existing_credits - FROM public.usage_overage_events - WHERE org_id = p_org_id - AND metric = p_metric - AND (billing_cycle_start IS NOT DISTINCT FROM p_billing_cycle_start::date) - AND (billing_cycle_end IS NOT DISTINCT FROM p_billing_cycle_end::date); - - v_credits_to_apply := GREATEST(v_required - v_existing_credits, 0); - v_remaining := v_credits_to_apply; - - INSERT INTO public.usage_overage_events ( - org_id, - metric, - overage_amount, - credits_estimated, - credits_debited, - credit_step_id, - billing_cycle_start, - billing_cycle_end, - details - ) - VALUES ( - p_org_id, - p_metric, - p_overage_amount, - v_required, - 0, - v_calc.credit_step_id, - p_billing_cycle_start, - p_billing_cycle_end, - p_details - ) - RETURNING id INTO v_event_id; - - FOR grant_rec IN - SELECT * - FROM public.usage_credit_grants - WHERE org_id = p_org_id - AND expires_at >= NOW() - AND credits_consumed < credits_total - ORDER BY expires_at ASC, granted_at ASC - FOR UPDATE - LOOP - EXIT WHEN v_remaining <= 0; - - v_available := grant_rec.credits_total - grant_rec.credits_consumed; - IF v_available <= 0 THEN - CONTINUE; - END IF; - - v_use := LEAST(v_available, v_remaining); - v_remaining := v_remaining - v_use; - v_applied := v_applied + v_use; - - UPDATE public.usage_credit_grants - SET credits_consumed = credits_consumed + v_use - WHERE id = grant_rec.id; - - INSERT INTO public.usage_credit_consumptions ( - grant_id, - org_id, - overage_event_id, - metric, - credits_used - ) - VALUES ( - grant_rec.id, - p_org_id, - v_event_id, - p_metric, - v_use - ); - - SELECT COALESCE(SUM(GREATEST(credits_total - credits_consumed, 0)), 0) - INTO v_balance - FROM public.usage_credit_grants - WHERE org_id = p_org_id - AND expires_at >= NOW(); - - INSERT INTO public.usage_credit_transactions ( - org_id, - grant_id, - transaction_type, - amount, - balance_after, - occurred_at, - description, - source_ref - ) - VALUES ( - p_org_id, - grant_rec.id, - 'deduction', - -v_use, - v_balance, - NOW(), - format('Overage deduction for %s usage', p_metric::text), - jsonb_build_object('overage_event_id', v_event_id, 'metric', p_metric::text) - ); - END LOOP; - - UPDATE public.usage_overage_events - SET credits_debited = v_applied - WHERE id = v_event_id; - - IF v_per_unit > 0 THEN - v_overage_paid := LEAST(p_overage_amount, (v_applied + v_existing_credits) / v_per_unit); - ELSE - v_overage_paid := p_overage_amount; - END IF; - - RETURN QUERY SELECT - p_overage_amount, - v_required, - v_applied, - GREATEST(v_required - v_existing_credits - v_applied, 0), - v_calc.credit_step_id, - v_overage_paid, - GREATEST(p_overage_amount - v_overage_paid, 0), - v_event_id; -END; -$$; - -DROP VIEW IF EXISTS public.usage_credit_ledger; - -CREATE VIEW public.usage_credit_ledger -WITH (security_invoker = TRUE, security_barrier = TRUE) AS -WITH overage_allocations AS ( - SELECT - e.id AS overage_event_id, - e.org_id, - e.metric, - e.overage_amount, - e.credits_estimated, - e.credits_debited, - e.billing_cycle_start, - e.billing_cycle_end, - e.created_at, - e.details, - COALESCE(SUM(c.credits_used), 0) AS credits_applied, - JSONB_AGG( - JSONB_BUILD_OBJECT( - 'grant_id', c.grant_id, - 'credits_used', c.credits_used, - 'grant_source', g.source, - 'grant_expires_at', g.expires_at, - 'grant_notes', g.notes - ) - ORDER BY g.expires_at, g.granted_at - ) FILTER (WHERE c.grant_id IS NOT NULL) AS grant_allocations - FROM public.usage_overage_events AS e - LEFT JOIN public.usage_credit_consumptions AS c - ON e.id = c.overage_event_id - LEFT JOIN public.usage_credit_grants AS g - ON c.grant_id = g.id - GROUP BY - e.id, - e.org_id, - e.metric, - e.overage_amount, - e.credits_estimated, - e.credits_debited, - e.billing_cycle_start, - e.billing_cycle_end, - e.created_at, - e.details -), - -aggregated_deductions AS ( - SELECT - a.org_id, - 'deduction'::public.credit_transaction_type AS transaction_type, - a.overage_event_id, - a.metric, - a.overage_amount, - a.billing_cycle_start, - a.billing_cycle_end, - a.grant_allocations, - a.details, - MIN(t.id) AS id, - SUM(t.amount) AS amount, - MIN(t.balance_after) AS balance_after, - MAX(t.occurred_at) AS occurred_at, - MIN(t.description) AS description_raw, - COALESCE( - NULLIF(a.details ->> 'note', ''), - NULLIF(a.details ->> 'description', ''), - MIN(t.description), - FORMAT('Overage %s', a.metric::text) - ) AS description, - JSONB_BUILD_OBJECT( - 'overage_event_id', a.overage_event_id, - 'metric', a.metric::text, - 'overage_amount', a.overage_amount, - 'grant_allocations', a.grant_allocations - ) AS source_ref - FROM public.usage_credit_transactions AS t - INNER JOIN overage_allocations AS a - ON (t.source_ref ->> 'overage_event_id')::uuid = a.overage_event_id - WHERE - t.transaction_type = 'deduction' - AND t.source_ref ? 'overage_event_id' - GROUP BY - a.overage_event_id, - a.metric, - a.overage_amount, - a.billing_cycle_start, - a.billing_cycle_end, - a.grant_allocations, - a.details, - a.org_id -), - -other_transactions AS ( - SELECT - t.id, - t.org_id, - t.transaction_type, - t.amount, - t.balance_after, - t.occurred_at, - t.description, - t.source_ref, - NULL::uuid AS overage_event_id, - NULL::public.credit_metric_type AS metric, - NULL::numeric AS overage_amount, - NULL::date AS billing_cycle_start, - NULL::date AS billing_cycle_end, - NULL::jsonb AS grant_allocations - FROM public.usage_credit_transactions AS t - WHERE - t.transaction_type <> 'deduction' - OR t.source_ref IS NULL - OR NOT (t.source_ref ? 'overage_event_id') -) - -SELECT - id, - org_id, - transaction_type, - amount, - balance_after, - occurred_at, - description, - source_ref, - overage_event_id, - metric, - overage_amount, - billing_cycle_start, - billing_cycle_end, - grant_allocations, - NULL::jsonb AS details -FROM aggregated_deductions -UNION ALL -SELECT - id, - org_id, - transaction_type, - amount, - balance_after, - occurred_at, - description, - source_ref, - overage_event_id, - metric, - overage_amount, - billing_cycle_start, - billing_cycle_end, - grant_allocations, - NULL::jsonb AS details -FROM other_transactions; - -GRANT SELECT ON public.usage_credit_ledger TO authenticated; -GRANT SELECT ON public.usage_credit_ledger TO service_role; - --- Create queue to deliver credit usage threshold alerts -SELECT pgmq.create('credit_usage_alerts'); - --- Enqueue alerts when credit consumption crosses key thresholds -CREATE OR REPLACE FUNCTION public.enqueue_credit_usage_alert() RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - v_total numeric := 0; - v_available numeric := 0; - v_available_before numeric := 0; - v_percent_after numeric := 0; - v_percent_before numeric := 0; - v_threshold integer; - v_alert_cycle integer; - v_occurred_at timestamptz := COALESCE(NEW.occurred_at, NOW()); -BEGIN - IF TG_OP <> 'INSERT' THEN - RETURN COALESCE(NEW, OLD); - END IF; - - IF NEW.amount IS NULL OR NEW.amount >= 0 THEN - RETURN NEW; - END IF; - - SELECT - COALESCE(total_credits, 0), - COALESCE(available_credits, 0) - INTO v_total, v_available - FROM public.usage_credit_balances - WHERE org_id = NEW.org_id; - - v_available := GREATEST(COALESCE(NEW.balance_after, v_available, 0), 0); - - IF v_total <= 0 THEN - RETURN NEW; - END IF; - - v_available_before := GREATEST(v_available - NEW.amount, 0); - IF v_available_before > v_total THEN - v_available_before := v_total; - END IF; - - v_percent_after := LEAST(GREATEST(((v_total - v_available) / v_total) * 100, 0), 100); - v_percent_before := LEAST(GREATEST(((v_total - v_available_before) / v_total) * 100, 0), 100); - - v_alert_cycle := (date_part('year', v_occurred_at)::int * 100) + date_part('month', v_occurred_at)::int; - - FOREACH v_threshold IN ARRAY ARRAY [50, 75, 90, 100] - LOOP - IF v_percent_after >= v_threshold AND v_percent_before < v_threshold THEN - PERFORM pgmq.send( - 'credit_usage_alerts', - jsonb_build_object( - 'function_name', 'credit_usage_alerts', - 'function_type', NULL, - 'payload', jsonb_build_object( - 'org_id', NEW.org_id, - 'threshold', v_threshold, - 'percent_used', ROUND(v_percent_after, 2), - 'total_credits', v_total, - 'available_credits', v_available, - 'alert_cycle', v_alert_cycle, - 'transaction_id', NEW.id - ) - ) - ); - END IF; - END LOOP; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.enqueue_credit_usage_alert() OWNER TO postgres; - -DROP TRIGGER IF EXISTS credit_usage_alert_on_transactions ON public.usage_credit_transactions; - -CREATE TRIGGER credit_usage_alert_on_transactions -AFTER INSERT ON public.usage_credit_transactions -FOR EACH ROW EXECUTE FUNCTION public.enqueue_credit_usage_alert(); - --- Process the new queue alongside other high-frequency triggers -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks() RETURNS void LANGUAGE plpgsql -SET search_path = '' AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; -BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - - -- Every 10 seconds: High-frequency queues (at :00, :10, :20, :30, :40, :50) - IF current_second % 10 = 0 THEN - -- Process high-frequency queues with default batch size (950) - BEGIN - PERFORM public.process_function_queue(ARRAY['on_channel_update', 'on_user_create', 'on_user_update', 'on_version_create', 'on_version_delete', 'on_version_update', 'on_app_delete', 'on_organization_create', 'on_user_delete', 'on_app_create', 'credit_usage_alerts']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (high-frequency) failed: %', SQLERRM; - END; - - -- Process channel device counts with batch size 1000 - BEGIN - PERFORM public.process_channel_device_counts_queue(1000); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_channel_device_counts_queue failed: %', SQLERRM; - END; - - END IF; - - -- Every minute (at :00 seconds): Per-minute tasks - IF current_second = 0 THEN - BEGIN - PERFORM public.delete_accounts_marked_for_deletion(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_accounts_marked_for_deletion failed: %', SQLERRM; - END; - - -- Process with batch size 10 - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_sync_sub', 'cron_stat_app'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (per-minute) failed: %', SQLERRM; - END; - - -- on_manifest_create uses default batch size - BEGIN - PERFORM public.process_function_queue(ARRAY['on_manifest_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (manifest_create) failed: %', SQLERRM; - END; - END IF; - - -- Every 5 minutes (at :00 seconds): Org stats with batch size 10 - IF current_minute % 5 = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_stat_org'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (cron_stat_org) failed: %', SQLERRM; - END; - END IF; - - -- Every hour (at :00:00): Hourly cleanup - IF current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_frequent_job_details(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_frequent_job_details failed: %', SQLERRM; - END; - END IF; - - -- Every 2 hours (at :00:00): Low-frequency queues with default batch size - IF current_hour % 2 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['admin_stats', 'cron_email', 'on_organization_delete', 'on_deploy_history_create', 'cron_clear_versions']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (low-frequency) failed: %', SQLERRM; - END; - END IF; - - -- Every 6 hours (at :00:00): Stats jobs - IF current_hour % 6 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_stats_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_stats_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:00:00 - Midnight tasks - IF current_hour = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_queue_messages(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_queue_messages failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.delete_old_deleted_apps(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_old_deleted_apps failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.remove_old_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'remove_old_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:40:00 - Old app version retention - IF current_hour = 0 AND current_minute = 40 AND current_second = 0 THEN - BEGIN - PERFORM public.update_app_versions_retention(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'update_app_versions_retention failed: %', SQLERRM; - END; - END IF; - - -- Daily at 01:01:00 - Admin stats creation - IF current_hour = 1 AND current_minute = 1 AND current_second = 0 THEN - BEGIN - PERFORM public.process_admin_stats(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_admin_stats failed: %', SQLERRM; - END; - END IF; - - -- Daily at 03:00:00 - Free trial and credits - IF current_hour = 3 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_free_trial_expired(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_free_trial_expired failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.expire_usage_credits(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'expire_usage_credits failed: %', SQLERRM; - END; - END IF; - - -- Daily at 04:00:00 - Sync sub scheduler - IF current_hour = 4 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_sync_sub_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_sync_sub_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 12:00:00 - Noon tasks - IF current_hour = 12 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup job_run_details failed: %', SQLERRM; - END; - - -- Weekly stats email (every Saturday at noon) - IF EXTRACT(DOW FROM NOW()) = 6 THEN - BEGIN - PERFORM public.process_stats_email_weekly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_weekly failed: %', SQLERRM; - END; - END IF; - - -- Monthly stats email (1st of month at noon) - IF EXTRACT(DAY FROM NOW()) = 1 THEN - BEGIN - PERFORM public.process_stats_email_monthly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_monthly failed: %', SQLERRM; - END; - END IF; - END IF; -END; -$$; - - -COMMIT; diff --git a/supabase/migrations/20251212112948_add_expose_metadata_to_apps.sql b/supabase/migrations/20251212112948_add_expose_metadata_to_apps.sql deleted file mode 100644 index 1a4ab5fc18..0000000000 --- a/supabase/migrations/20251212112948_add_expose_metadata_to_apps.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Add expose_metadata column to apps table --- When true, link and comment fields are exposed to the plugin --- Default: false for security/privacy -ALTER TABLE apps -ADD COLUMN IF NOT EXISTS expose_metadata boolean DEFAULT false NOT NULL; - --- Add comment for documentation -COMMENT ON COLUMN apps.expose_metadata IS 'When true, bundle link and comment metadata are exposed to the plugin in update responses'; diff --git a/supabase/migrations/20251213114641_add_revenue_metrics_to_global_stats.sql b/supabase/migrations/20251213114641_add_revenue_metrics_to_global_stats.sql deleted file mode 100644 index 18c9f38b53..0000000000 --- a/supabase/migrations/20251213114641_add_revenue_metrics_to_global_stats.sql +++ /dev/null @@ -1,99 +0,0 @@ --- Add revenue metrics columns to global_stats table --- These will store MRR (Monthly Recurring Revenue) and ARR (Annual Recurring Revenue) per plan --- Revenue metrics (in dollars) -ALTER TABLE public.global_stats -ADD COLUMN mrr double precision DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN total_revenue double precision DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN revenue_solo double precision DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN revenue_maker double precision DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN revenue_team double precision DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN revenue_enterprise double precision DEFAULT 0 NOT NULL; - --- Per-plan monthly/yearly subscription counts -ALTER TABLE public.global_stats -ADD COLUMN plan_solo_monthly integer DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN plan_solo_yearly integer DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN plan_maker_monthly integer DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN plan_maker_yearly integer DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN plan_team_monthly integer DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN plan_team_yearly integer DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN plan_enterprise integer DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN plan_enterprise_monthly integer DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN plan_enterprise_yearly integer DEFAULT 0 NOT NULL; - --- Subscription flow tracking -ALTER TABLE public.global_stats -ADD COLUMN new_paying_orgs integer DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN canceled_orgs integer DEFAULT 0 NOT NULL; - --- Credits tracking -ALTER TABLE public.global_stats -ADD COLUMN credits_bought bigint DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ADD COLUMN credits_consumed bigint DEFAULT 0 NOT NULL; - --- Comments -COMMENT ON COLUMN public.global_stats.mrr IS 'Total Monthly Recurring Revenue in dollars'; - -COMMENT ON COLUMN public.global_stats.total_revenue IS 'Total Annual Recurring Revenue (ARR) in dollars'; - -COMMENT ON COLUMN public.global_stats.revenue_solo IS 'Solo plan ARR in dollars'; - -COMMENT ON COLUMN public.global_stats.revenue_maker IS 'Maker plan ARR in dollars'; - -COMMENT ON COLUMN public.global_stats.revenue_team IS 'Team plan ARR in dollars'; - -COMMENT ON COLUMN public.global_stats.plan_solo_monthly IS 'Number of Solo plan monthly subscriptions'; - -COMMENT ON COLUMN public.global_stats.plan_solo_yearly IS 'Number of Solo plan yearly subscriptions'; - -COMMENT ON COLUMN public.global_stats.plan_maker_monthly IS 'Number of Maker plan monthly subscriptions'; - -COMMENT ON COLUMN public.global_stats.plan_maker_yearly IS 'Number of Maker plan yearly subscriptions'; - -COMMENT ON COLUMN public.global_stats.plan_team_monthly IS 'Number of Team plan monthly subscriptions'; - -COMMENT ON COLUMN public.global_stats.plan_team_yearly IS 'Number of Team plan yearly subscriptions'; - -COMMENT ON COLUMN public.global_stats.plan_enterprise_monthly IS 'Number of Enterprise plan monthly subscriptions'; - -COMMENT ON COLUMN public.global_stats.plan_enterprise_yearly IS 'Number of Enterprise plan yearly subscriptions'; - -COMMENT ON COLUMN public.global_stats.revenue_enterprise IS 'Enterprise plan ARR in dollars'; - -COMMENT ON COLUMN public.global_stats.new_paying_orgs IS 'Number of new paying organizations today'; - -COMMENT ON COLUMN public.global_stats.canceled_orgs IS 'Number of canceled subscriptions today'; - -COMMENT ON COLUMN public.global_stats.credits_bought IS 'Total credits purchased today'; - -COMMENT ON COLUMN public.global_stats.credits_consumed IS 'Total credits consumed today'; diff --git a/supabase/migrations/20251213140000_add_encryption_tracking_to_devices.sql b/supabase/migrations/20251213140000_add_encryption_tracking_to_devices.sql deleted file mode 100644 index 60b7c97e29..0000000000 --- a/supabase/migrations/20251213140000_add_encryption_tracking_to_devices.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Add encryption key prefix column to devices table -ALTER TABLE public.devices -ADD COLUMN key_id character varying(20); - --- Add comment to explain the column -COMMENT ON COLUMN public.devices.key_id IS 'First 20 characters of the base64-encoded public key (identifies which key is in use)'; - --- Create index for better query performance on key_id -CREATE INDEX IF NOT EXISTS idx_devices_key_id ON public.devices (key_id) -WHERE key_id IS NOT NULL; - -ALTER TABLE public.app_versions -ADD COLUMN key_id character varying(20); - --- Add comment to explain the column -COMMENT ON COLUMN public.app_versions.key_id IS 'First 20 characters of the base64-encoded public key used to encrypt this bundle (identifies which key was used for encryption)'; - --- Create index for better query performance on key_id -CREATE INDEX IF NOT EXISTS idx_app_versions_key_id ON public.app_versions ( - key_id -) -WHERE key_id IS NOT NULL; diff --git a/supabase/migrations/20251219192610_add_cli_version_to_app_versions.sql b/supabase/migrations/20251219192610_add_cli_version_to_app_versions.sql deleted file mode 100644 index c6923c954c..0000000000 --- a/supabase/migrations/20251219192610_add_cli_version_to_app_versions.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Add cli_version column to app_versions table to track which CLI version was used to upload the bundle -ALTER TABLE public.app_versions -ADD COLUMN IF NOT EXISTS cli_version character varying; - --- Add comment to explain the column -COMMENT ON COLUMN public.app_versions.cli_version IS 'The version of @capgo/cli used to upload this bundle'; diff --git a/supabase/migrations/20251220011455_optimize_is_good_plan_v5_org.sql b/supabase/migrations/20251220011455_optimize_is_good_plan_v5_org.sql deleted file mode 100644 index 0991bf24c3..0000000000 --- a/supabase/migrations/20251220011455_optimize_is_good_plan_v5_org.sql +++ /dev/null @@ -1,521 +0,0 @@ --- Optimization for is_good_plan_v5_org function --- This migration adds missing indexes and rewrites the function to: --- 1. Eliminate redundant subqueries (fetched subscription dates twice) --- 2. Add missing composite index on daily_version (app_id, date) --- 3. Add covering index on stripe_info for plan lookups --- 4. Add partial index on app_versions for storage calculation --- 5. Early exit for Enterprise plans (skip metrics calculation) - --- Step 1: Add missing indexes - --- Fix daily_version missing date in composite index (was only app_id) -CREATE INDEX IF NOT EXISTS idx_daily_version_app_id_date -ON public.daily_version (app_id, date); - --- Covering index for stripe_info to avoid heap access during plan lookups -CREATE INDEX IF NOT EXISTS idx_stripe_info_customer_covering -ON public.stripe_info (customer_id) -INCLUDE (product_id, subscription_anchor_start, subscription_anchor_end); - --- Partial index for storage calculation (only non-deleted versions) -CREATE INDEX IF NOT EXISTS idx_app_versions_owner_org_not_deleted -ON public.app_versions (owner_org) -WHERE deleted = false; - --- Step 2: Rewrite is_good_plan_v5_org with optimizations -DROP FUNCTION IF EXISTS public.is_good_plan_v5_org(uuid); - -CREATE FUNCTION public.is_good_plan_v5_org(orgid uuid) -RETURNS boolean LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - v_product_id text; - v_start_date date; - v_end_date date; - v_plan_name text; - total_metrics RECORD; - v_anchor_day INTERVAL; -BEGIN - -- Get product_id and calculate current billing cycle (properly inlined get_cycle_info_org) - SELECT - si.product_id, - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - INTO v_product_id, v_anchor_day - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE o.id = orgid; - - -- Calculate current billing cycle dates based on anchor day - IF v_anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN - v_start_date := (date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + v_anchor_day)::date; - ELSE - v_start_date := (date_trunc('MONTH', NOW()) + v_anchor_day)::date; - END IF; - v_end_date := (v_start_date + INTERVAL '1 MONTH')::date; - - -- Get plan name directly (inlined, avoids get_current_plan_name_org function call) - SELECT p.name INTO v_plan_name - FROM public.plans p - WHERE p.stripe_id = v_product_id; - - -- Early exit for Enterprise plans (skip expensive metrics calculation) - IF v_plan_name = 'Enterprise' THEN - RETURN TRUE; - END IF; - - -- Get metrics (uses existing cache via get_total_metrics) - SELECT * INTO total_metrics - FROM public.get_total_metrics(orgid, v_start_date, v_end_date); - - -- Direct plan fit check (inlined find_fit_plan_v3 logic) - RETURN EXISTS ( - SELECT 1 FROM public.plans p - WHERE p.name = v_plan_name - AND p.mau >= total_metrics.mau - AND p.bandwidth >= total_metrics.bandwidth - AND p.storage >= total_metrics.storage - AND p.build_time_unit >= COALESCE(total_metrics.build_time_unit, 0) - ); -END; -$$; - -ALTER FUNCTION public.is_good_plan_v5_org(uuid) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.is_good_plan_v5_org(uuid) TO anon; -GRANT ALL ON FUNCTION public.is_good_plan_v5_org(uuid) TO authenticated; -GRANT ALL ON FUNCTION public.is_good_plan_v5_org(uuid) TO service_role; - --- Step 3: Optimize get_current_plan_max_org (eliminates 3 nested subqueries) -DROP FUNCTION IF EXISTS public.get_current_plan_max_org(uuid); - -CREATE FUNCTION public.get_current_plan_max_org(orgid uuid) RETURNS TABLE ( - mau bigint, - bandwidth bigint, - storage bigint, - build_time_unit bigint -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT p.mau, p.bandwidth, p.storage, p.build_time_unit - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; -END; -$$; - -ALTER FUNCTION public.get_current_plan_max_org(uuid) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_current_plan_max_org(uuid) TO anon; -GRANT ALL ON FUNCTION public.get_current_plan_max_org(uuid) TO authenticated; -GRANT ALL ON FUNCTION public.get_current_plan_max_org(uuid) TO service_role; - --- Step 4: Optimize get_plan_usage_percent_detailed (1-arg version) --- Problem: Calls get_current_plan_max_org + get_total_metrics separately --- Solution: Single query for plan limits, reuse optimized get_total_metrics -DROP FUNCTION IF EXISTS public.get_plan_usage_percent_detailed(uuid); - -CREATE FUNCTION public.get_plan_usage_percent_detailed(orgid uuid) -RETURNS TABLE ( - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - v_start_date date; - v_end_date date; - v_plan_mau bigint; - v_plan_bandwidth bigint; - v_plan_storage bigint; - v_plan_build_time bigint; - v_anchor_day INTERVAL; - total_stats RECORD; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; - percent_build_time double precision; -BEGIN - -- Single query for org/stripe info and plan limits (get anchor day for cycle calculation) - SELECT - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL), - p.mau, - p.bandwidth, - p.storage, - p.build_time_unit - INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; - - -- Calculate current billing cycle dates based on anchor day - IF v_anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN - v_start_date := (date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + v_anchor_day)::date; - ELSE - v_start_date := (date_trunc('MONTH', NOW()) + v_anchor_day)::date; - END IF; - v_end_date := (v_start_date + INTERVAL '1 MONTH')::date; - - -- Get metrics using optimized function - SELECT * INTO total_stats - FROM public.get_total_metrics(orgid, v_start_date, v_end_date); - - -- Calculate percentages - percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); - - RETURN QUERY SELECT - GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, - percent_bandwidth, - percent_storage, - percent_build_time; -END; -$$; - -ALTER FUNCTION public.get_plan_usage_percent_detailed(uuid) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid) TO anon; -GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed( - uuid -) TO authenticated; -GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed( - uuid -) TO service_role; - --- Step 5: Optimize get_plan_usage_percent_detailed (3-arg version with cycle dates) -DROP FUNCTION IF EXISTS public.get_plan_usage_percent_detailed( - uuid, date, date -); - -CREATE FUNCTION public.get_plan_usage_percent_detailed( - orgid uuid, - cycle_start date, - cycle_end date -) RETURNS TABLE ( - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - v_plan_mau bigint; - v_plan_bandwidth bigint; - v_plan_storage bigint; - v_plan_build_time bigint; - total_stats RECORD; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; - percent_build_time double precision; -BEGIN - -- Single query for plan limits (inlined get_current_plan_max_org) - SELECT p.mau, p.bandwidth, p.storage, p.build_time_unit - INTO v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; - - -- Get metrics for specified cycle - SELECT * INTO total_stats - FROM public.get_total_metrics(orgid, cycle_start, cycle_end); - - -- Calculate percentages - percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); - - RETURN QUERY SELECT - GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, - percent_bandwidth, - percent_storage, - percent_build_time; -END; -$$; - -ALTER FUNCTION public.get_plan_usage_percent_detailed( - uuid, date, date -) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed( - uuid, date, date -) TO anon; -GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed( - uuid, date, date -) TO authenticated; -GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed( - uuid, date, date -) TO service_role; - --- Step 6: Optimize get_total_metrics (3-arg version) --- Problem: Aggregates from get_app_metrics which returns per-app per-day data, then sums --- Solution: Direct aggregation from daily tables, each aggregated separately to avoid Cartesian product -DROP FUNCTION IF EXISTS public.get_total_metrics(uuid, date, date); - -CREATE FUNCTION public.get_total_metrics( - org_id uuid, - start_date date, - end_date date -) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - v_mau bigint; - v_bandwidth bigint; - v_build_time bigint; - v_get bigint; - v_fail bigint; - v_install bigint; - v_uninstall bigint; - v_storage bigint; -BEGIN - -- Get all app_ids for this org (active + deleted) - -- Aggregate each metric table separately to avoid Cartesian product - - -- MAU - SELECT COALESCE(SUM(dm.mau), 0)::bigint INTO v_mau - FROM public.daily_mau dm - WHERE dm.app_id IN ( - SELECT apps.app_id FROM public.apps WHERE apps.owner_org = org_id - UNION - SELECT deleted_apps.app_id FROM public.deleted_apps WHERE deleted_apps.owner_org = org_id - ) - AND dm.date BETWEEN start_date AND end_date; - - -- Bandwidth - SELECT COALESCE(SUM(db.bandwidth), 0)::bigint INTO v_bandwidth - FROM public.daily_bandwidth db - WHERE db.app_id IN ( - SELECT apps.app_id FROM public.apps WHERE apps.owner_org = org_id - UNION - SELECT deleted_apps.app_id FROM public.deleted_apps WHERE deleted_apps.owner_org = org_id - ) - AND db.date BETWEEN start_date AND end_date; - - -- Build time - SELECT COALESCE(SUM(dbt.build_time_unit), 0)::bigint INTO v_build_time - FROM public.daily_build_time dbt - WHERE dbt.app_id IN ( - SELECT apps.app_id FROM public.apps WHERE apps.owner_org = org_id - UNION - SELECT deleted_apps.app_id FROM public.deleted_apps WHERE deleted_apps.owner_org = org_id - ) - AND dbt.date BETWEEN start_date AND end_date; - - -- Version stats (get, fail, install, uninstall) - SELECT - COALESCE(SUM(dv.get), 0)::bigint, - COALESCE(SUM(dv.fail), 0)::bigint, - COALESCE(SUM(dv.install), 0)::bigint, - COALESCE(SUM(dv.uninstall), 0)::bigint - INTO v_get, v_fail, v_install, v_uninstall - FROM public.daily_version dv - WHERE dv.app_id IN ( - SELECT apps.app_id FROM public.apps WHERE apps.owner_org = org_id - UNION - SELECT deleted_apps.app_id FROM public.deleted_apps WHERE deleted_apps.owner_org = org_id - ) - AND dv.date BETWEEN start_date AND end_date; - - -- Storage is calculated separately (current total, not time-series) - SELECT COALESCE(SUM(avm.size), 0)::bigint INTO v_storage - FROM public.app_versions av - INNER JOIN public.app_versions_meta avm ON av.id = avm.id - WHERE av.owner_org = org_id AND av.deleted = false; - - RETURN QUERY SELECT v_mau, v_storage, v_bandwidth, v_build_time, v_get, v_fail, v_install, v_uninstall; -END; -$$; - -ALTER FUNCTION public.get_total_metrics(uuid, date, date) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_total_metrics(uuid, date, date) TO anon; -GRANT ALL ON FUNCTION public.get_total_metrics( - uuid, date, date -) TO authenticated; -GRANT ALL ON FUNCTION public.get_total_metrics( - uuid, date, date -) TO service_role; - --- Step 7: Optimize get_total_metrics (1-arg version) --- Problem: Calls get_cycle_info_org with nested subqueries --- Solution: Inline cycle date calculation -DROP FUNCTION IF EXISTS public.get_total_metrics(uuid); - -CREATE FUNCTION public.get_total_metrics(org_id uuid) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - v_start_date date; - v_end_date date; - v_anchor_day INTERVAL; -BEGIN - -- Get anchor day for cycle calculation (properly inlined get_cycle_info_org) - SELECT - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - INTO v_anchor_day - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE o.id = org_id; - - -- Calculate current billing cycle dates based on anchor day - IF v_anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN - v_start_date := (date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + v_anchor_day)::date; - ELSE - v_start_date := (date_trunc('MONTH', NOW()) + v_anchor_day)::date; - END IF; - v_end_date := (v_start_date + INTERVAL '1 MONTH')::date; - - RETURN QUERY SELECT * FROM public.get_total_metrics(org_id, v_start_date, v_end_date); -END; -$$; - -ALTER FUNCTION public.get_total_metrics(uuid) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_total_metrics(uuid) TO anon; -GRANT ALL ON FUNCTION public.get_total_metrics(uuid) TO authenticated; -GRANT ALL ON FUNCTION public.get_total_metrics(uuid) TO service_role; - --- Step 8: Optimize get_orgs_v6(userid uuid) --- Problem: Calls 7+ functions per row (is_paying_org, is_trial_org, is_allowed_action_org, etc.) --- Each function queries orgs → stripe_info separately --- Solution: Single JOIN to stripe_info, compute all flags inline -DROP FUNCTION IF EXISTS public.get_orgs_v6(uuid); - -CREATE FUNCTION public.get_orgs_v6(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - -- Calculate current billing cycle for each org (properly inlined get_cycle_info_org logic) - -- anchor_day = day of month when billing cycle starts (extracted from original subscription_anchor_start) - -- If we're before anchor_day this month, cycle started last month; otherwise cycle started this month - billing_cycles AS ( - SELECT - o.id AS org_id, - -- Calculate cycle_start based on anchor day and current date - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- is_paying_org: status = 'succeeded' - (si.status = 'succeeded') AS paying, - -- is_trial_org: days left in trial - GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer AS trial_left, - -- is_allowed_action_org (= is_paying_and_good_plan_org): paying with good plan OR in trial - ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) AS can_use_more, - -- is_canceled_org: status = 'canceled' - (si.status = 'canceled') AS is_canceled, - -- app_count - COALESCE(ac.cnt, 0) AS app_count, - -- subscription dates (properly calculated current billing cycle) - bc.cycle_start AS subscription_start, - (bc.cycle_start + INTERVAL '1 MONTH') AS subscription_end, - o.management_email, - -- is_org_yearly - COALESCE(si.price_id = p.price_y_id, false) AS is_yearly, - o.stats_updated_at, - -- get_next_stats_update_date (simplified - just add 4 min intervals based on position) - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v6(uuid) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO service_role; diff --git a/supabase/migrations/20251221091510_fix_lint_indexes.sql b/supabase/migrations/20251221091510_fix_lint_indexes.sql deleted file mode 100644 index ee187dfa3a..0000000000 --- a/supabase/migrations/20251221091510_fix_lint_indexes.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_build_logs_user_id -ON public.build_logs (user_id); - -CREATE INDEX IF NOT EXISTS idx_build_requests_requested_by -ON public.build_requests (requested_by); diff --git a/supabase/migrations/20251222140030_rbac_system.sql b/supabase/migrations/20251222140030_rbac_system.sql deleted file mode 100644 index 4542526180..0000000000 --- a/supabase/migrations/20251222140030_rbac_system.sql +++ /dev/null @@ -1,3840 +0,0 @@ --- supabase/migrations/20251222140030_rbac_system.sql --- This preserves the original behavior while making the rollout atomic for new environments. - --- 0) RBAC literal constants (avoid repeated string literals across the migration) --- START RBAC CONSTANTS -CREATE OR REPLACE FUNCTION public.rbac_scope_platform() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_scope_org() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_scope_app() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_scope_bundle() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'bundle'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_scope_channel() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_principal_user() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'user'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_principal_group() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'group'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_principal_apikey() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'apikey'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_right_super_admin() RETURNS public.user_min_right -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'super_admin'::public.user_min_right $$; - -CREATE OR REPLACE FUNCTION public.rbac_right_admin() RETURNS public.user_min_right -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'admin'::public.user_min_right $$; - -CREATE OR REPLACE FUNCTION public.rbac_right_write() RETURNS public.user_min_right -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'write'::public.user_min_right $$; - -CREATE OR REPLACE FUNCTION public.rbac_right_upload() RETURNS public.user_min_right -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'upload'::public.user_min_right $$; - -CREATE OR REPLACE FUNCTION public.rbac_right_read() RETURNS public.user_min_right -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'read'::public.user_min_right $$; - -CREATE OR REPLACE FUNCTION public.rbac_right_invite_super_admin() RETURNS public.user_min_right -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'invite_super_admin'::public.user_min_right $$; - -CREATE OR REPLACE FUNCTION public.rbac_right_invite_admin() RETURNS public.user_min_right -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'invite_admin'::public.user_min_right $$; - -CREATE OR REPLACE FUNCTION public.rbac_right_invite_write() RETURNS public.user_min_right -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'invite_write'::public.user_min_right $$; - -CREATE OR REPLACE FUNCTION public.rbac_right_invite_upload() RETURNS public.user_min_right -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'invite_upload'::public.user_min_right $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_platform_super_admin() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform_super_admin'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_org_super_admin() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org_super_admin'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_org_admin() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org_admin'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_org_billing_admin() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org_billing_admin'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_org_member() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org_member'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_app_admin() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app_admin'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_app_developer() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app_developer'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_app_uploader() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app_uploader'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_app_reader() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app_reader'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_bundle_admin() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'bundle_admin'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_bundle_reader() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'bundle_reader'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_channel_admin() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel_admin'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_role_channel_reader() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel_reader'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_read() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.read'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_update_settings() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.update_settings'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_delete() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.delete'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_read_members() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.read_members'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_invite_user() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.invite_user'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_update_user_roles() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.update_user_roles'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_read_billing() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.read_billing'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_update_billing() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.update_billing'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_read_invoices() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.read_invoices'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_read_audit() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.read_audit'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_org_read_billing_audit() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'org.read_billing_audit'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_read() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.read'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_update_settings() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.update_settings'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_delete() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.delete'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_read_bundles() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.read_bundles'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_upload_bundle() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.upload_bundle'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_create_channel() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.create_channel'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_read_channels() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.read_channels'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_read_logs() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.read_logs'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_manage_devices() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.manage_devices'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_read_devices() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.read_devices'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_build_native() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.build_native'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_read_audit() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.read_audit'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_update_user_roles() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.update_user_roles'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_app_transfer() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'app.transfer'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_bundle_delete() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'bundle.delete'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_bundle_read() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'bundle.read'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_bundle_update() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'bundle.update'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_channel_read() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel.read'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_channel_update_settings() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel.update_settings'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_channel_delete() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel.delete'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_channel_read_history() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel.read_history'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_channel_promote_bundle() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel.promote_bundle'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_channel_rollback_bundle() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel.rollback_bundle'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_channel_manage_forced_devices() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel.manage_forced_devices'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_channel_read_forced_devices() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel.read_forced_devices'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_channel_read_audit() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'channel.read_audit'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_platform_impersonate_user() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform.impersonate_user'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_platform_manage_orgs_any() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform.manage_orgs_any'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_platform_manage_apps_any() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform.manage_apps_any'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_platform_manage_channels_any() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform.manage_channels_any'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_platform_run_maintenance_jobs() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform.run_maintenance_jobs'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_platform_delete_orphan_users() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform.delete_orphan_users'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_platform_read_all_audit() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform.read_all_audit'::text $$; - -CREATE OR REPLACE FUNCTION public.rbac_perm_platform_db_break_glass() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'platform.db_break_glass'::text $$; --- END RBAC CONSTANTS - --- 1) Feature flag and supporting identifiers -ALTER TABLE public.orgs -ADD COLUMN IF NOT EXISTS use_new_rbac boolean NOT NULL DEFAULT false; -COMMENT ON COLUMN public.orgs.use_new_rbac IS 'Feature flag: when true, org uses RBAC instead of legacy org_users rights.'; - -CREATE TABLE IF NOT EXISTS public.rbac_settings ( - id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1), - use_new_rbac boolean NOT NULL DEFAULT false, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); -COMMENT ON TABLE public.rbac_settings IS 'Singleton row to flip RBAC on globally without touching org records.'; -COMMENT ON COLUMN public.rbac_settings.use_new_rbac IS 'Global RBAC flag. Legacy permissions remain default (false).'; - -INSERT INTO public.rbac_settings (id, use_new_rbac) -VALUES (1, false) -ON CONFLICT (id) DO NOTHING; - -ALTER TABLE public.rbac_settings -ALTER COLUMN updated_at SET DEFAULT now(); - --- Add stable UUIDs for polymorphic principals/scopes. -ALTER TABLE public.apikeys -ADD COLUMN IF NOT EXISTS rbac_id uuid DEFAULT gen_random_uuid(); -UPDATE public.apikeys SET rbac_id = gen_random_uuid() WHERE rbac_id IS NULL; -ALTER TABLE public.apikeys ALTER COLUMN rbac_id SET NOT NULL; -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'apikeys_rbac_id_key' - AND conrelid = 'public.apikeys'::regclass - ) THEN - ALTER TABLE public.apikeys ADD CONSTRAINT apikeys_rbac_id_key UNIQUE (rbac_id); - END IF; -END; -$$; -COMMENT ON COLUMN public.apikeys.rbac_id IS 'Stable UUID to bind RBAC roles to api keys.'; - -ALTER TABLE public.channels -ADD COLUMN IF NOT EXISTS rbac_id uuid DEFAULT gen_random_uuid(); -UPDATE public.channels SET rbac_id = gen_random_uuid() WHERE rbac_id IS NULL; -ALTER TABLE public.channels ALTER COLUMN rbac_id SET NOT NULL; -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'channels_rbac_id_key' - AND conrelid = 'public.channels'::regclass - ) THEN - ALTER TABLE public.channels ADD CONSTRAINT channels_rbac_id_key UNIQUE (rbac_id); - END IF; -END; -$$; -COMMENT ON COLUMN public.channels.rbac_id IS 'Stable UUID to bind RBAC roles to channel scope.'; - --- apps.id already exists but was not unique; make it an addressable scope identifier. -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'apps_id_unique' - AND conrelid = 'public.apps'::regclass - ) THEN - ALTER TABLE public.apps - ADD CONSTRAINT apps_id_unique UNIQUE (id); - END IF; -END; -$$; -COMMENT ON COLUMN public.apps.id IS 'UUID scope id for RBAC (app-level roles reference this id).'; - --- 2) Core RBAC tables -CREATE TABLE IF NOT EXISTS public.roles ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - name text UNIQUE NOT NULL, - scope_type text NOT NULL CHECK (scope_type IN (public.rbac_scope_platform(), public.rbac_scope_org(), public.rbac_scope_app(), public.rbac_scope_bundle(), public.rbac_scope_channel())), - description text, - priority_rank int NOT NULL DEFAULT 0, - is_assignable boolean NOT NULL DEFAULT true, - created_at timestamptz NOT NULL DEFAULT now(), - created_by uuid NULL -); -COMMENT ON TABLE public.roles IS 'Canonical RBAC roles. Scope_type indicates the native scope the role is defined for.'; - -CREATE TABLE IF NOT EXISTS public.permissions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - key text UNIQUE NOT NULL, - scope_type text NOT NULL CHECK (scope_type IN (public.rbac_scope_platform(), public.rbac_scope_org(), public.rbac_scope_app(), public.rbac_scope_bundle(), public.rbac_scope_channel())), - bundle_id bigint NULL REFERENCES public.app_versions(id) ON DELETE CASCADE, - description text, - created_at timestamptz NOT NULL DEFAULT now() -); -COMMENT ON TABLE public.permissions IS 'Atomic permission keys; used by role_permissions. Only priority permissions are seeded in Phase 1.'; - -CREATE TABLE IF NOT EXISTS public.role_permissions ( - role_id uuid REFERENCES public.roles(id) ON DELETE CASCADE, - permission_id uuid REFERENCES public.permissions(id) ON DELETE CASCADE, - PRIMARY KEY (role_id, permission_id) -); -COMMENT ON TABLE public.role_permissions IS 'Join table assigning permission keys to roles.'; - -CREATE TABLE IF NOT EXISTS public.role_hierarchy ( - parent_role_id uuid REFERENCES public.roles(id) ON DELETE CASCADE, - child_role_id uuid REFERENCES public.roles(id) ON DELETE CASCADE, - PRIMARY KEY (parent_role_id, child_role_id), - CHECK (parent_role_id IS DISTINCT FROM child_role_id) -); -COMMENT ON TABLE public.role_hierarchy IS 'Explicit role inheritance. Parent inherits all permissions of its children (acyclic by convention).'; - -CREATE TABLE IF NOT EXISTS public.groups ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - org_id uuid NOT NULL REFERENCES public.orgs(id) ON DELETE CASCADE, - name text NOT NULL, - description text, - is_system boolean NOT NULL DEFAULT false, - created_by uuid, - created_at timestamptz NOT NULL DEFAULT now(), - CONSTRAINT groups_org_name_unique UNIQUE (org_id, name) -); -COMMENT ON TABLE public.groups IS 'Org-scoped groups/teams. Groups are a principal for role bindings.'; - -CREATE TABLE IF NOT EXISTS public.group_members ( - group_id uuid REFERENCES public.groups(id) ON DELETE CASCADE, - user_id uuid REFERENCES public.users(id) ON DELETE CASCADE, - added_by uuid, - added_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (group_id, user_id) -); -COMMENT ON TABLE public.group_members IS 'Membership join table linking users to groups.'; - -CREATE TABLE IF NOT EXISTS public.role_bindings ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - principal_type text NOT NULL CHECK (principal_type IN (public.rbac_principal_user(), public.rbac_principal_group(), public.rbac_principal_apikey())), - principal_id uuid NOT NULL, - role_id uuid NOT NULL REFERENCES public.roles(id) ON DELETE CASCADE, - scope_type text NOT NULL CHECK (scope_type IN (public.rbac_scope_platform(), public.rbac_scope_org(), public.rbac_scope_app(), public.rbac_scope_bundle(), public.rbac_scope_channel())), - org_id uuid NULL REFERENCES public.orgs(id) ON DELETE CASCADE, - app_id uuid NULL REFERENCES public.apps(id) ON DELETE CASCADE, - bundle_id bigint NULL REFERENCES public.app_versions(id) ON DELETE CASCADE, - channel_id uuid NULL REFERENCES public.channels(rbac_id) ON DELETE CASCADE, - granted_by uuid NOT NULL, - granted_at timestamptz NOT NULL DEFAULT now(), - expires_at timestamptz NULL, - reason text NULL, - is_direct boolean NOT NULL DEFAULT true, - CHECK ( - (scope_type = public.rbac_scope_platform() AND org_id IS NULL AND app_id IS NULL AND bundle_id IS NULL AND channel_id IS NULL) OR - (scope_type = public.rbac_scope_org() AND org_id IS NOT NULL AND app_id IS NULL AND bundle_id IS NULL AND channel_id IS NULL) OR - (scope_type = public.rbac_scope_app() AND org_id IS NOT NULL AND app_id IS NOT NULL AND bundle_id IS NULL AND channel_id IS NULL) OR - (scope_type = public.rbac_scope_bundle() AND org_id IS NOT NULL AND app_id IS NOT NULL AND bundle_id IS NOT NULL AND channel_id IS NULL) OR - (scope_type = public.rbac_scope_channel() AND org_id IS NOT NULL AND app_id IS NOT NULL AND bundle_id IS NULL AND channel_id IS NOT NULL) - ) -); -COMMENT ON TABLE public.role_bindings IS 'Assign roles to principals at a scope. SSD: only one role per scope_type per scope/principal.'; - --- SSD: only one role per scope_type per scope/principal. -CREATE UNIQUE INDEX IF NOT EXISTS role_bindings_platform_scope_uniq - ON public.role_bindings (principal_type, principal_id, scope_type) - WHERE scope_type = public.rbac_scope_platform(); -CREATE UNIQUE INDEX IF NOT EXISTS role_bindings_org_scope_uniq - ON public.role_bindings (principal_type, principal_id, org_id, scope_type) - WHERE scope_type = public.rbac_scope_org(); -CREATE UNIQUE INDEX IF NOT EXISTS role_bindings_app_scope_uniq - ON public.role_bindings (principal_type, principal_id, app_id, scope_type) - WHERE scope_type = public.rbac_scope_app(); -CREATE UNIQUE INDEX IF NOT EXISTS role_bindings_bundle_scope_uniq - ON public.role_bindings (principal_type, principal_id, bundle_id, scope_type) - WHERE scope_type = public.rbac_scope_bundle(); -CREATE UNIQUE INDEX IF NOT EXISTS role_bindings_channel_scope_uniq - ON public.role_bindings (principal_type, principal_id, channel_id, scope_type) - WHERE scope_type = public.rbac_scope_channel(); - -CREATE INDEX IF NOT EXISTS role_bindings_principal_scope_idx - ON public.role_bindings (principal_type, principal_id, scope_type, org_id, app_id, channel_id); -CREATE INDEX IF NOT EXISTS role_bindings_scope_idx - ON public.role_bindings (scope_type, org_id, app_id, channel_id); - --- SSD enforcement is now handled directly by unique indexes on scope_type - --- 3) Seed priority permissions (Phase 1 only) -INSERT INTO public.permissions (key, scope_type, description) -VALUES - -- Org permissions - (public.rbac_perm_org_read(), public.rbac_scope_org(), 'Read org level settings and metadata'), - (public.rbac_perm_org_update_settings(), public.rbac_scope_org(), 'Update org configuration/settings'), - (public.rbac_perm_org_delete(), public.rbac_scope_org(), 'Delete an organization'), - (public.rbac_perm_org_read_members(), public.rbac_scope_org(), 'Read org membership list'), - (public.rbac_perm_org_invite_user(), public.rbac_scope_org(), 'Invite or add members to org'), - (public.rbac_perm_org_update_user_roles(), public.rbac_scope_org(), 'Change org/member roles'), - (public.rbac_perm_org_read_billing(), public.rbac_scope_org(), 'Read org billing settings'), - (public.rbac_perm_org_update_billing(), public.rbac_scope_org(), 'Update org billing settings'), - (public.rbac_perm_org_read_invoices(), public.rbac_scope_org(), 'Read invoices'), - (public.rbac_perm_org_read_audit(), public.rbac_scope_org(), 'Read org-level audit trail'), - (public.rbac_perm_org_read_billing_audit(), public.rbac_scope_org(), 'Read billing/audit details'), - -- App permissions - (public.rbac_perm_app_read(), public.rbac_scope_app(), 'Read app metadata'), - (public.rbac_perm_app_update_settings(), public.rbac_scope_app(), 'Update app settings'), - (public.rbac_perm_app_delete(), public.rbac_scope_app(), 'Delete an app'), - (public.rbac_perm_app_read_bundles(), public.rbac_scope_app(), 'Read app bundle metadata'), - (public.rbac_perm_app_upload_bundle(), public.rbac_scope_app(), 'Upload a bundle'), - (public.rbac_perm_app_create_channel(), public.rbac_scope_app(), 'Create channels'), - (public.rbac_perm_app_read_channels(), public.rbac_scope_app(), 'List/read channels'), - (public.rbac_perm_app_read_logs(), public.rbac_scope_app(), 'Read app logs/metrics'), - (public.rbac_perm_app_manage_devices(), public.rbac_scope_app(), 'Manage devices at app scope'), - (public.rbac_perm_app_read_devices(), public.rbac_scope_app(), 'Read devices at app scope'), - (public.rbac_perm_app_build_native(), public.rbac_scope_app(), 'Trigger native builds'), - (public.rbac_perm_app_read_audit(), public.rbac_scope_app(), 'Read app-level audit trail'), - (public.rbac_perm_app_update_user_roles(), public.rbac_scope_app(), 'Update user roles for this app'), - (public.rbac_perm_app_transfer(), public.rbac_scope_app(), 'Transfer app to another organization'), - -- Bundle permissions - (public.rbac_perm_bundle_delete(), public.rbac_scope_app(), 'Delete a bundle'), - -- Channel permissions - (public.rbac_perm_channel_read(), public.rbac_scope_channel(), 'Read channel metadata'), - (public.rbac_perm_channel_update_settings(), public.rbac_scope_channel(), 'Update channel settings'), - (public.rbac_perm_channel_delete(), public.rbac_scope_channel(), 'Delete a channel'), - (public.rbac_perm_channel_read_history(), public.rbac_scope_channel(), 'Read deploy history'), - (public.rbac_perm_channel_promote_bundle(), public.rbac_scope_channel(), 'Promote bundle to channel'), - (public.rbac_perm_channel_rollback_bundle(), public.rbac_scope_channel(), 'Rollback bundle on channel'), - (public.rbac_perm_channel_manage_forced_devices(), public.rbac_scope_channel(), 'Manage forced devices'), - (public.rbac_perm_channel_read_forced_devices(), public.rbac_scope_channel(), 'Read forced devices'), - (public.rbac_perm_channel_read_audit(), public.rbac_scope_channel(), 'Read channel-level audit'), - -- Platform permissions - (public.rbac_perm_platform_impersonate_user(), public.rbac_scope_platform(), 'Support/impersonation'), - (public.rbac_perm_platform_manage_orgs_any(), public.rbac_scope_platform(), 'Administer any org'), - (public.rbac_perm_platform_manage_apps_any(), public.rbac_scope_platform(), 'Administer any app'), - (public.rbac_perm_platform_manage_channels_any(), public.rbac_scope_platform(), 'Administer any channel'), - (public.rbac_perm_platform_run_maintenance_jobs(), public.rbac_scope_platform(), 'Run maintenance/ops jobs'), - (public.rbac_perm_platform_delete_orphan_users(), public.rbac_scope_platform(), 'Delete orphan users'), - (public.rbac_perm_platform_read_all_audit(), public.rbac_scope_platform(), 'Read all audit trails'), - (public.rbac_perm_platform_db_break_glass(), public.rbac_scope_platform(), 'Emergency direct DB access') -ON CONFLICT (key) DO NOTHING; - --- 4) Seed priority roles -INSERT INTO public.roles (name, scope_type, description, priority_rank, is_assignable, created_by) -VALUES - (public.rbac_role_platform_super_admin(), public.rbac_scope_platform(), 'Full platform control (not assignable to customers)', 100, false, NULL), - (public.rbac_role_org_super_admin(), public.rbac_scope_org(), 'Super admin for an org (same permissions as org_admin)', 95, true, NULL), - (public.rbac_role_org_admin(), public.rbac_scope_org(), 'Full org administration', 90, true, NULL), - (public.rbac_role_org_billing_admin(), public.rbac_scope_org(), 'Billing-only administrator for an org', 80, true, NULL), - (public.rbac_role_org_member(), public.rbac_scope_org(), 'Basic org member: read-only access to org and all apps', 75, true, NULL), - (public.rbac_role_app_admin(), public.rbac_scope_app(), 'Full administration of an app', 70, true, NULL), - (public.rbac_role_app_developer(), public.rbac_scope_app(), 'Developer access: upload bundles, manage devices, but no destructive operations', 68, true, NULL), - (public.rbac_role_app_uploader(), public.rbac_scope_app(), 'Upload-only access: read app data and upload bundles', 66, true, NULL), - (public.rbac_role_app_reader(), public.rbac_scope_app(), 'Read-only access to an app', 65, true, NULL), - (public.rbac_role_bundle_admin(), public.rbac_scope_bundle(), 'Full administration of a bundle', 62, true, NULL), - (public.rbac_role_bundle_reader(), public.rbac_scope_bundle(), 'Read-only access to a bundle', 61, true, NULL), - (public.rbac_role_channel_admin(), public.rbac_scope_channel(), 'Full administration of a channel', 60, true, NULL), - (public.rbac_role_channel_reader(), public.rbac_scope_channel(), 'Read-only access to a channel', 55, true, NULL) -ON CONFLICT (name) DO NOTHING; - --- 5) Attach permissions to roles --- platform_super_admin: full control over all permissions (operations team only) -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON TRUE -WHERE r.name = public.rbac_role_platform_super_admin() -ON CONFLICT DO NOTHING; - --- org_admin: org management, member/role management, and delegated app/channel control (no billing updates, no deletions) -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_org_read(), public.rbac_perm_org_update_settings(), public.rbac_perm_org_read_members(), public.rbac_perm_org_invite_user(), public.rbac_perm_org_update_user_roles(), - public.rbac_perm_org_read_billing(), public.rbac_perm_org_read_invoices(), public.rbac_perm_org_read_audit(), public.rbac_perm_org_read_billing_audit(), - -- app/channel control granted at org scope (no deletions) - public.rbac_perm_app_read(), public.rbac_perm_app_update_settings(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_upload_bundle(), - public.rbac_perm_app_create_channel(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), public.rbac_perm_app_manage_devices(), - public.rbac_perm_app_read_devices(), public.rbac_perm_app_build_native(), public.rbac_perm_app_read_audit(), public.rbac_perm_app_update_user_roles(), - public.rbac_perm_channel_read(), public.rbac_perm_channel_update_settings(), public.rbac_perm_channel_read_history(), - public.rbac_perm_channel_promote_bundle(), public.rbac_perm_channel_rollback_bundle(), public.rbac_perm_channel_manage_forced_devices(), - public.rbac_perm_channel_read_forced_devices(), public.rbac_perm_channel_read_audit() -) -WHERE r.name = public.rbac_role_org_admin() -ON CONFLICT DO NOTHING; - --- org_super_admin: same permissions as org_admin plus app destructive operations and billing -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_org_read(), public.rbac_perm_org_update_settings(), public.rbac_perm_org_delete(), public.rbac_perm_org_read_members(), public.rbac_perm_org_invite_user(), public.rbac_perm_org_update_user_roles(), - public.rbac_perm_org_read_billing(), public.rbac_perm_org_update_billing(), public.rbac_perm_org_read_invoices(), public.rbac_perm_org_read_audit(), public.rbac_perm_org_read_billing_audit(), - -- app/channel control granted at org scope (including deletions) - public.rbac_perm_app_read(), public.rbac_perm_app_update_settings(), public.rbac_perm_app_delete(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_upload_bundle(), - public.rbac_perm_app_create_channel(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), public.rbac_perm_app_manage_devices(), - public.rbac_perm_app_read_devices(), public.rbac_perm_app_build_native(), public.rbac_perm_app_read_audit(), public.rbac_perm_app_update_user_roles(), - public.rbac_perm_app_transfer(), - public.rbac_perm_bundle_delete(), - public.rbac_perm_channel_read(), public.rbac_perm_channel_update_settings(), public.rbac_perm_channel_delete(), public.rbac_perm_channel_read_history(), - public.rbac_perm_channel_promote_bundle(), public.rbac_perm_channel_rollback_bundle(), public.rbac_perm_channel_manage_forced_devices(), - public.rbac_perm_channel_read_forced_devices(), public.rbac_perm_channel_read_audit() -) -WHERE r.name = public.rbac_role_org_super_admin() -ON CONFLICT DO NOTHING; - --- org_billing_admin: restricted to billing views/updates -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_org_read(), public.rbac_perm_org_read_billing(), public.rbac_perm_org_update_billing(), public.rbac_perm_org_read_invoices(), public.rbac_perm_org_read_billing_audit() -) -WHERE r.name = public.rbac_role_org_billing_admin() -ON CONFLICT DO NOTHING; - --- org_member: basic member with read-only access to org and all apps (for self-service and visibility) -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - -- Org permissions: read metadata and members (allows self-service removal) - public.rbac_perm_org_read(), public.rbac_perm_org_read_members(), - -- App permissions: read-only access to all apps in org - public.rbac_perm_app_read(), 'app.list_bundles', 'app.list_channels', public.rbac_perm_app_read_logs(), public.rbac_perm_app_read_devices(), public.rbac_perm_app_read_audit(), - -- Bundle permissions: read-only - public.rbac_perm_bundle_read(), - -- Channel permissions: read-only - public.rbac_perm_channel_read(), public.rbac_perm_channel_read_history(), public.rbac_perm_channel_read_forced_devices(), public.rbac_perm_channel_read_audit() -) -WHERE r.name = public.rbac_role_org_member() -ON CONFLICT DO NOTHING; - --- app_admin: full control of app + channels under that app -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_app_read(), public.rbac_perm_app_update_settings(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_upload_bundle(), - public.rbac_perm_app_create_channel(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), public.rbac_perm_app_manage_devices(), - public.rbac_perm_app_read_devices(), public.rbac_perm_app_build_native(), public.rbac_perm_app_read_audit(), public.rbac_perm_app_update_user_roles(), - public.rbac_perm_bundle_delete(), - public.rbac_perm_channel_read(), public.rbac_perm_channel_update_settings(), public.rbac_perm_channel_delete(), public.rbac_perm_channel_read_history(), - public.rbac_perm_channel_promote_bundle(), public.rbac_perm_channel_rollback_bundle(), public.rbac_perm_channel_manage_forced_devices(), - public.rbac_perm_channel_read_forced_devices(), public.rbac_perm_channel_read_audit() -) -WHERE r.name = public.rbac_role_app_admin() -ON CONFLICT DO NOTHING; - --- app_developer: can upload, manage devices, build, update channels but no deletion or creation -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_app_read(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_upload_bundle(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), - public.rbac_perm_app_manage_devices(), public.rbac_perm_app_read_devices(), public.rbac_perm_app_build_native(), public.rbac_perm_app_read_audit(), - public.rbac_perm_channel_read(), public.rbac_perm_channel_update_settings(), public.rbac_perm_channel_read_history(), public.rbac_perm_channel_promote_bundle(), - public.rbac_perm_channel_rollback_bundle(), public.rbac_perm_channel_manage_forced_devices(), public.rbac_perm_channel_read_forced_devices(), public.rbac_perm_channel_read_audit() -) -WHERE r.name = public.rbac_role_app_developer() -ON CONFLICT DO NOTHING; - --- app_uploader: read access + upload bundle only -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_app_read(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_upload_bundle(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), public.rbac_perm_app_read_devices(), public.rbac_perm_app_read_audit() -) -WHERE r.name = public.rbac_role_app_uploader() -ON CONFLICT DO NOTHING; - --- channel_admin: full control of a channel -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_channel_read(), public.rbac_perm_channel_update_settings(), public.rbac_perm_channel_delete(), public.rbac_perm_channel_read_history(), - public.rbac_perm_channel_promote_bundle(), public.rbac_perm_channel_rollback_bundle(), public.rbac_perm_channel_manage_forced_devices(), - public.rbac_perm_channel_read_forced_devices(), public.rbac_perm_channel_read_audit() -) -WHERE r.name = public.rbac_role_channel_admin() -ON CONFLICT DO NOTHING; - --- app_reader: read-only access to app -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_app_read(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), public.rbac_perm_app_read_devices(), public.rbac_perm_app_read_audit() -) -WHERE r.name = public.rbac_role_app_reader() -ON CONFLICT DO NOTHING; - --- channel_reader: read-only access to channel -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_channel_read(), public.rbac_perm_channel_read_history(), public.rbac_perm_channel_read_forced_devices(), public.rbac_perm_channel_read_audit() -) -WHERE r.name = public.rbac_role_channel_reader() -ON CONFLICT DO NOTHING; - --- bundle_admin: full control of a bundle -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_bundle_read(), public.rbac_perm_bundle_update(), public.rbac_perm_bundle_delete() -) -WHERE r.name = public.rbac_role_bundle_admin() -ON CONFLICT DO NOTHING; - --- bundle_reader: read-only access to bundle -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key IN ( - public.rbac_perm_bundle_read() -) -WHERE r.name = public.rbac_role_bundle_reader() -ON CONFLICT DO NOTHING; - --- 6) Role hierarchy (explicit inheritance) --- Org hierarchy -INSERT INTO public.role_hierarchy (parent_role_id, child_role_id) -SELECT parent.id, child.id -FROM public.roles parent, public.roles child -WHERE parent.name = public.rbac_role_org_super_admin() AND child.name = public.rbac_role_org_admin() -ON CONFLICT DO NOTHING; - -INSERT INTO public.role_hierarchy (parent_role_id, child_role_id) -SELECT parent.id, child.id -FROM public.roles parent, public.roles child -WHERE parent.name = public.rbac_role_org_admin() AND child.name = public.rbac_role_app_admin() -ON CONFLICT DO NOTHING; - --- App hierarchy -INSERT INTO public.role_hierarchy (parent_role_id, child_role_id) -SELECT parent.id, child.id -FROM public.roles parent, public.roles child -WHERE parent.name = public.rbac_role_app_admin() AND child.name = public.rbac_role_app_developer() -ON CONFLICT DO NOTHING; - -INSERT INTO public.role_hierarchy (parent_role_id, child_role_id) -SELECT parent.id, child.id -FROM public.roles parent, public.roles child -WHERE parent.name = public.rbac_role_app_developer() AND child.name = public.rbac_role_app_uploader() -ON CONFLICT DO NOTHING; - -INSERT INTO public.role_hierarchy (parent_role_id, child_role_id) -SELECT parent.id, child.id -FROM public.roles parent, public.roles child -WHERE parent.name = public.rbac_role_app_uploader() AND child.name = public.rbac_role_app_reader() -ON CONFLICT DO NOTHING; - -INSERT INTO public.role_hierarchy (parent_role_id, child_role_id) -SELECT parent.id, child.id -FROM public.roles parent, public.roles child -WHERE parent.name = public.rbac_role_app_admin() AND child.name = public.rbac_role_bundle_admin() -ON CONFLICT DO NOTHING; - -INSERT INTO public.role_hierarchy (parent_role_id, child_role_id) -SELECT parent.id, child.id -FROM public.roles parent, public.roles child -WHERE parent.name = public.rbac_role_app_admin() AND child.name = public.rbac_role_channel_admin() -ON CONFLICT DO NOTHING; - --- Bundle hierarchy -INSERT INTO public.role_hierarchy (parent_role_id, child_role_id) -SELECT parent.id, child.id -FROM public.roles parent, public.roles child -WHERE parent.name = public.rbac_role_bundle_admin() AND child.name = public.rbac_role_bundle_reader() -ON CONFLICT DO NOTHING; - --- Channel hierarchy -INSERT INTO public.role_hierarchy (parent_role_id, child_role_id) -SELECT parent.id, child.id -FROM public.roles parent, public.roles child -WHERE parent.name = public.rbac_role_channel_admin() AND child.name = public.rbac_role_channel_reader() -ON CONFLICT DO NOTHING; - --- 7) Helper: feature flag resolution -CREATE OR REPLACE FUNCTION public.rbac_is_enabled_for_org(p_org_id uuid) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -AS $$ -DECLARE - v_org_enabled boolean; - v_global_enabled boolean; -BEGIN - SELECT use_new_rbac INTO v_org_enabled FROM public.orgs WHERE id = p_org_id; - SELECT use_new_rbac INTO v_global_enabled FROM public.rbac_settings WHERE id = 1; - - RETURN COALESCE(v_org_enabled, false) OR COALESCE(v_global_enabled, false); -END; -$$; -COMMENT ON FUNCTION public.rbac_is_enabled_for_org(uuid) IS 'Feature-flag gate for RBAC. Defaults to false; true when org or global flag is set.'; - --- 8) Helper: map legacy min_right + scope -> RBAC permission key -CREATE OR REPLACE FUNCTION public.rbac_permission_for_legacy(p_min_right public.user_min_right, p_scope text) RETURNS text -LANGUAGE plpgsql -SET search_path = '' -IMMUTABLE AS $$ -BEGIN - IF p_scope = public.rbac_scope_org() THEN - IF p_min_right IN (public.rbac_right_super_admin(), public.rbac_right_admin(), public.rbac_right_invite_super_admin(), public.rbac_right_invite_admin()) THEN - RETURN public.rbac_perm_org_update_user_roles(); - ELSIF p_min_right IN (public.rbac_right_write(), public.rbac_right_upload(), public.rbac_right_invite_write(), public.rbac_right_invite_upload()) THEN - RETURN public.rbac_perm_org_update_settings(); - ELSE - RETURN public.rbac_perm_org_read(); - END IF; - ELSIF p_scope = public.rbac_scope_app() THEN - IF p_min_right IN (public.rbac_right_super_admin(), public.rbac_right_admin(), public.rbac_right_invite_super_admin(), public.rbac_right_invite_admin(), public.rbac_right_write(), public.rbac_right_invite_write()) THEN - RETURN public.rbac_perm_app_update_settings(); - ELSIF p_min_right IN (public.rbac_right_upload(), public.rbac_right_invite_upload()) THEN - RETURN public.rbac_perm_app_upload_bundle(); - ELSE - RETURN public.rbac_perm_app_read(); - END IF; - ELSIF p_scope = public.rbac_scope_channel() THEN - IF p_min_right IN (public.rbac_right_super_admin(), public.rbac_right_admin(), public.rbac_right_invite_super_admin(), public.rbac_right_invite_admin(), public.rbac_right_write(), public.rbac_right_invite_write()) THEN - RETURN public.rbac_perm_channel_update_settings(); - ELSIF p_min_right IN (public.rbac_right_upload(), public.rbac_right_invite_upload()) THEN - RETURN public.rbac_perm_channel_promote_bundle(); - ELSE - RETURN public.rbac_perm_channel_read(); - END IF; - END IF; - - RETURN NULL; -END; -$$; -COMMENT ON FUNCTION public.rbac_permission_for_legacy(public.user_min_right, text) IS 'Compatibility mapping from legacy min_right + scope to a single RBAC permission key (documented assumptions).'; - --- 9) Helper: RBAC permission resolution -CREATE OR REPLACE FUNCTION public.rbac_has_permission( - p_principal_type text, - p_principal_id uuid, - p_permission_key text, - p_org_id uuid, - p_app_id character varying, - p_channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - v_org_id uuid := p_org_id; - v_app_uuid uuid; - v_channel_uuid uuid; - v_channel_app_id text; - v_channel_org_id uuid; - v_has boolean := false; -BEGIN - IF p_permission_key IS NULL THEN - RETURN false; - END IF; - - -- Resolve scope identifiers to UUIDs - IF p_app_id IS NOT NULL THEN - SELECT id, owner_org INTO v_app_uuid, v_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF p_channel_id IS NOT NULL THEN - SELECT rbac_id, app_id, owner_org INTO v_channel_uuid, v_channel_app_id, v_channel_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_uuid IS NOT NULL THEN - IF v_app_uuid IS NULL THEN - SELECT id INTO v_app_uuid FROM public.apps WHERE app_id = v_channel_app_id LIMIT 1; - END IF; - IF v_org_id IS NULL THEN - v_org_id := v_channel_org_id; - END IF; - END IF; - END IF; - - WITH RECURSIVE scope_catalog AS ( - SELECT public.rbac_scope_platform()::text AS scope_type, NULL::uuid AS org_id, NULL::uuid AS app_id, NULL::uuid AS channel_id - UNION ALL - SELECT public.rbac_scope_org(), v_org_id, NULL::uuid, NULL::uuid WHERE v_org_id IS NOT NULL - UNION ALL - SELECT public.rbac_scope_app(), v_org_id, v_app_uuid, NULL::uuid WHERE v_app_uuid IS NOT NULL - UNION ALL - SELECT public.rbac_scope_channel(), v_org_id, v_app_uuid, v_channel_uuid WHERE v_channel_uuid IS NOT NULL - ), - direct_roles AS ( - SELECT rb.role_id - FROM scope_catalog s - JOIN public.role_bindings rb ON rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_platform()) OR - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.channel_id = s.channel_id) - ) - WHERE rb.principal_type = p_principal_type - AND rb.principal_id = p_principal_id - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - group_roles AS ( - SELECT rb.role_id - FROM scope_catalog s - JOIN public.group_members gm ON gm.user_id = p_principal_id - JOIN public.groups g ON g.id = gm.group_id - JOIN public.role_bindings rb ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = gm.group_id - WHERE p_principal_type = public.rbac_principal_user() - AND rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.channel_id = s.channel_id) - ) - AND (v_org_id IS NULL OR g.org_id = v_org_id) - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - combined_roles AS ( - SELECT role_id FROM direct_roles - UNION - SELECT role_id FROM group_roles - ), - role_closure AS ( - SELECT role_id FROM combined_roles - UNION - SELECT rh.child_role_id - FROM public.role_hierarchy rh - JOIN role_closure rc ON rc.role_id = rh.parent_role_id - ), - perm_set AS ( - SELECT DISTINCT p.key - FROM role_closure rc - JOIN public.role_permissions rp ON rp.role_id = rc.role_id - JOIN public.permissions p ON p.id = rp.permission_id - ) - SELECT EXISTS (SELECT 1 FROM perm_set WHERE key = p_permission_key) INTO v_has; - - RETURN v_has; -END; -$$; -COMMENT ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) IS 'RBAC permission resolver with scope awareness and role hierarchy expansion.'; - --- 10) Legacy logic extracted for fallback -CREATE OR REPLACE FUNCTION public.check_min_rights_legacy( - min_right public.user_min_right, - user_id uuid, - org_id uuid, - app_id character varying, - channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - user_right_record RECORD; -BEGIN - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_NO_UID', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text)); - RETURN false; - END IF; - - FOR user_right_record IN - SELECT org_users.user_right, org_users.app_id, org_users.channel_id - FROM public.org_users - WHERE org_users.org_id = check_min_rights_legacy.org_id AND org_users.user_id = check_min_rights_legacy.user_id - LOOP - IF (user_right_record.user_right >= min_right AND user_right_record.app_id IS NULL AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy.app_id AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy.app_id AND user_right_record.channel_id = check_min_rights_legacy.channel_id) - THEN - RETURN true; - END IF; - END LOOP; - - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); - RETURN false; -END; -$$; - --- 11) Updated rights checks: route between legacy and RBAC -CREATE OR REPLACE FUNCTION public.check_min_rights( - min_right public.user_min_right, - org_id uuid, - app_id character varying, - channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' AS $$ -DECLARE - allowed boolean; -BEGIN - allowed := public.check_min_rights(min_right, (SELECT auth.uid()), org_id, app_id, channel_id); - RETURN allowed; -END; -$$; - -CREATE OR REPLACE FUNCTION public.check_min_rights( - min_right public.user_min_right, - user_id uuid, - org_id uuid, - app_id character varying, - channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - v_allowed boolean := false; - v_perm text; - v_scope text; - v_apikey text; - v_apikey_principal uuid; - v_use_rbac boolean; - v_effective_org_id uuid := org_id; -BEGIN - -- Derive org from app/channel when not provided to honor org-level flag and scoping. - IF v_effective_org_id IS NULL AND app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id FROM public.apps WHERE app_id = check_min_rights.app_id LIMIT 1; - END IF; - IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id FROM public.channels WHERE id = channel_id LIMIT 1; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - IF NOT v_use_rbac THEN - RETURN public.check_min_rights_legacy(min_right, user_id, COALESCE(org_id, v_effective_org_id), app_id, channel_id); - END IF; - - IF channel_id IS NOT NULL THEN - v_scope := public.rbac_scope_channel(); - ELSIF app_id IS NOT NULL THEN - v_scope := public.rbac_scope_app(); - ELSE - v_scope := public.rbac_scope_org(); - END IF; - - v_perm := public.rbac_permission_for_legacy(min_right, v_scope); - - IF user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), user_id, v_perm, v_effective_org_id, app_id, channel_id); - END IF; - - -- Also consider apikey principal when RBAC is enabled (API keys can hold roles directly). - IF NOT v_allowed THEN - SELECT public.get_apikey_header() INTO v_apikey; - IF v_apikey IS NOT NULL THEN - SELECT rbac_id INTO v_apikey_principal FROM public.apikeys WHERE key = v_apikey LIMIT 1; - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, v_perm, v_effective_org_id, app_id, channel_id); - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_RBAC', jsonb_build_object('org_id', COALESCE(org_id, v_effective_org_id), 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id, 'scope', v_scope, 'perm', v_perm)); - END IF; - - RETURN v_allowed; -END; -$$; - --- 12) has_app_right helpers (branch to RBAC when enabled) -CREATE OR REPLACE FUNCTION public.has_app_right( - "appid" character varying, - "right" public.user_min_right -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -BEGIN - RETURN public.has_app_right_userid("appid", "right", (SELECT auth.uid())); -END; -$$; - -CREATE OR REPLACE FUNCTION public.has_app_right_userid( - "appid" character varying, - "right" public.user_min_right, - "userid" uuid -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - org_id uuid; - allowed boolean; -BEGIN - org_id := public.get_user_main_org_id_by_app_id("appid"); - - allowed := public.check_min_rights("right", "userid", org_id, "appid", NULL::bigint); - IF NOT allowed THEN - PERFORM public.pg_log('deny: HAS_APP_RIGHT_USERID', jsonb_build_object('appid', "appid", 'org_id', org_id, 'right', "right"::text, 'userid', "userid")); - END IF; - RETURN allowed; -END; -$$; - -CREATE OR REPLACE FUNCTION public.has_app_right_apikey( - "appid" character varying, - "right" public.user_min_right, - "userid" uuid, - "apikey" text -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - org_id uuid; - api_key record; - allowed boolean; - use_rbac boolean; - perm_key text; -BEGIN - org_id := public.get_user_main_org_id_by_app_id("appid"); - use_rbac := public.rbac_is_enabled_for_org(org_id); - - SELECT * FROM public.apikeys WHERE key = "apikey" INTO api_key; - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: APIKEY_ORG_RESTRICT', jsonb_build_object('org_id', org_id, 'appid', "appid")); - RETURN false; - END IF; - END IF; - - IF api_key.limited_to_apps IS DISTINCT FROM '{}' THEN - IF NOT ("appid" = ANY(api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: APIKEY_APP_RESTRICT', jsonb_build_object('appid', "appid")); - RETURN false; - END IF; - END IF; - - IF use_rbac THEN - perm_key := public.rbac_permission_for_legacy("right", public.rbac_scope_app()); - allowed := public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, perm_key, org_id, "appid", NULL::bigint); - ELSE - allowed := public.check_min_rights("right", "userid", org_id, "appid", NULL::bigint); - END IF; - - IF NOT allowed THEN - PERFORM public.pg_log('deny: HAS_APP_RIGHT_APIKEY', jsonb_build_object('appid', "appid", 'org_id', org_id, 'right', "right"::text, 'userid', "userid", 'rbac', use_rbac)); - END IF; - RETURN allowed; -END; -$$; - --- 13) Compatibility helper: suggested RBAC role for a legacy org_users record -CREATE OR REPLACE FUNCTION public.rbac_legacy_role_hint( - p_user_right public.user_min_right, - p_app_id character varying, - p_channel_id bigint -) RETURNS text -LANGUAGE plpgsql -SET search_path = '' -IMMUTABLE AS $$ -BEGIN - IF p_channel_id IS NOT NULL THEN - -- No channel-level role mapping for now - RETURN NULL; - ELSIF p_app_id IS NOT NULL THEN - -- App-level legacy mapping to RBAC roles - IF p_user_right >= public.rbac_right_admin()::public.user_min_right THEN - RETURN public.rbac_role_app_admin(); - ELSIF p_user_right = public.rbac_right_write()::public.user_min_right THEN - RETURN public.rbac_role_app_developer(); - ELSIF p_user_right = public.rbac_right_upload()::public.user_min_right THEN - RETURN public.rbac_role_app_uploader(); - ELSIF p_user_right = public.rbac_right_read()::public.user_min_right THEN - RETURN public.rbac_role_app_reader(); - END IF; - RETURN NULL; - ELSE - -- Org-level legacy mapping - IF p_user_right >= public.rbac_right_super_admin()::public.user_min_right THEN - RETURN public.rbac_role_org_super_admin(); - ELSIF p_user_right >= public.rbac_right_admin()::public.user_min_right THEN - RETURN public.rbac_role_org_admin(); - ELSIF p_user_right = public.rbac_right_write()::public.user_min_right THEN - -- Org-level write creates org_member + app_developer for each app - RETURN 'org_member + app_developer(per-app)'; - ELSIF p_user_right = public.rbac_right_upload()::public.user_min_right THEN - -- Org-level upload creates org_member + app_uploader for each app - RETURN 'org_member + app_uploader(per-app)'; - ELSIF p_user_right = public.rbac_right_read()::public.user_min_right THEN - -- Org-level read creates org_member + app_reader for each app - RETURN 'org_member + app_reader(per-app)'; - END IF; - RETURN NULL; - END IF; -END; -$$; -COMMENT ON FUNCTION public.rbac_legacy_role_hint(public.user_min_right, character varying, bigint) IS 'Heuristic mapping from legacy org_users rows to Phase 1 priority roles. For org-level read/upload/write, returns composite string indicating org_member + per-app role pattern used during migration.'; - --- 14) Migration utility to convert org_users to role_bindings -CREATE OR REPLACE FUNCTION public.rbac_migrate_org_users_to_bindings( - p_org_id uuid, - p_granted_by uuid DEFAULT NULL -) RETURNS jsonb -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - v_granted_by uuid; - v_org_user RECORD; - v_app RECORD; - v_role_name text; - v_app_role_name text; - v_role_id uuid; - v_app_role_id uuid; - v_scope_type text; - v_app_uuid uuid; - v_channel_uuid uuid; - v_binding_id uuid; - v_migrated_count int := 0; - v_skipped_count int := 0; - v_error_count int := 0; - v_errors jsonb := '[]'::jsonb; - v_migration_reason text := 'Migrated from org_users (legacy)'; -BEGIN - -- Use provided granted_by or find org owner - IF p_granted_by IS NULL THEN - SELECT created_by INTO v_granted_by FROM public.orgs WHERE id = p_org_id LIMIT 1; - IF v_granted_by IS NULL THEN - -- Fallback: use first admin user in org - SELECT user_id INTO v_granted_by - FROM public.org_users - WHERE org_id = p_org_id - AND user_right >= public.rbac_right_admin()::public.user_min_right - AND app_id IS NULL - AND channel_id IS NULL - ORDER BY created_at ASC - LIMIT 1; - END IF; - IF v_granted_by IS NULL THEN - RAISE EXCEPTION 'Cannot determine granted_by user for org %', p_org_id; - END IF; - ELSE - v_granted_by := p_granted_by; - END IF; - - -- Iterate through all org_users for this org - FOR v_org_user IN - SELECT id, user_id, org_id, app_id, channel_id, user_right - FROM public.org_users - WHERE org_id = p_org_id - LOOP - BEGIN - -- Special handling for org-level read/upload/write: create org_member + app-level roles - IF v_org_user.app_id IS NULL AND v_org_user.channel_id IS NULL - AND v_org_user.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) THEN - - -- 1) Create org_member binding - SELECT id INTO v_role_id FROM public.roles WHERE name = public.rbac_role_org_member() LIMIT 1; - IF v_role_id IS NOT NULL THEN - -- Check if org_member binding already exists - SELECT id INTO v_binding_id FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = v_org_user.user_id - AND role_id = v_role_id - AND scope_type = public.rbac_scope_org() - AND org_id = p_org_id - LIMIT 1; - - IF v_binding_id IS NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), v_org_user.user_id, v_role_id, public.rbac_scope_org(), p_org_id, - v_granted_by, now(), v_migration_reason, true - ); - v_migrated_count := v_migrated_count + 1; - END IF; - END IF; - - -- 2) Determine app-level role based on user_right - IF v_org_user.user_right = public.rbac_right_read() THEN - v_app_role_name := public.rbac_role_app_reader(); - ELSIF v_org_user.user_right = public.rbac_right_upload() THEN - v_app_role_name := public.rbac_role_app_uploader(); - ELSIF v_org_user.user_right = public.rbac_right_write() THEN - v_app_role_name := public.rbac_role_app_developer(); - END IF; - - SELECT id INTO v_app_role_id FROM public.roles WHERE name = v_app_role_name LIMIT 1; - IF v_app_role_id IS NULL THEN - v_error_count := v_error_count + 1; - v_errors := v_errors || jsonb_build_object( - 'org_user_id', v_org_user.id, - 'reason', 'app_role_not_found', - 'role_name', v_app_role_name - ); - CONTINUE; - END IF; - - -- 3) Create app-level binding for EACH app in the org - FOR v_app IN - SELECT id, app_id FROM public.apps WHERE owner_org = p_org_id - LOOP - -- Check if app binding already exists - SELECT id INTO v_binding_id FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = v_org_user.user_id - AND role_id = v_app_role_id - AND scope_type = public.rbac_scope_app() - AND app_id = v_app.id - LIMIT 1; - - IF v_binding_id IS NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, app_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), v_org_user.user_id, v_app_role_id, public.rbac_scope_app(), p_org_id, v_app.id, - v_granted_by, now(), v_migration_reason, true - ); - v_migrated_count := v_migrated_count + 1; - ELSE - v_skipped_count := v_skipped_count + 1; - END IF; - END LOOP; - - CONTINUE; -- Skip standard processing for this org_user - END IF; - - -- Standard processing for app/channel-specific rights or admin rights - v_role_name := public.rbac_legacy_role_hint( - v_org_user.user_right, - v_org_user.app_id, - v_org_user.channel_id - ); - - -- Skip if no suitable role - IF v_role_name IS NULL THEN - v_skipped_count := v_skipped_count + 1; - v_errors := v_errors || jsonb_build_object( - 'org_user_id', v_org_user.id, - 'user_id', v_org_user.user_id, - 'reason', 'no_suitable_role', - 'user_right', v_org_user.user_right::text, - 'app_id', v_org_user.app_id, - 'channel_id', v_org_user.channel_id - ); - CONTINUE; - END IF; - - -- Get role ID - SELECT id INTO v_role_id FROM public.roles WHERE name = v_role_name LIMIT 1; - IF v_role_id IS NULL THEN - v_error_count := v_error_count + 1; - v_errors := v_errors || jsonb_build_object( - 'org_user_id', v_org_user.id, - 'user_id', v_org_user.user_id, - 'reason', 'role_not_found', - 'role_name', v_role_name - ); - CONTINUE; - END IF; - - -- Determine scope type and resolve IDs - IF v_org_user.channel_id IS NOT NULL THEN - v_scope_type := public.rbac_scope_channel(); - SELECT id INTO v_app_uuid FROM public.apps - WHERE app_id = v_org_user.app_id LIMIT 1; - SELECT rbac_id INTO v_channel_uuid FROM public.channels - WHERE id = v_org_user.channel_id LIMIT 1; - - IF v_app_uuid IS NULL OR v_channel_uuid IS NULL THEN - v_error_count := v_error_count + 1; - v_errors := v_errors || jsonb_build_object( - 'org_user_id', v_org_user.id, - 'reason', 'channel_or_app_not_found', - 'app_id', v_org_user.app_id, - 'channel_id', v_org_user.channel_id - ); - CONTINUE; - END IF; - ELSIF v_org_user.app_id IS NOT NULL THEN - v_scope_type := public.rbac_scope_app(); - SELECT id INTO v_app_uuid FROM public.apps - WHERE app_id = v_org_user.app_id LIMIT 1; - v_channel_uuid := NULL; - - IF v_app_uuid IS NULL THEN - v_error_count := v_error_count + 1; - v_errors := v_errors || jsonb_build_object( - 'org_user_id', v_org_user.id, - 'reason', 'app_not_found', - 'app_id', v_org_user.app_id - ); - CONTINUE; - END IF; - ELSE - v_scope_type := public.rbac_scope_org(); - v_app_uuid := NULL; - v_channel_uuid := NULL; - END IF; - - -- Check if binding already exists (idempotency) - SELECT id INTO v_binding_id FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = v_org_user.user_id - AND role_id = v_role_id - AND scope_type = v_scope_type - AND org_id = p_org_id - AND (app_id = v_app_uuid OR (app_id IS NULL AND v_app_uuid IS NULL)) - AND (channel_id = v_channel_uuid OR (channel_id IS NULL AND v_channel_uuid IS NULL)) - LIMIT 1; - - IF v_binding_id IS NOT NULL THEN - v_skipped_count := v_skipped_count + 1; - CONTINUE; - END IF; - - -- Create role binding - INSERT INTO public.role_bindings ( - principal_type, - principal_id, - role_id, - scope_type, - org_id, - app_id, - channel_id, - granted_by, - granted_at, - reason, - is_direct - ) VALUES ( - public.rbac_principal_user(), - v_org_user.user_id, - v_role_id, - v_scope_type, - p_org_id, - v_app_uuid, - v_channel_uuid, - v_granted_by, - now(), - v_migration_reason, - true - ); - - v_migrated_count := v_migrated_count + 1; - - EXCEPTION WHEN OTHERS THEN - v_error_count := v_error_count + 1; - v_errors := v_errors || jsonb_build_object( - 'org_user_id', v_org_user.id, - 'user_id', v_org_user.user_id, - 'reason', 'exception', - 'error', SQLERRM - ); - END; - END LOOP; - - RETURN jsonb_build_object( - 'org_id', p_org_id, - 'granted_by', v_granted_by, - 'migrated_count', v_migrated_count, - 'skipped_count', v_skipped_count, - 'error_count', v_error_count, - 'errors', v_errors - ); -END; -$$; -COMMENT ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid, uuid) IS 'Migrates org_users records to role_bindings for a specific org. Idempotent and returns migration report.'; - --- Convenience function: migrate and enable RBAC for an org in one call -CREATE OR REPLACE FUNCTION public.rbac_enable_for_org( - p_org_id uuid, - p_granted_by uuid DEFAULT NULL -) RETURNS jsonb -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - v_migration_result jsonb; - v_was_enabled boolean; -BEGIN - -- Check if already enabled - SELECT use_new_rbac INTO v_was_enabled FROM public.orgs WHERE id = p_org_id; - IF v_was_enabled THEN - RETURN jsonb_build_object( - 'status', 'already_enabled', - 'org_id', p_org_id, - 'message', 'RBAC was already enabled for this org' - ); - END IF; - - -- Migrate org_users to role_bindings - v_migration_result := public.rbac_migrate_org_users_to_bindings(p_org_id, p_granted_by); - - -- Enable RBAC flag - UPDATE public.orgs SET use_new_rbac = true WHERE id = p_org_id; - - RETURN jsonb_build_object( - 'status', 'success', - 'org_id', p_org_id, - 'migration_result', v_migration_result, - 'rbac_enabled', true - ); -END; -$$; -COMMENT ON FUNCTION public.rbac_enable_for_org(uuid, uuid) IS 'Migrates org_users to role_bindings and enables RBAC for an org in one transaction.'; - --- Helper: preview migration without executing it -CREATE OR REPLACE FUNCTION public.rbac_preview_migration( - p_org_id uuid -) RETURNS TABLE( - org_user_id bigint, - user_id uuid, - user_right text, - app_id character varying, - channel_id bigint, - suggested_role text, - scope_type text, - will_migrate boolean, - skip_reason text -) -LANGUAGE plpgsql -SET search_path = '' -AS $$ -BEGIN - RETURN QUERY - SELECT - ou.id AS org_user_id, - ou.user_id, - ou.user_right::text AS user_right, - ou.app_id, - ou.channel_id, - public.rbac_legacy_role_hint(ou.user_right, ou.app_id, ou.channel_id) AS suggested_role, - CASE - WHEN ou.channel_id IS NOT NULL THEN public.rbac_scope_channel() - WHEN ou.app_id IS NOT NULL THEN public.rbac_scope_app() - ELSE public.rbac_scope_org() - END AS scope_type, - public.rbac_legacy_role_hint(ou.user_right, ou.app_id, ou.channel_id) IS NOT NULL AS will_migrate, - CASE - WHEN public.rbac_legacy_role_hint(ou.user_right, ou.app_id, ou.channel_id) IS NULL THEN 'no_suitable_role' - ELSE NULL - END AS skip_reason - FROM public.org_users ou - WHERE ou.org_id = p_org_id - ORDER BY ou.user_id, ou.app_id NULLS FIRST, ou.channel_id NULLS FIRST; -END; -$$; -COMMENT ON FUNCTION public.rbac_preview_migration(uuid) IS 'Preview what would be migrated for an org without making changes.'; - --- Helper: rollback migration (remove migrated bindings and disable RBAC) -CREATE OR REPLACE FUNCTION public.rbac_rollback_org( - p_org_id uuid -) RETURNS jsonb -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - v_deleted_count int; - v_migration_reason text := 'Migrated from org_users (legacy)'; -BEGIN - -- Delete all role_bindings that were migrated from org_users - DELETE FROM public.role_bindings - WHERE org_id = p_org_id - AND reason = v_migration_reason - AND is_direct = true; - - GET DIAGNOSTICS v_deleted_count = ROW_COUNT; - - -- Disable RBAC flag - UPDATE public.orgs SET use_new_rbac = false WHERE id = p_org_id; - - RETURN jsonb_build_object( - 'status', 'success', - 'org_id', p_org_id, - 'deleted_bindings', v_deleted_count, - 'rbac_enabled', false - ); -END; -$$; -COMMENT ON FUNCTION public.rbac_rollback_org(uuid) IS 'Removes migrated role_bindings and disables RBAC for an org (rollback migration).'; - --- 15) Fix invite_user_to_org permission check logic -CREATE OR REPLACE FUNCTION "public"."invite_user_to_org" ( - "email" varchar, - "org_id" uuid, - "invite_type" public.user_min_right -) RETURNS varchar LANGUAGE plpgsql SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - org record; - invited_user record; - current_record record; - current_tmp_user record; - calling_user_id uuid; -BEGIN - -- Get the calling user's ID - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id) - INTO calling_user_id; - - -- Check if org exists - SELECT * INTO org FROM public.orgs WHERE public.orgs.id=invite_user_to_org.org_id; - IF org IS NULL THEN - RETURN 'NO_ORG'; - END IF; - - -- Check if user has at least public.rbac_right_admin() rights - IF NOT public.check_min_rights(public.rbac_right_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN - PERFORM public.pg_log('deny: NO_RIGHTS_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'email', invite_user_to_org.email, 'invite_type', invite_user_to_org.invite_type, 'calling_user', calling_user_id)); - RETURN 'NO_RIGHTS'; - END IF; - - -- If inviting as super_admin, caller must be super_admin - IF (invite_type = public.rbac_right_super_admin()::public.user_min_right OR invite_type = public.rbac_right_invite_super_admin()::public.user_min_right) THEN - IF NOT public.check_min_rights(public.rbac_right_super_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN - PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'email', invite_user_to_org.email, 'invite_type', invite_user_to_org.invite_type, 'calling_user', calling_user_id)); - RETURN 'NO_RIGHTS'; - END IF; - END IF; - - -- Check if user already exists - SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email=invite_user_to_org.email; - - IF invited_user IS NOT NULL THEN - -- User exists, check if already in org - SELECT public.org_users.id INTO current_record - FROM public.org_users - WHERE public.org_users.user_id=invited_user.id - AND public.org_users.org_id=invite_user_to_org.org_id; - - IF current_record IS NOT NULL THEN - RETURN 'ALREADY_INVITED'; - ELSE - -- Add user to org - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - RETURN 'OK'; - END IF; - ELSE - -- User doesn't exist, check tmp_users for pending invitations - SELECT * INTO current_tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email=invite_user_to_org.email - AND public.tmp_users.org_id=invite_user_to_org.org_id; - - IF current_tmp_user IS NOT NULL THEN - -- Invitation already exists - IF current_tmp_user.cancelled_at IS NOT NULL THEN - -- Invitation was cancelled, check if recent - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - -- No invitation exists, need to create one (handled elsewhere) - RETURN 'NO_EMAIL'; - END IF; - END IF; -END; -$$; - -COMMENT ON FUNCTION public.invite_user_to_org(varchar, uuid, public.user_min_right) IS -'Invite a user to an organization. Admins can invite read/upload/write/admin roles. Super admins can invite super_admin roles.'; - --- 16) Add use_new_rbac flag to get_orgs_v6 return type -DROP FUNCTION IF EXISTS public.get_orgs_v6(); -DROP FUNCTION IF EXISTS public.get_orgs_v6(uuid); - --- Update the overload with user_id parameter -CREATE OR REPLACE FUNCTION "public"."get_orgs_v6" ("userid" "uuid") RETURNS TABLE ( - "gid" "uuid", - "created_by" "uuid", - "logo" "text", - "name" "text", - "role" character varying, - "paying" boolean, - "trial_left" integer, - "can_use_more" boolean, - "is_canceled" boolean, - "app_count" bigint, - "subscription_start" timestamp with time zone, - "subscription_end" timestamp with time zone, - "management_email" "text", - "is_yearly" boolean, - "use_new_rbac" boolean -) LANGUAGE "plpgsql" -SET search_path = '' SECURITY DEFINER AS $$ -BEGIN - RETURN QUERY - SELECT - sub.id AS gid, - sub.created_by, - sub.logo, - sub.name, - org_users.user_right::varchar AS role, - public.is_paying_org(sub.id) AS paying, - public.is_trial_org(sub.id) AS trial_left, - public.is_allowed_action_org(sub.id) AS can_use_more, - public.is_canceled_org(sub.id) AS is_canceled, - (SELECT count(*) FROM public.apps WHERE owner_org = sub.id) AS app_count, - (sub.f).subscription_anchor_start AS subscription_start, - (sub.f).subscription_anchor_end AS subscription_end, - sub.management_email AS management_email, - public.is_org_yearly(sub.id) AS is_yearly, - sub.use_new_rbac AS use_new_rbac - FROM ( - SELECT public.get_cycle_info_org(o.id) AS f, o.* FROM public.orgs AS o - ) sub - JOIN public.org_users ON (org_users."user_id" = get_orgs_v6.userid AND sub.id = org_users."org_id"); -END; -$$; - --- Update the overload without parameters (calls the one above) -CREATE OR REPLACE FUNCTION "public"."get_orgs_v6" () RETURNS TABLE ( - "gid" "uuid", - "created_by" "uuid", - "logo" "text", - "name" "text", - "role" character varying, - "paying" boolean, - "trial_left" integer, - "can_use_more" boolean, - "is_canceled" boolean, - "app_count" bigint, - "subscription_start" timestamp with time zone, - "subscription_end" timestamp with time zone, - "management_email" "text", - "is_yearly" boolean, - "use_new_rbac" boolean -) LANGUAGE "plpgsql" -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT "public"."get_apikey_header"() into api_key_text; - user_id := NULL; - - -- Check for API key first - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.apikeys WHERE key=api_key_text into api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - user_id := api_key.user_id; - - -- Check limited_to_orgs only if api_key exists and has restrictions - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - return query select orgs.* FROM public.get_orgs_v6(user_id) orgs - where orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - -- If no valid API key user_id yet, try to get FROM public.identity - IF user_id IS NULL THEN - SELECT public.get_identity() into user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - return query select * FROM public.get_orgs_v6(user_id); -END; -$$; - -COMMENT ON FUNCTION public.get_orgs_v6(uuid) IS 'Get organizations for a user, including use_new_rbac flag for per-org RBAC rollout'; -COMMENT ON FUNCTION public.get_orgs_v6() IS 'Get organizations for authenticated user or API key, including use_new_rbac flag'; - --- 16b) RBAC-aware org id list for user or API key -CREATE OR REPLACE FUNCTION "public"."get_user_org_ids"() RETURNS TABLE ( - "org_id" "uuid" -) LANGUAGE "plpgsql" -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - v_user_id uuid; - limited_orgs uuid[]; - has_limited_orgs boolean := false; -BEGIN - SELECT "public"."get_apikey_header"() into api_key_text; - v_user_id := NULL; - - -- Check for API key first - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.apikeys WHERE key=api_key_text into api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - v_user_id := api_key.user_id; - limited_orgs := api_key.limited_to_orgs; - has_limited_orgs := COALESCE(array_length(limited_orgs, 1), 0) > 0; - END IF; - - -- If no valid API key v_user_id yet, try to get FROM public.identity - IF v_user_id IS NULL THEN - SELECT public.get_identity() into v_user_id; - - IF v_user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY - WITH role_orgs AS ( - -- Direct role bindings on org scope - SELECT rb.org_id AS org_uuid - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_user_id - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - -- Group role bindings on org scope - SELECT rb.org_id AS org_uuid - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = v_user_id - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - -- App scope bindings (user) - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.apps ON apps.id = rb.app_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_user_id - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - -- App scope bindings (group) - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.apps ON apps.id = rb.app_id - JOIN public.group_members gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = v_user_id - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - -- Channel scope bindings (user) - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.channels ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_user_id - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - -- Channel scope bindings (group) - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.channels ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - JOIN public.group_members gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = v_user_id - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - legacy_orgs AS ( - SELECT org_users.org_id AS org_uuid - FROM public.org_users - WHERE org_users.user_id = v_user_id - ), - all_orgs AS ( - SELECT org_uuid FROM legacy_orgs - UNION - SELECT org_uuid FROM role_orgs - ) - SELECT ao.org_uuid AS org_id - FROM all_orgs ao - WHERE ao.org_uuid IS NOT NULL - AND ( - NOT has_limited_orgs - OR ao.org_uuid = ANY(limited_orgs) - ); -END; -$$; - -ALTER FUNCTION "public"."get_user_org_ids"() OWNER TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."get_user_org_ids"() TO "authenticated"; - -COMMENT ON FUNCTION public.get_user_org_ids() IS - 'RBAC/legacy-aware org id list for authenticated user or API key (includes org_users and role_bindings membership).'; - --- ============================================================================ --- RBAC-AWARE is_admin() OVERRIDE --- ============================================================================ - --- Override is_admin() to check RBAC platform roles when RBAC is enabled globally -CREATE OR REPLACE FUNCTION public.is_admin(userid uuid) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - admin_ids_jsonb jsonb; - is_admin_legacy boolean := false; - mfa_verified boolean; - rbac_enabled boolean; - has_platform_admin boolean := false; -BEGIN - -- Always check MFA first - SELECT public.verify_mfa() INTO mfa_verified; - IF NOT mfa_verified THEN - RETURN false; - END IF; - - -- Always check legacy vault list (for bootstrapping and backward compatibility) - SELECT decrypted_secret::jsonb INTO admin_ids_jsonb - FROM vault.decrypted_secrets WHERE name = 'admin_users'; - is_admin_legacy := (admin_ids_jsonb ? userid::text); - - -- Check if RBAC is enabled globally - SELECT use_new_rbac INTO rbac_enabled FROM public.rbac_settings WHERE id = 1; - - IF COALESCE(rbac_enabled, false) THEN - -- RBAC mode: also check for platform_super_admin role binding - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - JOIN public.roles r ON r.id = rb.role_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.scope_type = public.rbac_scope_platform() - AND r.name = public.rbac_role_platform_super_admin() - ) INTO has_platform_admin; - - -- In RBAC mode: admin if EITHER in vault list OR has platform role - RETURN is_admin_legacy OR has_platform_admin; - ELSE - -- Legacy mode: only use vault secret list - RETURN is_admin_legacy; - END IF; -END; -$$; - -COMMENT ON FUNCTION public.is_admin(uuid) IS 'Check if user is platform admin. In RBAC mode: checks vault list OR platform_super_admin role (allows bootstrapping). In legacy mode: only checks vault list. Always requires MFA.'; - --- ============================================================================ --- ROW LEVEL SECURITY POLICIES --- ============================================================================ - --- 1) rbac_settings: Global singleton, admin-only writes, authenticated reads -ALTER TABLE public.rbac_settings ENABLE ROW LEVEL SECURITY; - -CREATE POLICY rbac_settings_read_authenticated ON public.rbac_settings - FOR SELECT - TO authenticated - USING (true); - -CREATE POLICY rbac_settings_admin_all ON public.rbac_settings - FOR ALL - TO authenticated - USING (public.is_admin(auth.uid())) - WITH CHECK (public.is_admin(auth.uid())); - --- 2) roles: Public read (needed for UI role lists), admin-only writes -ALTER TABLE public.roles ENABLE ROW LEVEL SECURITY; - -CREATE POLICY roles_read_all ON public.roles - FOR SELECT - TO authenticated - USING (true); - -CREATE POLICY roles_admin_write ON public.roles - FOR ALL - TO authenticated - USING (public.is_admin(auth.uid())) - WITH CHECK (public.is_admin(auth.uid())); - --- 3) permissions: Public read (needed for permission resolution), admin-only writes -ALTER TABLE public.permissions ENABLE ROW LEVEL SECURITY; - -CREATE POLICY permissions_read_all ON public.permissions - FOR SELECT - TO authenticated - USING (true); - -CREATE POLICY permissions_admin_write ON public.permissions - FOR ALL - TO authenticated - USING (public.is_admin(auth.uid())) - WITH CHECK (public.is_admin(auth.uid())); - --- 4) role_permissions: Public read (needed for permission resolution), admin-only writes -ALTER TABLE public.role_permissions ENABLE ROW LEVEL SECURITY; - -CREATE POLICY role_permissions_read_all ON public.role_permissions - FOR SELECT - TO authenticated - USING (true); - -CREATE POLICY role_permissions_admin_write ON public.role_permissions - FOR ALL - TO authenticated - USING (public.is_admin(auth.uid())) - WITH CHECK (public.is_admin(auth.uid())); - --- 5) role_hierarchy: Public read (needed for permission resolution), admin-only writes -ALTER TABLE public.role_hierarchy ENABLE ROW LEVEL SECURITY; - -CREATE POLICY role_hierarchy_read_all ON public.role_hierarchy - FOR SELECT - TO authenticated - USING (true); - -CREATE POLICY role_hierarchy_admin_write ON public.role_hierarchy - FOR ALL - TO authenticated - USING (public.is_admin(auth.uid())) - WITH CHECK (public.is_admin(auth.uid())); - --- 6) groups: Read/write for org members with appropriate rights -ALTER TABLE public.groups ENABLE ROW LEVEL SECURITY; - -CREATE POLICY groups_read_org_member ON public.groups - FOR SELECT - TO authenticated - USING ( - -- User is member of the org - EXISTS ( - SELECT 1 FROM public.org_users - WHERE org_users.org_id = groups.org_id - AND org_users.user_id = auth.uid() - ) - OR - -- User is platform admin - public.is_admin(auth.uid()) - ); - -CREATE POLICY groups_write_org_admin ON public.groups - FOR ALL - TO authenticated - USING ( - -- User has admin rights in the org - public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), org_id, NULL::varchar, NULL::bigint) - OR - -- User is platform admin - public.is_admin(auth.uid()) - ) - WITH CHECK ( - -- User has admin rights in the org - public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), org_id, NULL::varchar, NULL::bigint) - OR - -- User is platform admin - public.is_admin(auth.uid()) - ); - --- 7) group_members: Read/write for org members with appropriate rights -ALTER TABLE public.group_members ENABLE ROW LEVEL SECURITY; - -CREATE POLICY group_members_read_org_member ON public.group_members - FOR SELECT - TO authenticated - USING ( - -- User is member of the org that owns the group - EXISTS ( - SELECT 1 FROM public.groups - JOIN public.org_users ON org_users.org_id = groups.org_id - WHERE groups.id = group_members.group_id - AND org_users.user_id = auth.uid() - ) - OR - -- User is platform admin - public.is_admin(auth.uid()) - ); - -CREATE POLICY group_members_write_org_admin ON public.group_members - FOR ALL - TO authenticated - USING ( - -- User has admin rights in the org that owns the group - EXISTS ( - SELECT 1 FROM public.groups - WHERE groups.id = group_members.group_id - AND ( - public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), groups.org_id, NULL::varchar, NULL::bigint) - OR public.is_admin(auth.uid()) - ) - ) - ) - WITH CHECK ( - -- User has admin rights in the org that owns the group - EXISTS ( - SELECT 1 FROM public.groups - WHERE groups.id = group_members.group_id - AND ( - public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), groups.org_id, NULL::varchar, NULL::bigint) - OR public.is_admin(auth.uid()) - ) - ) - ); - --- 8) role_bindings: Read/write based on scope and org membership -ALTER TABLE public.role_bindings ENABLE ROW LEVEL SECURITY; - -CREATE POLICY role_bindings_read_scope_member ON public.role_bindings - FOR SELECT - TO authenticated - USING ( - -- Platform scope: admin only - (scope_type = public.rbac_scope_platform() AND public.is_admin(auth.uid())) - OR - -- Org scope: org member - (scope_type = public.rbac_scope_org() AND EXISTS ( - SELECT 1 FROM public.org_users - WHERE org_users.org_id = role_bindings.org_id - AND org_users.user_id = auth.uid() - )) - OR - -- App scope: org member (app belongs to org) - (scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - JOIN public.org_users ON org_users.org_id = apps.owner_org - WHERE apps.id = role_bindings.app_id - AND org_users.user_id = auth.uid() - )) - OR - -- Channel scope: org member (channel belongs to app belongs to org) - (scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - JOIN public.apps ON apps.app_id = channels.app_id - JOIN public.org_users ON org_users.org_id = apps.owner_org - WHERE channels.rbac_id = role_bindings.channel_id - AND org_users.user_id = auth.uid() - )) - OR - -- Platform admin sees all - public.is_admin(auth.uid()) - ); - -CREATE POLICY role_bindings_write_scope_admin ON public.role_bindings - FOR ALL - TO authenticated - USING ( - -- Platform scope: admin only - (scope_type = public.rbac_scope_platform() AND public.is_admin(auth.uid())) - OR - -- Org scope: org admin - (scope_type = public.rbac_scope_org() AND public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), org_id, NULL::varchar, NULL::bigint)) - OR - -- App scope: app admin - (scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - WHERE apps.id = role_bindings.app_id - AND public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), apps.owner_org, apps.app_id, NULL::bigint) - )) - OR - -- Channel scope: channel admin - (scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - JOIN public.apps ON apps.app_id = channels.app_id - WHERE channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), apps.owner_org, channels.app_id, channels.id) - )) - OR - -- Platform admin can write all - public.is_admin(auth.uid()) - ) - WITH CHECK ( - -- Same as USING clause - (scope_type = public.rbac_scope_platform() AND public.is_admin(auth.uid())) - OR - (scope_type = public.rbac_scope_org() AND public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), org_id, NULL::varchar, NULL::bigint)) - OR - (scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - WHERE apps.id = role_bindings.app_id - AND public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), apps.owner_org, apps.app_id, NULL::bigint) - )) - OR - (scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - JOIN public.apps ON apps.app_id = channels.app_id - WHERE channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights(public.rbac_right_admin()::public.user_min_right, auth.uid(), apps.owner_org, channels.app_id, channels.id) - )) - OR - public.is_admin(auth.uid()) - ); - --- ============================================================================= --- AUTO-MIGRATION: Convert all existing org_users to role_bindings --- ============================================================================= --- This block runs automatically when the migration is applied in production. --- It's idempotent - safe to run multiple times as it skips existing bindings. - -DO $$ -DECLARE - v_org RECORD; - v_migration_result jsonb; - v_total_migrated int := 0; - v_total_skipped int := 0; - v_total_errors int := 0; - v_orgs_processed int := 0; -BEGIN - RAISE NOTICE 'Starting automatic RBAC migration for all organizations...'; - - -- Migrate org_users to role_bindings for each organization - FOR v_org IN SELECT id, name FROM public.orgs ORDER BY created_at - LOOP - BEGIN - v_orgs_processed := v_orgs_processed + 1; - - -- Call migration function for this org - SELECT public.rbac_migrate_org_users_to_bindings(v_org.id) INTO v_migration_result; - - -- Accumulate statistics - v_total_migrated := v_total_migrated + (v_migration_result->>'migrated_count')::int; - v_total_skipped := v_total_skipped + (v_migration_result->>'skipped_count')::int; - v_total_errors := v_total_errors + (v_migration_result->>'error_count')::int; - - RAISE NOTICE 'Org [%] "%": migrated=%, skipped=%, errors=%', - v_org.id, v_org.name, - v_migration_result->>'migrated_count', - v_migration_result->>'skipped_count', - v_migration_result->>'error_count'; - - -- Log errors if any - IF (v_migration_result->>'error_count')::int > 0 THEN - RAISE WARNING 'Errors during migration for org %: %', v_org.id, v_migration_result->'errors'; - END IF; - - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'Failed to migrate org % (%): %', v_org.id, v_org.name, SQLERRM; - v_total_errors := v_total_errors + 1; - END; - END LOOP; - - RAISE NOTICE '============================================================================='; - RAISE NOTICE 'RBAC auto-migration completed:'; - RAISE NOTICE ' Organizations processed: %', v_orgs_processed; - RAISE NOTICE ' Total bindings created: %', v_total_migrated; - RAISE NOTICE ' Total bindings skipped: %', v_total_skipped; - RAISE NOTICE ' Total errors: %', v_total_errors; - RAISE NOTICE '============================================================================='; - - IF v_total_errors > 0 THEN - RAISE WARNING 'Migration completed with % errors. Review logs above.', v_total_errors; - END IF; -END $$; - --- ============================================================================= --- Sync org_users and role_bindings on user/org creation --- ============================================================================= --- This section ensures that when a user is added to an org, entries are created in both: --- 1. org_users (legacy system) --- 2. role_bindings (new RBAC system) --- This allows switching between both systems during transition. - --- Update the trigger function that creates org_users entries to also create role_bindings entries -CREATE OR REPLACE FUNCTION "public"."generate_org_user_on_org_create"() RETURNS "trigger" -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - org_super_admin_role_id uuid; -BEGIN - -- Create org_users entry (legacy system) - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (NEW.created_by, NEW.id, public.rbac_right_super_admin()::"public"."user_min_right"); - - -- Get the org_super_admin role ID for role_bindings - SELECT id INTO org_super_admin_role_id - FROM public.roles - WHERE name = public.rbac_role_org_super_admin() - LIMIT 1; - - -- Create role_bindings entry (new RBAC system) if role exists - IF org_super_admin_role_id IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, - principal_id, - role_id, - scope_type, - org_id, - granted_by, - granted_at, - reason, - is_direct - ) VALUES ( - public.rbac_principal_user(), - NEW.created_by, - org_super_admin_role_id, - public.rbac_scope_org(), - NEW.id, - NEW.created_by, -- The user grants themselves super_admin on their own org - now(), - 'Auto-granted on org creation', - true - ) - -- Only insert if not already exists (in case of re-run or manual entry) - ON CONFLICT DO NOTHING; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."generate_org_user_on_org_create"() OWNER TO "postgres"; - -COMMENT ON FUNCTION "public"."generate_org_user_on_org_create"() IS - 'Creates entries in both org_users (legacy) and role_bindings (RBAC) when an org is created, allowing dual-system operation during transition.'; - --- Create a function for when users are manually added to orgs --- This would be triggered by inserts into org_users table -CREATE OR REPLACE FUNCTION "public"."sync_org_user_to_role_binding"() RETURNS "trigger" -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - role_name_to_bind text; - role_id_to_bind uuid; - org_member_role_id uuid; - app_role_name text; - app_role_id uuid; - v_app RECORD; - v_app_uuid uuid; - v_channel_uuid uuid; - v_granted_by uuid; - v_sync_reason text := 'Synced from org_users'; -BEGIN - v_granted_by := COALESCE(auth.uid(), NEW.user_id); - - -- Handle org-level rights (no app_id, no channel_id) - IF NEW.app_id IS NULL AND NEW.channel_id IS NULL THEN - -- For super_admin and admin: create org-level binding directly - IF NEW.user_right IN (public.rbac_right_super_admin(), public.rbac_right_admin()) THEN - CASE NEW.user_right - WHEN public.rbac_right_super_admin() THEN role_name_to_bind := public.rbac_role_org_super_admin(); - WHEN public.rbac_right_admin() THEN role_name_to_bind := public.rbac_role_org_admin(); - END CASE; - - SELECT id INTO role_id_to_bind FROM public.roles WHERE name = role_name_to_bind LIMIT 1; - - IF role_id_to_bind IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, role_id_to_bind, public.rbac_scope_org(), NEW.org_id, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - - -- For read/upload/write at org level: create org_member + app-level roles for each app - ELSIF NEW.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) THEN - -- 1) Create org_member binding at org level - SELECT id INTO org_member_role_id FROM public.roles WHERE name = public.rbac_role_org_member() LIMIT 1; - IF org_member_role_id IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, org_member_role_id, public.rbac_scope_org(), NEW.org_id, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - - -- 2) Determine app-level role based on user_right - CASE NEW.user_right - WHEN public.rbac_right_read() THEN app_role_name := public.rbac_role_app_reader(); - WHEN public.rbac_right_upload() THEN app_role_name := public.rbac_role_app_uploader(); - WHEN public.rbac_right_write() THEN app_role_name := public.rbac_role_app_developer(); - END CASE; - - SELECT id INTO app_role_id FROM public.roles WHERE name = app_role_name LIMIT 1; - - -- 3) Create app-level binding for EACH app in the org - IF app_role_id IS NOT NULL THEN - FOR v_app IN SELECT id FROM public.apps WHERE owner_org = NEW.org_id - LOOP - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, app_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, app_role_id, public.rbac_scope_app(), NEW.org_id, v_app.id, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END LOOP; - END IF; - END IF; - - -- Handle app-level rights (has app_id, no channel_id) - ELSIF NEW.app_id IS NOT NULL AND NEW.channel_id IS NULL THEN - CASE NEW.user_right - WHEN public.rbac_right_super_admin() THEN role_name_to_bind := public.rbac_role_app_admin(); - WHEN public.rbac_right_admin() THEN role_name_to_bind := public.rbac_role_app_admin(); - WHEN public.rbac_right_write() THEN role_name_to_bind := public.rbac_role_app_developer(); - WHEN public.rbac_right_upload() THEN role_name_to_bind := public.rbac_role_app_uploader(); - WHEN public.rbac_right_read() THEN role_name_to_bind := public.rbac_role_app_reader(); - ELSE role_name_to_bind := public.rbac_role_app_reader(); - END CASE; - - SELECT id INTO role_id_to_bind FROM public.roles WHERE name = role_name_to_bind LIMIT 1; - SELECT id INTO v_app_uuid FROM public.apps WHERE app_id = NEW.app_id LIMIT 1; - - IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, app_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, role_id_to_bind, public.rbac_scope_app(), NEW.org_id, v_app_uuid, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - - -- Handle channel-level rights (has app_id and channel_id) - ELSIF NEW.app_id IS NOT NULL AND NEW.channel_id IS NOT NULL THEN - CASE NEW.user_right - WHEN public.rbac_right_super_admin() THEN role_name_to_bind := public.rbac_role_channel_admin(); - WHEN public.rbac_right_admin() THEN role_name_to_bind := public.rbac_role_channel_admin(); - WHEN public.rbac_right_write() THEN role_name_to_bind := 'channel_developer'; - WHEN public.rbac_right_upload() THEN role_name_to_bind := 'channel_uploader'; - WHEN public.rbac_right_read() THEN role_name_to_bind := public.rbac_role_channel_reader(); - ELSE role_name_to_bind := public.rbac_role_channel_reader(); - END CASE; - - SELECT id INTO role_id_to_bind FROM public.roles WHERE name = role_name_to_bind LIMIT 1; - SELECT id INTO v_app_uuid FROM public.apps WHERE app_id = NEW.app_id LIMIT 1; - SELECT rbac_id INTO v_channel_uuid FROM public.channels WHERE id = NEW.channel_id LIMIT 1; - - IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL AND v_channel_uuid IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, app_id, channel_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, role_id_to_bind, public.rbac_scope_channel(), NEW.org_id, v_app_uuid, v_channel_uuid, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."sync_org_user_to_role_binding"() OWNER TO "postgres"; - -COMMENT ON FUNCTION "public"."sync_org_user_to_role_binding"() IS - 'Automatically creates/updates role_bindings entries when org_users entries are inserted, ensuring both systems stay in sync. For org-level read/upload/write rights, creates org_member + app-level roles for each app.'; - --- Create trigger to sync org_users insertions to role_bindings -DROP TRIGGER IF EXISTS sync_org_user_to_role_binding_on_insert ON public.org_users; -CREATE TRIGGER sync_org_user_to_role_binding_on_insert -AFTER INSERT ON public.org_users -FOR EACH ROW -EXECUTE FUNCTION public.sync_org_user_to_role_binding(); - -COMMENT ON TRIGGER sync_org_user_to_role_binding_on_insert ON public.org_users IS - 'Ensures role_bindings are created automatically when org_users entries are added.'; - --- ============================================================================= --- Sync role_bindings on org_users UPDATE (user_right change) --- ============================================================================= --- This function handles when a member's permission is changed from the org settings UI. --- It updates all role_bindings for that user across all apps in the org. - -CREATE OR REPLACE FUNCTION "public"."sync_org_user_role_binding_on_update"() RETURNS "trigger" -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - old_org_role_name text; - new_org_role_name text; - old_org_role_id uuid; - new_org_role_id uuid; - old_app_role_name text; - new_app_role_name text; - old_app_role_id uuid; - new_app_role_id uuid; - org_member_role_id uuid; - v_app RECORD; - v_granted_by uuid; - v_update_reason text := 'Updated from org_users'; -BEGIN - -- Only process if user_right actually changed - IF OLD.user_right = NEW.user_right THEN - RETURN NEW; - END IF; - - -- Only handle org-level rights (no app_id, no channel_id) - IF NEW.app_id IS NOT NULL OR NEW.channel_id IS NOT NULL THEN - RETURN NEW; - END IF; - - v_granted_by := COALESCE(auth.uid(), NEW.user_id); - - -- Map old user_right to role names - CASE OLD.user_right - WHEN public.rbac_right_super_admin() THEN - old_org_role_name := public.rbac_role_org_super_admin(); - old_app_role_name := NULL; - WHEN public.rbac_right_admin() THEN - old_org_role_name := public.rbac_role_org_admin(); - old_app_role_name := NULL; - WHEN public.rbac_right_write() THEN - old_org_role_name := public.rbac_role_org_member(); - old_app_role_name := public.rbac_role_app_developer(); - WHEN public.rbac_right_upload() THEN - old_org_role_name := public.rbac_role_org_member(); - old_app_role_name := public.rbac_role_app_uploader(); - WHEN public.rbac_right_read() THEN - old_org_role_name := public.rbac_role_org_member(); - old_app_role_name := public.rbac_role_app_reader(); - WHEN 'invite_super_admin'::public.user_min_right THEN - -- Invite roles don't have role_bindings yet; they're pending invitations - old_org_role_name := NULL; - old_app_role_name := NULL; - WHEN 'invite_admin'::public.user_min_right THEN - old_org_role_name := NULL; - old_app_role_name := NULL; - WHEN 'invite_write'::public.user_min_right THEN - old_org_role_name := NULL; - old_app_role_name := NULL; - WHEN 'invite_upload'::public.user_min_right THEN - old_org_role_name := NULL; - old_app_role_name := NULL; - WHEN 'invite_read'::public.user_min_right THEN - old_org_role_name := NULL; - old_app_role_name := NULL; - ELSE - -- Handle any unexpected values by logging and returning unchanged - RAISE WARNING 'Unexpected OLD.user_right value: %, skipping role binding sync', OLD.user_right; - RETURN NEW; - END CASE; - - -- Map new user_right to role names - CASE NEW.user_right - WHEN public.rbac_right_super_admin() THEN - new_org_role_name := public.rbac_role_org_super_admin(); - new_app_role_name := NULL; - WHEN public.rbac_right_admin() THEN - new_org_role_name := public.rbac_role_org_admin(); - new_app_role_name := NULL; - WHEN public.rbac_right_write() THEN - new_org_role_name := public.rbac_role_org_member(); - new_app_role_name := public.rbac_role_app_developer(); - WHEN public.rbac_right_upload() THEN - new_org_role_name := public.rbac_role_org_member(); - new_app_role_name := public.rbac_role_app_uploader(); - WHEN public.rbac_right_read() THEN - new_org_role_name := public.rbac_role_org_member(); - new_app_role_name := public.rbac_role_app_reader(); - WHEN 'invite_super_admin'::public.user_min_right THEN - -- Invite roles don't create role_bindings yet; they're pending invitations - new_org_role_name := NULL; - new_app_role_name := NULL; - WHEN 'invite_admin'::public.user_min_right THEN - new_org_role_name := NULL; - new_app_role_name := NULL; - WHEN 'invite_write'::public.user_min_right THEN - new_org_role_name := NULL; - new_app_role_name := NULL; - WHEN 'invite_upload'::public.user_min_right THEN - new_org_role_name := NULL; - new_app_role_name := NULL; - WHEN 'invite_read'::public.user_min_right THEN - new_org_role_name := NULL; - new_app_role_name := NULL; - ELSE - -- Handle any unexpected values by logging and returning unchanged - RAISE WARNING 'Unexpected NEW.user_right value: %, skipping role binding sync', NEW.user_right; - RETURN NEW; - END CASE; - - -- Get role IDs - IF old_org_role_name IS NOT NULL THEN - SELECT id INTO old_org_role_id FROM public.roles WHERE name = old_org_role_name LIMIT 1; - END IF; - - IF new_org_role_name IS NOT NULL THEN - SELECT id INTO new_org_role_id FROM public.roles WHERE name = new_org_role_name LIMIT 1; - END IF; - SELECT id INTO org_member_role_id FROM public.roles WHERE name = public.rbac_role_org_member() LIMIT 1; - - IF old_app_role_name IS NOT NULL THEN - SELECT id INTO old_app_role_id FROM public.roles WHERE name = old_app_role_name LIMIT 1; - END IF; - - IF new_app_role_name IS NOT NULL THEN - SELECT id INTO new_app_role_id FROM public.roles WHERE name = new_app_role_name LIMIT 1; - END IF; - - -- Delete old org-level binding (only if there was a role) - IF old_org_role_id IS NOT NULL THEN - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = NEW.user_id - AND scope_type = public.rbac_scope_org() - AND org_id = NEW.org_id - AND role_id = old_org_role_id; - END IF; - - -- Delete old app-level bindings (for read/upload/write users) - IF old_app_role_id IS NOT NULL THEN - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = NEW.user_id - AND scope_type = public.rbac_scope_app() - AND org_id = NEW.org_id - AND role_id = old_app_role_id; - END IF; - - -- Create new org-level binding - IF new_org_role_id IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, new_org_role_id, public.rbac_scope_org(), NEW.org_id, - v_granted_by, now(), v_update_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - - -- Create new app-level bindings for each app (for read/upload/write users) - IF new_app_role_id IS NOT NULL THEN - FOR v_app IN SELECT id FROM public.apps WHERE owner_org = NEW.org_id - LOOP - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, app_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, new_app_role_id, public.rbac_scope_app(), NEW.org_id, v_app.id, - v_granted_by, now(), v_update_reason, true - ) ON CONFLICT DO NOTHING; - END LOOP; - END IF; - - -- Handle transition from admin/super_admin to read/upload/write: - -- Need to also delete any old org_member binding that might exist - IF OLD.user_right IN (public.rbac_right_super_admin(), public.rbac_right_admin()) AND NEW.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) THEN - -- No additional cleanup needed, old org-level binding already deleted above - NULL; - END IF; - - -- Handle transition from read/upload/write to admin/super_admin: - -- Need to delete the org_member binding - IF OLD.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) AND NEW.user_right IN (public.rbac_right_super_admin(), public.rbac_right_admin()) THEN - IF org_member_role_id IS NOT NULL THEN - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = NEW.user_id - AND scope_type = public.rbac_scope_org() - AND org_id = NEW.org_id - AND role_id = org_member_role_id; - END IF; - - -- Also delete any remaining app-level bindings - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = NEW.user_id - AND scope_type = public.rbac_scope_app() - AND org_id = NEW.org_id; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."sync_org_user_role_binding_on_update"() OWNER TO "postgres"; - -COMMENT ON FUNCTION "public"."sync_org_user_role_binding_on_update"() IS - 'Automatically updates role_bindings entries when org_users.user_right is modified, ensuring both systems stay in sync. Handles transitions between admin roles and member roles.'; - --- Create trigger to sync org_users updates to role_bindings -DROP TRIGGER IF EXISTS sync_org_user_role_binding_on_update ON public.org_users; -CREATE TRIGGER sync_org_user_role_binding_on_update -AFTER UPDATE OF user_right ON public.org_users -FOR EACH ROW -EXECUTE FUNCTION public.sync_org_user_role_binding_on_update(); - -COMMENT ON TRIGGER sync_org_user_role_binding_on_update ON public.org_users IS - 'Ensures role_bindings are updated automatically when org_users permissions are changed.'; - --- ============================================================================= --- Enriched role_bindings view for the admin interface --- ============================================================================= - --- Helper function to check if a user is an org admin (avoid RLS recursion) -CREATE OR REPLACE FUNCTION public.is_user_org_admin(p_user_id uuid, p_org_id uuid) -RETURNS boolean -LANGUAGE sql -SECURITY DEFINER -STABLE -AS $$ - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - AND rb.org_id = p_org_id - AND rb.scope_type = public.rbac_scope_org() - AND r.name IN (public.rbac_role_platform_super_admin(), public.rbac_role_org_super_admin(), public.rbac_role_org_admin()) - ); -$$; - -COMMENT ON FUNCTION public.is_user_org_admin(uuid, uuid) IS - 'Checks whether a user has an admin role in an organization (bypasses RLS to avoid recursion).'; - --- Helper function to check if a user is an app admin (avoid RLS recursion) -CREATE OR REPLACE FUNCTION public.is_user_app_admin(p_user_id uuid, p_app_id uuid) -RETURNS boolean -LANGUAGE sql -SECURITY DEFINER -STABLE -AS $$ - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - AND rb.app_id = p_app_id - AND rb.scope_type = public.rbac_scope_app() - AND r.name IN (public.rbac_role_app_admin(), public.rbac_role_org_super_admin(), public.rbac_role_org_admin(), public.rbac_role_platform_super_admin()) - ); -$$; - -COMMENT ON FUNCTION public.is_user_app_admin(uuid, uuid) IS - 'Checks whether a user has an admin role for an app (bypasses RLS to avoid recursion).'; - --- Helper function to check if a user has a role in an app (avoid RLS recursion) -CREATE OR REPLACE FUNCTION public.user_has_role_in_app(p_user_id uuid, p_app_id uuid) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -STABLE -SET search_path = '' -AS $$ -DECLARE - v_caller_id uuid := auth.uid(); - v_org_id uuid; -BEGIN - IF v_caller_id IS NULL THEN - RETURN false; - END IF; - - IF v_caller_id <> p_user_id THEN - SELECT owner_org INTO v_org_id - FROM public.apps - WHERE id = p_app_id - LIMIT 1; - - IF v_org_id IS NULL THEN - RETURN false; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_caller_id - AND (rb.org_id = v_org_id OR rb.app_id = p_app_id) - ) THEN - RETURN false; - END IF; - END IF; - - RETURN EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - AND rb.app_id = p_app_id - AND rb.scope_type = public.rbac_scope_app() - ); -END; -$$; - -COMMENT ON FUNCTION public.user_has_role_in_app(uuid, uuid) IS - 'Checks whether a user has a role in an app (bypasses RLS to avoid recursion).'; - --- Helper function to check if a user has app.update_user_roles permission -CREATE OR REPLACE FUNCTION public.user_has_app_update_user_roles(p_user_id uuid, p_app_id uuid) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -STABLE -SET search_path = '' -AS $$ -DECLARE - v_app_id_varchar text; - v_org_id uuid; - v_caller_id uuid := auth.uid(); -BEGIN - IF v_caller_id IS NULL THEN - RETURN false; - END IF; - - -- Fetch app_id varchar and org_id from apps table - SELECT app_id, owner_org INTO v_app_id_varchar, v_org_id - FROM public.apps - WHERE id = p_app_id - LIMIT 1; - - IF v_app_id_varchar IS NULL OR v_org_id IS NULL THEN - RETURN false; - END IF; - - IF v_caller_id <> p_user_id THEN - IF NOT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_caller_id - AND (rb.org_id = v_org_id OR rb.app_id = p_app_id) - ) THEN - RETURN false; - END IF; - END IF; - - -- Use rbac_has_permission to check the permission - RETURN public.rbac_has_permission( - public.rbac_principal_user(), - p_user_id, - public.rbac_perm_app_update_user_roles(), - v_org_id, - v_app_id_varchar, - NULL - ); -END; -$$; - -COMMENT ON FUNCTION public.user_has_app_update_user_roles(uuid, uuid) IS - 'Checks whether a user has app.update_user_roles permission (bypasses RLS to avoid recursion).'; - -REVOKE ALL ON FUNCTION public.user_has_role_in_app(uuid, uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.user_has_role_in_app(uuid, uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.user_has_role_in_app(uuid, uuid) TO authenticated; -GRANT EXECUTE ON FUNCTION public.user_has_role_in_app(uuid, uuid) TO service_role; - -REVOKE ALL ON FUNCTION public.user_has_app_update_user_roles(uuid, uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.user_has_app_update_user_roles(uuid, uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.user_has_app_update_user_roles(uuid, uuid) TO authenticated; -GRANT EXECUTE ON FUNCTION public.user_has_app_update_user_roles(uuid, uuid) TO service_role; - --- Policy SELECT: check admin rights or role in the app -CREATE POLICY "Allow viewing role bindings with permission" -ON public.role_bindings -FOR SELECT -TO authenticated -USING ( - -- Org admins can see all bindings in their org - public.is_user_org_admin(auth.uid(), org_id) - OR - -- App admins can see bindings for their apps - (scope_type = public.rbac_scope_app() AND public.is_user_app_admin(auth.uid(), app_id)) - OR - -- Users with a role in the app can see other app members - (scope_type = public.rbac_scope_app() AND app_id IS NOT NULL AND public.user_has_role_in_app(auth.uid(), app_id)) -); - -COMMENT ON POLICY "Allow viewing role bindings with permission" ON public.role_bindings IS - 'Allows viewing role bindings if the user is admin or has a role in the app.'; - --- Policy DELETE: use helper functions to avoid recursion -CREATE POLICY "Allow admins to delete manageable role bindings" -ON public.role_bindings -FOR DELETE -TO authenticated -USING ( - -- Users with app.update_user_roles can delete bindings for the app - (scope_type = public.rbac_scope_app() AND public.user_has_app_update_user_roles(auth.uid(), app_id)) - OR - -- Users can remove themselves from an app - (scope_type = public.rbac_scope_app() AND principal_type = public.rbac_principal_user() AND principal_id = auth.uid()) -); - -COMMENT ON POLICY "Allow admins to delete manageable role bindings" ON public.role_bindings IS - 'Allows users with app.update_user_roles permission and the user themselves to delete role bindings.'; - --- ============================================================================= --- RPCs for RBAC Member Management --- ============================================================================= - --- Function to get org members with their RBAC roles -CREATE OR REPLACE FUNCTION "public"."get_org_members_rbac"(p_org_id uuid) -RETURNS TABLE ( - user_id uuid, - email character varying, - image_url character varying, - role_name text, - role_id uuid, - binding_id uuid, - granted_at timestamptz -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- Check if user has permission to view org members - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_read(), auth.uid(), p_org_id, NULL, NULL) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_MEMBERS'; - END IF; - - -- Return org members with their RBAC roles - RETURN QUERY - SELECT - u.id as user_id, - u.email, - u.image_url, - r.name as role_name, - rb.role_id, - rb.id as binding_id, - rb.granted_at - FROM public.users u - INNER JOIN public.role_bindings rb ON rb.principal_id = u.id - AND rb.principal_type = public.rbac_principal_user() - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE r.scope_type = public.rbac_scope_org() AND r.name LIKE 'org_%' - ORDER BY - CASE r.name - WHEN public.rbac_role_org_super_admin() THEN 1 - WHEN public.rbac_role_org_admin() THEN 2 - WHEN public.rbac_role_org_billing_admin() THEN 3 - WHEN public.rbac_role_org_member() THEN 4 - ELSE 5 - END, - u.email; -END; -$$; - -ALTER FUNCTION "public"."get_org_members_rbac"(uuid) OWNER TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."get_org_members_rbac"(uuid) TO "authenticated"; - -COMMENT ON FUNCTION "public"."get_org_members_rbac"(uuid) IS - 'Returns organization members with their RBAC roles. Requires org.read permission.'; - --- Function to update an org member's role -CREATE OR REPLACE FUNCTION "public"."update_org_member_role"( - p_org_id uuid, - p_user_id uuid, - p_new_role_name text -) -RETURNS text -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_new_role_id uuid; - v_existing_binding_id uuid; - v_org_created_by uuid; - v_role_family text; -BEGIN - -- Check if user has permission to update roles - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), p_org_id, NULL, NULL) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; - END IF; - - -- Get org owner to prevent removing the last super admin - SELECT created_by INTO v_org_created_by - FROM public.orgs - WHERE id = p_org_id; - - -- Prevent changing the org owner's role - IF p_user_id = v_org_created_by THEN - RAISE EXCEPTION 'CANNOT_CHANGE_OWNER_ROLE'; - END IF; - - -- Validate the new role exists and is an org-level role - SELECT r.id, r.scope_type INTO v_new_role_id, v_role_family - FROM public.roles r - WHERE r.name = p_new_role_name - LIMIT 1; - - IF v_new_role_id IS NULL THEN - RAISE EXCEPTION 'ROLE_NOT_FOUND'; - END IF; - - IF v_role_family != public.rbac_scope_org() THEN - RAISE EXCEPTION 'ROLE_MUST_BE_ORG_LEVEL'; - END IF; - - -- Check if changing from super_admin and if this is the last super_admin - IF EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_id = p_user_id - AND rb.principal_type = public.rbac_principal_user() - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - AND r.name = public.rbac_role_org_super_admin() - ) THEN - -- Count super admins in this org - IF ( - SELECT COUNT(*) - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - AND rb.principal_type = public.rbac_principal_user() - AND r.name = public.rbac_role_org_super_admin() - ) <= 1 AND p_new_role_name != public.rbac_role_org_super_admin() THEN - RAISE EXCEPTION 'CANNOT_REMOVE_LAST_SUPER_ADMIN'; - END IF; - END IF; - - -- Find existing role binding for this user at org level - SELECT rb.id INTO v_existing_binding_id - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_id = p_user_id - AND rb.principal_type = public.rbac_principal_user() - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - AND r.scope_type = public.rbac_scope_org() - LIMIT 1; - - -- Delete existing org-level role binding if it exists - IF v_existing_binding_id IS NOT NULL THEN - DELETE FROM public.role_bindings - WHERE id = v_existing_binding_id; - END IF; - - -- Create new role binding - INSERT INTO public.role_bindings ( - principal_type, - principal_id, - role_id, - scope_type, - org_id, - app_id, - channel_id, - granted_by, - granted_at, - reason, - is_direct - ) VALUES ( - public.rbac_principal_user(), - p_user_id, - v_new_role_id, - public.rbac_scope_org(), - p_org_id, - NULL, - NULL, - auth.uid(), - NOW(), - 'Role updated via update_org_member_role', - true - ); - - RETURN 'OK'; -END; -$$; - --- Function to delete an org member's role with RBAC constraints -CREATE OR REPLACE FUNCTION "public"."delete_org_member_role"( - p_org_id uuid, - p_user_id uuid -) -RETURNS text -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_existing_binding_id uuid; - v_org_created_by uuid; -BEGIN - -- Check if user has permission to update roles - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), p_org_id, NULL, NULL) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; - END IF; - - -- Get org owner to prevent removing the last super admin - SELECT created_by INTO v_org_created_by - FROM public.orgs - WHERE id = p_org_id; - - -- Prevent removing the org owner - IF p_user_id = v_org_created_by THEN - RAISE EXCEPTION 'CANNOT_CHANGE_OWNER_ROLE'; - END IF; - - -- Check if removing a super_admin and if this is the last super_admin - IF EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_id = p_user_id - AND rb.principal_type = public.rbac_principal_user() - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - AND r.name = public.rbac_role_org_super_admin() - ) THEN - -- Count super admins in this org - IF ( - SELECT COUNT(*) - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - AND rb.principal_type = public.rbac_principal_user() - AND r.name = public.rbac_role_org_super_admin() - ) <= 1 THEN - RAISE EXCEPTION 'CANNOT_REMOVE_LAST_SUPER_ADMIN'; - END IF; - END IF; - - -- Find existing role binding for this user at org level - SELECT rb.id INTO v_existing_binding_id - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_id = p_user_id - AND rb.principal_type = public.rbac_principal_user() - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - AND r.scope_type = public.rbac_scope_org() - LIMIT 1; - - -- Delete existing org-level role binding if it exists - IF v_existing_binding_id IS NOT NULL THEN - DELETE FROM public.role_bindings - WHERE id = v_existing_binding_id; - END IF; - - RETURN 'OK'; -END; -$$; - -ALTER FUNCTION "public"."delete_org_member_role"(uuid, uuid) OWNER TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."delete_org_member_role"(uuid, uuid) TO "authenticated"; - -COMMENT ON FUNCTION "public"."delete_org_member_role"(uuid, uuid) IS - 'Deletes an organization member''s role. Requires org.update_user_roles permission. Returns OK on success.'; - - -ALTER FUNCTION "public"."update_org_member_role"(uuid, uuid, text) OWNER TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."update_org_member_role"(uuid, uuid, text) TO "authenticated"; - -COMMENT ON FUNCTION "public"."update_org_member_role"(uuid, uuid, text) IS - 'Updates an organization member''s role. Requires org.update_user_roles permission. Returns OK on success.'; - --- ===================================================== --- Migration: Replace role_bindings_view with secure RPCs --- ===================================================== - --- Function to get app access (replaces role_bindings_view for AccessTable) -CREATE OR REPLACE FUNCTION "public"."get_app_access_rbac"(p_app_id uuid) -RETURNS TABLE ( - id uuid, - principal_type text, - principal_id uuid, - principal_name text, - role_id uuid, - role_name text, - role_description text, - granted_at timestamptz, - granted_by uuid, - expires_at timestamptz, - reason text, - is_direct boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_org_id uuid; - v_app_id_string text; -BEGIN - -- Get org_id and app_id string from app - SELECT a.owner_org, a.app_id INTO v_org_id, v_app_id_string - FROM public.apps a - WHERE a.id = p_app_id; - - IF v_org_id IS NULL THEN - RAISE EXCEPTION 'APP_NOT_FOUND'; - END IF; - - -- Check if user has permission to view app access - IF NOT public.rbac_check_permission_direct(public.rbac_perm_app_read(), auth.uid(), v_org_id, v_app_id_string, NULL::bigint) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_ACCESS'; - END IF; - - -- Return app access with enriched data - RETURN QUERY - SELECT - rb.id, - rb.principal_type, - rb.principal_id, - CASE - WHEN rb.principal_type = public.rbac_principal_user() THEN u.email - WHEN rb.principal_type = public.rbac_principal_group() THEN g.name - ELSE rb.principal_id::text - END as principal_name, - rb.role_id, - r.name as role_name, - r.description as role_description, - rb.granted_at, - rb.granted_by, - rb.expires_at, - rb.reason, - rb.is_direct - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - LEFT JOIN public.users u ON rb.principal_type = public.rbac_principal_user() AND rb.principal_id = u.id - LEFT JOIN public.groups g ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = g.id - WHERE rb.scope_type = public.rbac_scope_app() - AND rb.app_id = p_app_id - ORDER BY rb.granted_at DESC; -END; -$$; - -ALTER FUNCTION "public"."get_app_access_rbac"(uuid) OWNER TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."get_app_access_rbac"(uuid) TO "authenticated"; - -COMMENT ON FUNCTION "public"."get_app_access_rbac"(uuid) IS - 'Retrieves all access bindings for an app with permission checks. Requires app.read permission.'; - -CREATE OR REPLACE FUNCTION "public"."get_org_user_access_rbac"(p_user_id uuid, p_org_id uuid) -RETURNS TABLE ( - id uuid, - principal_type text, - principal_id uuid, - role_id uuid, - role_name text, - role_description text, - scope_type text, - org_id uuid, - app_id uuid, - channel_id uuid, - granted_at timestamptz, - granted_by uuid, - expires_at timestamptz, - reason text, - is_direct boolean, - principal_name text, - user_email text, - group_name text -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- Check if user has permission to view org or if it's their own bindings - IF auth.uid() != p_user_id AND NOT public.rbac_check_permission_direct(public.rbac_perm_org_read(), auth.uid(), p_org_id, NULL::text, NULL::bigint) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_BINDINGS'; - END IF; - - -- Return user's org bindings with enriched data - RETURN QUERY - SELECT - rb.id, - rb.principal_type, - rb.principal_id, - rb.role_id, - r.name as role_name, - r.description as role_description, - rb.scope_type, - rb.org_id, - rb.app_id, - rb.channel_id, - rb.granted_at, - rb.granted_by, - rb.expires_at, - rb.reason, - rb.is_direct, - CASE - WHEN rb.principal_type = public.rbac_principal_user() THEN u.email::text - WHEN rb.principal_type = public.rbac_principal_group() THEN g.name::text - ELSE rb.principal_id::text - END as principal_name, - u.email::text as user_email, - g.name::text as group_name - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - LEFT JOIN public.users u ON rb.principal_type = public.rbac_principal_user() AND rb.principal_id = u.id - LEFT JOIN public.groups g ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = g.id - WHERE rb.org_id = p_org_id - AND rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - ORDER BY rb.granted_at DESC; -END; -$$; - - --- ============================================================================= --- rbac_check_permission_direct: Check RBAC permission with automatic legacy fallback --- ============================================================================= --- This function is the primary entry point for permission checks from application code. --- It routes between RBAC and legacy systems based on the org's feature flag. --- --- When RBAC is enabled: Uses rbac_has_permission directly with the provided permission key --- When RBAC is disabled: Maps the permission to a legacy min_right and uses check_min_rights_legacy --- --- Parameters: --- p_permission_key: RBAC permission (e.g., public.rbac_perm_app_upload_bundle(), public.rbac_perm_channel_promote_bundle()) --- p_user_id: The user to check permissions for --- p_org_id: Organization ID (can be NULL if derivable from app/channel) --- p_app_id: App ID (varchar, e.g., 'com.example.app') --- p_channel_id: Channel ID (bigint) --- p_apikey: Optional API key string for apikey-based permission checks - -CREATE OR REPLACE FUNCTION public.rbac_check_permission_direct( - p_permission_key text, - p_user_id uuid, - p_org_id uuid, - p_app_id character varying, - p_channel_id bigint, - p_apikey text DEFAULT NULL -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; -BEGIN - -- Validate permission key - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - -- Derive org from app/channel when not provided - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF v_effective_org_id IS NULL AND p_channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - END IF; - - -- Check if RBAC is enabled for this org - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - -- RBAC path: Check user permission directly - IF p_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), p_user_id, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - END IF; - - -- If user doesn't have permission, check apikey permission - IF NOT v_allowed AND p_apikey IS NOT NULL THEN - SELECT rbac_id INTO v_apikey_principal - FROM public.apikeys - WHERE key = p_apikey - LIMIT 1; - - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', p_user_id, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - ELSE - -- Legacy path: Map permission to min_right and use legacy check - -- Determine scope from permission prefix - -- Map permission to legacy right using reverse lookup - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - -- Unknown permission in legacy mode, deny by default - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', p_user_id - )); - RETURN false; - END IF; - - -- Use appropriate legacy check based on context - IF p_apikey IS NOT NULL AND p_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, p_user_id, p_apikey); - ELSIF p_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(p_app_id, v_legacy_right, p_user_id); - ELSE - RETURN public.check_min_rights_legacy(v_legacy_right, p_user_id, v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - -COMMENT ON FUNCTION public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, text) IS - 'Direct RBAC permission check with automatic legacy fallback based on org feature flag. Use this from application code for explicit permission checks.'; - --- ============================================================================= --- rbac_check_permission: Public wrapper for authenticated users --- ============================================================================= --- Uses auth.uid() and delegates to rbac_check_permission_direct. - -CREATE OR REPLACE FUNCTION public.rbac_check_permission( - p_permission_key text, - p_org_id uuid DEFAULT NULL, - p_app_id character varying DEFAULT NULL, - p_channel_id bigint DEFAULT NULL -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -BEGIN - IF auth.uid() IS NULL THEN - RETURN false; - END IF; - - RETURN public.rbac_check_permission_direct( - p_permission_key, - auth.uid(), - p_org_id, - p_app_id, - p_channel_id, - NULL - ); -END; -$$; - -COMMENT ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) IS - 'Public RBAC permission check for authenticated users. Uses auth.uid() and delegates to rbac_check_permission_direct.'; - --- ============================================================================= --- rbac_legacy_right_for_permission: Reverse mapping from permission to legacy min_right --- ============================================================================= --- This is the inverse of rbac_permission_for_legacy, used when we need to fall back --- to legacy checks but have a permission key. - -CREATE OR REPLACE FUNCTION public.rbac_legacy_right_for_permission( - p_permission_key text -) RETURNS public.user_min_right -LANGUAGE plpgsql -SET search_path = '' -IMMUTABLE AS $$ -BEGIN - -- Map permissions to their legacy equivalents - -- This mapping should match PERMISSION_TO_LEGACY_RIGHT in utils/rbac.ts - CASE p_permission_key - -- Read permissions -> public.rbac_right_read() - WHEN public.rbac_perm_org_read() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_org_read_members() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read_bundles() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read_channels() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read_logs() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read_devices() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_channel_read() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_channel_read_history() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_channel_read_forced_devices() THEN RETURN public.rbac_right_read(); - - -- Upload permissions -> public.rbac_right_upload() - WHEN public.rbac_perm_app_upload_bundle() THEN RETURN public.rbac_right_upload(); - - -- Write permissions -> public.rbac_right_write() - WHEN public.rbac_perm_app_update_settings() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_app_create_channel() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_app_manage_devices() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_app_build_native() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_channel_update_settings() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_channel_promote_bundle() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_channel_rollback_bundle() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_channel_manage_forced_devices() THEN RETURN public.rbac_right_write(); - - -- Admin permissions -> public.rbac_right_admin() - WHEN public.rbac_perm_org_update_settings() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_org_invite_user() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_org_read_billing() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_org_read_invoices() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_org_read_audit() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_app_delete() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_app_read_audit() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_bundle_delete() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_channel_delete() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_channel_read_audit() THEN RETURN public.rbac_right_admin(); - - -- Super admin permissions -> public.rbac_right_super_admin() - WHEN public.rbac_perm_org_update_user_roles() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_org_update_billing() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_org_read_billing_audit() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_org_delete() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_app_transfer() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_impersonate_user() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_manage_orgs_any() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_manage_apps_any() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_manage_channels_any() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_run_maintenance_jobs() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_delete_orphan_users() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_read_all_audit() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_db_break_glass() THEN RETURN public.rbac_right_super_admin(); - - ELSE RETURN NULL; -- Unknown permission - END CASE; -END; -$$; - -COMMENT ON FUNCTION public.rbac_legacy_right_for_permission(text) IS - 'Maps RBAC permission keys to legacy user_min_right values for fallback checks.'; - --- Grant execute permissions for new functions -REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, text) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, text) FROM anon; -REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, text) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, text) TO service_role; -GRANT EXECUTE ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_legacy_right_for_permission(text) TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_legacy_right_for_permission(text) TO service_role; - --- 17) Update transfer_app to use RBAC -CREATE OR REPLACE FUNCTION "public"."transfer_app"("p_app_id" character varying, "p_new_org_id" "uuid") RETURNS "void" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_old_org_id uuid; - v_user_id uuid; - v_last_transfer jsonb; - v_last_transfer_date timestamp; -BEGIN - SELECT owner_org, transfer_history[array_length(transfer_history, 1)] - INTO v_old_org_id, v_last_transfer - FROM public.apps - WHERE app_id = p_app_id; - - IF v_old_org_id IS NULL THEN - RAISE EXCEPTION 'App % not found', p_app_id; - END IF; - - v_user_id := (SELECT auth.uid()); - - IF NOT public.rbac_check_permission(public.rbac_perm_app_transfer(), v_old_org_id, p_app_id, NULL::bigint) THEN - PERFORM public.pg_log('deny: TRANSFER_OLD_ORG_RIGHTS', jsonb_build_object('app_id', p_app_id, 'old_org_id', v_old_org_id, 'new_org_id', p_new_org_id, 'uid', v_user_id)); - RAISE EXCEPTION 'You are not authorized to transfer this app. (No transfer permission on the source organization)'; - END IF; - - IF NOT public.rbac_check_permission(public.rbac_perm_app_transfer(), p_new_org_id, NULL::character varying, NULL::bigint) THEN - PERFORM public.pg_log('deny: TRANSFER_NEW_ORG_RIGHTS', jsonb_build_object('app_id', p_app_id, 'old_org_id', v_old_org_id, 'new_org_id', p_new_org_id, 'uid', v_user_id)); - RAISE EXCEPTION 'You are not authorized to transfer this app. (No transfer permission on the destination organization)'; - END IF; - - IF v_last_transfer IS NOT NULL THEN - v_last_transfer_date := (v_last_transfer->>'transferred_at')::timestamp; - IF v_last_transfer_date + interval '32 days' > now() THEN - RAISE EXCEPTION 'Cannot transfer app. Must wait at least 32 days between transfers. Last transfer was on %', v_last_transfer_date; - END IF; - END IF; - - UPDATE public.apps - SET - owner_org = p_new_org_id, - updated_at = now(), - transfer_history = COALESCE(transfer_history, '{}') || jsonb_build_object( - 'transferred_at', now(), - 'transferred_from', v_old_org_id, - 'transferred_to', p_new_org_id, - 'initiated_by', v_user_id - )::jsonb - WHERE app_id = p_app_id; - - UPDATE public.app_versions - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.app_versions_meta - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channel_devices - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channels - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - -END; -$$; - -COMMENT ON FUNCTION "public"."transfer_app"("p_app_id" character varying, "p_new_org_id" "uuid") IS 'Transfers an app and all its related data to a new organization. Requires app.transfer permission on both source and destination organizations.'; diff --git a/supabase/migrations/20251223234326_fix_duplicate_overage_tracking.sql b/supabase/migrations/20251223234326_fix_duplicate_overage_tracking.sql deleted file mode 100644 index e35bc0619c..0000000000 --- a/supabase/migrations/20251223234326_fix_duplicate_overage_tracking.sql +++ /dev/null @@ -1,290 +0,0 @@ --- Fix duplicate overage tracking issue --- Problem: apply_usage_overage creates a new record every time it's called, --- even when there are no credits available and the overage hasn't changed. --- This leads to hundreds of duplicate records with credits_debited=0. - -BEGIN; - -CREATE OR REPLACE FUNCTION "public"."apply_usage_overage"( - "p_org_id" "uuid", - "p_metric" "public"."credit_metric_type", - "p_overage_amount" numeric, - "p_billing_cycle_start" timestamp with time zone, - "p_billing_cycle_end" timestamp with time zone, - "p_details" "jsonb" DEFAULT NULL::"jsonb" -) RETURNS TABLE( - "overage_amount" numeric, - "credits_required" numeric, - "credits_applied" numeric, - "credits_remaining" numeric, - "credit_step_id" bigint, - "overage_covered" numeric, - "overage_unpaid" numeric, - "overage_event_id" "uuid" -) -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_calc RECORD; - v_event_id uuid; - v_remaining numeric := 0; - v_applied numeric := 0; - v_per_unit numeric := 0; - v_available numeric; - v_use numeric; - v_balance numeric; - v_overage_paid numeric := 0; - v_existing_credits_debited numeric := 0; - v_required numeric := 0; - v_credits_to_apply numeric := 0; - v_credits_available numeric := 0; - v_latest_event_id uuid; - v_latest_overage_amount numeric; - v_needs_new_record boolean := false; - grant_rec public.usage_credit_grants%ROWTYPE; -BEGIN - -- Early exit for invalid input - IF p_overage_amount IS NULL OR p_overage_amount <= 0 THEN - RETURN QUERY SELECT 0::numeric, 0::numeric, 0::numeric, 0::numeric, NULL::bigint, 0::numeric, 0::numeric, NULL::uuid; - RETURN; - END IF; - - -- Calculate credit cost for this overage - SELECT * - INTO v_calc - FROM public.calculate_credit_cost(p_metric, p_overage_amount) - LIMIT 1; - - -- If no pricing step found, create a single record and exit - IF v_calc.credit_step_id IS NULL THEN - -- Check if we already have a record for this cycle with NULL step - SELECT uoe.id, uoe.overage_amount INTO v_latest_event_id, v_latest_overage_amount - FROM public.usage_overage_events uoe - WHERE uoe.org_id = p_org_id - AND uoe.metric = p_metric - AND uoe.credit_step_id IS NULL - AND (uoe.billing_cycle_start IS NOT DISTINCT FROM p_billing_cycle_start::date) - AND (uoe.billing_cycle_end IS NOT DISTINCT FROM p_billing_cycle_end::date) - ORDER BY uoe.created_at DESC - LIMIT 1; - - -- Only create new record if overage amount changed significantly (more than 1% or first record) - IF v_latest_event_id IS NULL OR ABS(v_latest_overage_amount - p_overage_amount) / NULLIF(v_latest_overage_amount, 0) > 0.01 THEN - INSERT INTO public.usage_overage_events ( - org_id, - metric, - overage_amount, - credits_estimated, - credits_debited, - credit_step_id, - billing_cycle_start, - billing_cycle_end, - details - ) - VALUES ( - p_org_id, - p_metric, - p_overage_amount, - 0, - 0, - NULL, - p_billing_cycle_start, - p_billing_cycle_end, - p_details - ) - RETURNING id INTO v_event_id; - ELSE - -- Reuse existing event - v_event_id := v_latest_event_id; - END IF; - - RETURN QUERY SELECT p_overage_amount, 0::numeric, 0::numeric, 0::numeric, NULL::bigint, 0::numeric, p_overage_amount, v_event_id; - RETURN; - END IF; - - v_per_unit := v_calc.credit_cost_per_unit; - v_required := v_calc.credits_required; - - -- Get the most recent event for this cycle - SELECT uoe.id, uoe.overage_amount - INTO v_latest_event_id, v_latest_overage_amount - FROM public.usage_overage_events uoe - WHERE uoe.org_id = p_org_id - AND uoe.metric = p_metric - AND (uoe.billing_cycle_start IS NOT DISTINCT FROM p_billing_cycle_start::date) - AND (uoe.billing_cycle_end IS NOT DISTINCT FROM p_billing_cycle_end::date) - ORDER BY uoe.created_at DESC - LIMIT 1; - - -- Calculate how many credits we can still try to apply - -- Use credits_debited for this since it reflects actual consumption - SELECT COALESCE(SUM(credits_debited), 0) - INTO v_existing_credits_debited - FROM public.usage_overage_events - WHERE org_id = p_org_id - AND metric = p_metric - AND (billing_cycle_start IS NOT DISTINCT FROM p_billing_cycle_start::date) - AND (billing_cycle_end IS NOT DISTINCT FROM p_billing_cycle_end::date); - - v_credits_to_apply := GREATEST(v_required - v_existing_credits_debited, 0); - v_remaining := v_credits_to_apply; - - -- Check if there are any credits available in grants - SELECT COALESCE(SUM(GREATEST(credits_total - credits_consumed, 0)), 0) - INTO v_credits_available - FROM public.usage_credit_grants - WHERE org_id = p_org_id - AND expires_at >= NOW(); - - -- Determine if we need a new record: - -- 1. No existing record for this cycle (first overage) - -- 2. Overage amount changed significantly (more than 1%) - -- 3. We have NEW credits available AND we need to apply them - v_needs_new_record := v_latest_event_id IS NULL - OR (v_latest_overage_amount IS NOT NULL - AND ABS(v_latest_overage_amount - p_overage_amount) / NULLIF(v_latest_overage_amount, 0) > 0.01) - OR (v_credits_to_apply > 0 AND v_credits_available > 0 AND v_existing_credits_debited = 0); - - -- Only create new record if needed - IF v_needs_new_record THEN - INSERT INTO public.usage_overage_events ( - org_id, - metric, - overage_amount, - credits_estimated, - credits_debited, - credit_step_id, - billing_cycle_start, - billing_cycle_end, - details - ) - VALUES ( - p_org_id, - p_metric, - p_overage_amount, - v_required, - 0, - v_calc.credit_step_id, - p_billing_cycle_start, - p_billing_cycle_end, - COALESCE(p_details, '{}'::jsonb) || jsonb_build_object( - 'credits_available', v_credits_available, - 'credits_to_apply', v_credits_to_apply, - 'debit_status', CASE - WHEN v_credits_available = 0 THEN 'no_grants_available' - WHEN v_credits_to_apply = 0 THEN 'already_debited' - ELSE 'pending_debit' - END - ) - ) - RETURNING id INTO v_event_id; - - -- Apply credits from available grants if any - IF v_credits_to_apply > 0 THEN - FOR grant_rec IN - SELECT * - FROM public.usage_credit_grants - WHERE org_id = p_org_id - AND expires_at >= NOW() - AND credits_consumed < credits_total - ORDER BY expires_at ASC, granted_at ASC - FOR UPDATE - LOOP - EXIT WHEN v_remaining <= 0; - - v_available := grant_rec.credits_total - grant_rec.credits_consumed; - IF v_available <= 0 THEN - CONTINUE; - END IF; - - v_use := LEAST(v_available, v_remaining); - v_remaining := v_remaining - v_use; - v_applied := v_applied + v_use; - - UPDATE public.usage_credit_grants - SET credits_consumed = credits_consumed + v_use - WHERE id = grant_rec.id; - - INSERT INTO public.usage_credit_consumptions ( - grant_id, - org_id, - overage_event_id, - metric, - credits_used - ) - VALUES ( - grant_rec.id, - p_org_id, - v_event_id, - p_metric, - v_use - ); - - SELECT COALESCE(SUM(GREATEST(credits_total - credits_consumed, 0)), 0) - INTO v_balance - FROM public.usage_credit_grants - WHERE org_id = p_org_id - AND expires_at >= NOW(); - - INSERT INTO public.usage_credit_transactions ( - org_id, - grant_id, - transaction_type, - amount, - balance_after, - occurred_at, - description, - source_ref - ) - VALUES ( - p_org_id, - grant_rec.id, - 'deduction', - -v_use, - v_balance, - NOW(), - format('Overage deduction for %s usage', p_metric::text), - jsonb_build_object('overage_event_id', v_event_id, 'metric', p_metric::text) - ); - END LOOP; - - -- Update the event with actual credits applied - UPDATE public.usage_overage_events - SET - credits_debited = v_applied, - details = COALESCE(details, '{}'::jsonb) || jsonb_build_object( - 'credits_actually_applied', v_applied, - 'debit_status', CASE - WHEN v_applied >= v_credits_to_apply THEN 'fully_debited' - WHEN v_applied > 0 THEN 'partially_debited' - ELSE 'no_debit' - END - ) - WHERE id = v_event_id; - END IF; - ELSE - -- Reuse latest event ID, no new record needed - v_event_id := v_latest_event_id; - END IF; - - -- Calculate how much overage is covered by credits - IF v_per_unit > 0 THEN - v_overage_paid := LEAST(p_overage_amount, (v_applied + v_existing_credits_debited) / v_per_unit); - ELSE - v_overage_paid := p_overage_amount; - END IF; - - RETURN QUERY SELECT - p_overage_amount, - v_required, - v_applied, - GREATEST(v_required - v_existing_credits_debited - v_applied, 0), - v_calc.credit_step_id, - v_overage_paid, - GREATEST(p_overage_amount - v_overage_paid, 0), - v_event_id; -END; -$$; - -COMMIT; diff --git a/supabase/migrations/20251224103713_2fa_enforcement.sql b/supabase/migrations/20251224103713_2fa_enforcement.sql deleted file mode 100644 index 4c1e1ba8e1..0000000000 --- a/supabase/migrations/20251224103713_2fa_enforcement.sql +++ /dev/null @@ -1,615 +0,0 @@ --- ============================================================================ --- Section 1: has_2fa_enabled functions --- ============================================================================ - --- Function to check if the current user has 2FA enabled -CREATE OR REPLACE FUNCTION "public"."has_2fa_enabled"() RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - -- Check if the current user has any verified MFA factors - RETURN EXISTS( - SELECT 1 - FROM auth.mfa_factors - WHERE (SELECT auth.uid()) = user_id - AND status = 'verified' - ); -END; -$$; - -ALTER FUNCTION "public"."has_2fa_enabled"() OWNER TO "postgres"; - --- Function to check if a specific user has 2FA enabled --- This function is SECURITY DEFINER to allow backend/service_role access only -CREATE OR REPLACE FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - -- Check if the specified user has any verified MFA factors - RETURN EXISTS( - SELECT 1 - FROM auth.mfa_factors mfa - WHERE mfa.user_id = has_2fa_enabled.user_id - AND mfa.status = 'verified' - ); -END; -$$; - -ALTER FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") OWNER TO "postgres"; - --- Grant permissions --- The no-argument version should be accessible to authenticated users -GRANT EXECUTE ON FUNCTION "public"."has_2fa_enabled"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."has_2fa_enabled"() TO "anon"; - --- The user_id version should only be accessible to service_role and postgres (backend) --- Revoke all permissions from PUBLIC, anon, and authenticated first -REVOKE ALL ON FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") -FROM - PUBLIC; - -REVOKE ALL ON FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") -FROM - "anon"; - -REVOKE ALL ON FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") -FROM - "authenticated"; - --- Grant execution permission only to postgres and service_role -GRANT EXECUTE ON FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") TO "service_role"; - --- ============================================================================ --- Section 2: check_org_members_2fa_enabled function --- ============================================================================ - --- Function to check 2FA status for all members of an organization --- This function is accessible only to super_admins of the organization -CREATE OR REPLACE FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uuid") - RETURNS TABLE ( - "user_id" "uuid", - "2fa_enabled" boolean - ) - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - -- Check if org exists - IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = check_org_members_2fa_enabled.org_id) THEN - RAISE EXCEPTION 'Organization does not exist'; - END IF; - - -- Check if the current user is a super_admin of the organization - IF NOT ( - public.check_min_rights( - 'super_admin'::public.user_min_right, - (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], check_org_members_2fa_enabled.org_id)), - check_org_members_2fa_enabled.org_id, - NULL::character varying, - NULL::bigint - ) - ) THEN - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - -- Return list of org members with their 2FA status - RETURN QUERY - SELECT - ou.user_id, - COALESCE(public.has_2fa_enabled(ou.user_id), false) AS "2fa_enabled" - FROM public.org_users ou - WHERE ou.org_id = check_org_members_2fa_enabled.org_id; -END; -$$; - -ALTER FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uuid") OWNER TO "postgres"; - --- Grant permissions - accessible to authenticated users (permission check is inside the function) -GRANT EXECUTE ON FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uuid") TO "service_role"; - --- ============================================================================ --- Section 3: Add enforcing_2fa column to orgs table --- ============================================================================ - --- Add enforcing_2fa boolean column to orgs table (defaults to false) -ALTER TABLE "public"."orgs" -ADD COLUMN IF NOT EXISTS "enforcing_2fa" boolean NOT NULL DEFAULT false; - --- Add comment to document the column -COMMENT ON COLUMN "public"."orgs"."enforcing_2fa" IS 'When true, all members of this organization must have 2FA enabled to access the organization'; - --- ============================================================================ --- Section 4: Modify check_min_rights to enforce 2FA --- ============================================================================ - --- Modify check_min_rights to check 2FA enforcement rules --- If org has enforcing_2fa enabled and user doesn't have 2FA, deny access -CREATE OR REPLACE FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - user_right_record RECORD; - org_enforcing_2fa boolean; -BEGIN - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_NO_UID', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text)); - RETURN false; - END IF; - - -- Check if org has 2FA enforcement enabled - SELECT enforcing_2fa INTO org_enforcing_2fa - FROM public.orgs - WHERE public.orgs.id = check_min_rights.org_id; - - -- If org enforces 2FA and user doesn't have 2FA enabled, deny access - IF org_enforcing_2fa = true AND NOT public.has_2fa_enabled(user_id) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); - RETURN false; - END IF; - - FOR user_right_record IN - SELECT org_users.user_right, org_users.app_id, org_users.channel_id - FROM public.org_users - WHERE org_users.org_id = check_min_rights.org_id AND org_users.user_id = check_min_rights.user_id - LOOP - IF (user_right_record.user_right >= min_right AND user_right_record.app_id IS NULL AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights.app_id AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights.app_id AND user_right_record.channel_id = check_min_rights.channel_id) - THEN - RETURN true; - END IF; - END LOOP; - - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); - RETURN false; -END; -$$; - --- ============================================================================ --- Section 4.A: Create get_orgs_v7 with 2FA enforcement --- ============================================================================ - --- Create get_orgs_v7(userid uuid) - adds enforcing_2fa and 2fa_has_access fields --- Redacts sensitive information when user doesn't have 2FA access -CREATE FUNCTION public.get_orgs_v7(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - -- Calculate current billing cycle for each org - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - -- 2fa_has_access: true if enforcing_2fa is false OR (enforcing_2fa is true AND user has 2FA) - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - -- should_redact: true if org enforces 2FA and user doesn't have 2FA - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- Redact sensitive fields if user doesn't have 2FA access - CASE - WHEN tfa.should_redact THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access" - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "anon"; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "authenticated"; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO "postgres"; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO "service_role"; - --- Create get_orgs_v7() - wrapper function -CREATE OR REPLACE FUNCTION public.get_orgs_v7() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.apikeys WHERE key = api_key_text INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "anon"; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "authenticated"; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "service_role"; - --- ============================================================================ --- Section 4.B: Modify get_orgs_v6 to prevent information leakage --- ============================================================================ - --- Modify get_orgs_v6(userid uuid) to redact sensitive information when user doesn't have 2FA access -DROP FUNCTION IF EXISTS public.get_orgs_v6(uuid); - -CREATE FUNCTION public.get_orgs_v6(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - -- should_redact: true if org enforces 2FA and user doesn't have 2FA - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- Redact sensitive fields if user doesn't have 2FA access - CASE - WHEN tfa.should_redact THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v6(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM "anon"; -REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM "authenticated"; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v6(uuid) TO "postgres"; -GRANT EXECUTE ON FUNCTION public.get_orgs_v6(uuid) TO "service_role"; - --- ============================================================================ --- Section 5: reject_access_due_to_2fa function --- ============================================================================ - --- Function to check if access should be rejected due to 2FA enforcement --- Returns true if org requires 2FA and user doesn't have it, false otherwise --- This function is private (accessible only to backend/service_role) -CREATE OR REPLACE FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") - RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - org_enforcing_2fa boolean; -BEGIN - -- Check if org exists - IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = reject_access_due_to_2fa.org_id) THEN - RETURN false; - END IF; - - -- Check if org has 2FA enforcement enabled - SELECT enforcing_2fa INTO org_enforcing_2fa - FROM public.orgs - WHERE public.orgs.id = reject_access_due_to_2fa.org_id; - - -- 7.1 If a given org does not enable 2FA enforcement, return false - IF org_enforcing_2fa = false THEN - RETURN false; - END IF; - - -- 7.2 If a given org REQUIRES 2FA, and has_2fa_enabled(user_id) == false, return true - IF org_enforcing_2fa = true AND NOT public.has_2fa_enabled(reject_access_due_to_2fa.user_id) THEN - PERFORM public.pg_log('deny: REJECT_ACCESS_DUE_TO_2FA', jsonb_build_object('org_id', org_id, 'user_id', user_id)); - RETURN true; - END IF; - - -- 7.3 Otherwise, return false - RETURN false; -END; -$$; - -ALTER FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") OWNER TO "postgres"; - --- Revoke all permissions from PUBLIC, anon, and authenticated (private function) -REVOKE ALL ON FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") -FROM - PUBLIC; - -REVOKE ALL ON FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") -FROM - "anon"; - -REVOKE ALL ON FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") -FROM - "authenticated"; - --- Grant execution permission only to postgres and service_role -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") TO "service_role"; diff --git a/supabase/migrations/20251226120000_add_channel_allow_device_prod.sql b/supabase/migrations/20251226120000_add_channel_allow_device_prod.sql deleted file mode 100644 index d644f9f3d8..0000000000 --- a/supabase/migrations/20251226120000_add_channel_allow_device_prod.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE public.channels -ADD COLUMN IF NOT EXISTS allow_device boolean NOT NULL DEFAULT true; - -ALTER TABLE public.channels -ADD COLUMN IF NOT EXISTS allow_prod boolean NOT NULL DEFAULT true; diff --git a/supabase/migrations/20251226121000_add_channel_stats_actions.sql b/supabase/migrations/20251226121000_add_channel_stats_actions.sql deleted file mode 100644 index bbf0c82691..0000000000 --- a/supabase/migrations/20251226121000_add_channel_stats_actions.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'disableProdBuild'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'disableDevice'; diff --git a/supabase/migrations/20251226125240_audit_log.sql b/supabase/migrations/20251226125240_audit_log.sql deleted file mode 100644 index 41841103f3..0000000000 --- a/supabase/migrations/20251226125240_audit_log.sql +++ /dev/null @@ -1,556 +0,0 @@ --- Audit Log Table for tracking CRUD operations --- Tables tracked: orgs, apps, channels, app_versions, org_users - --- Create the audit_logs table -CREATE TABLE IF NOT EXISTS "public"."audit_logs" ( - "id" BIGSERIAL PRIMARY KEY, - "created_at" TIMESTAMPTZ DEFAULT NOW() NOT NULL, - "table_name" TEXT NOT NULL, - "record_id" TEXT NOT NULL, - "operation" TEXT NOT NULL, - "user_id" UUID, - "org_id" UUID NOT NULL, - "old_record" JSONB, - "new_record" JSONB, - "changed_fields" TEXT[] -); - --- Add comments -COMMENT ON TABLE "public"."audit_logs" IS 'Audit log for tracking changes to orgs, apps, channels, app_versions, and org_users tables'; -COMMENT ON COLUMN "public"."audit_logs"."table_name" IS 'Name of the table that was modified (orgs, apps, channels, app_versions, org_users)'; -COMMENT ON COLUMN "public"."audit_logs"."record_id" IS 'Primary key of the affected record'; -COMMENT ON COLUMN "public"."audit_logs"."operation" IS 'Type of operation: INSERT, UPDATE, or DELETE'; -COMMENT ON COLUMN "public"."audit_logs"."user_id" IS 'User who made the change (from auth.uid() or API key)'; -COMMENT ON COLUMN "public"."audit_logs"."org_id" IS 'Organization context for filtering'; -COMMENT ON COLUMN "public"."audit_logs"."old_record" IS 'Previous state of the record (null for INSERT)'; -COMMENT ON COLUMN "public"."audit_logs"."new_record" IS 'New state of the record (null for DELETE)'; -COMMENT ON COLUMN "public"."audit_logs"."changed_fields" IS 'Array of field names that changed (for UPDATE operations)'; - --- Add foreign key constraints for referential integrity -ALTER TABLE "public"."audit_logs" - ADD CONSTRAINT audit_logs_org_id_fkey - FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") - ON DELETE CASCADE; - -ALTER TABLE "public"."audit_logs" - ADD CONSTRAINT audit_logs_user_id_fkey - FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") - ON DELETE SET NULL; - --- Create indexes for efficient querying -CREATE INDEX idx_audit_logs_org_id ON "public"."audit_logs"("org_id"); -CREATE INDEX idx_audit_logs_table_name ON "public"."audit_logs"("table_name"); -CREATE INDEX idx_audit_logs_user_id ON "public"."audit_logs"("user_id"); -CREATE INDEX idx_audit_logs_created_at ON "public"."audit_logs"("created_at" DESC); -CREATE INDEX idx_audit_logs_org_created ON "public"."audit_logs"("org_id", "created_at" DESC); -CREATE INDEX idx_audit_logs_operation ON "public"."audit_logs"("operation"); - --- Create the audit trigger function -CREATE OR REPLACE FUNCTION "public"."audit_log_trigger"() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_old_record JSONB; - v_new_record JSONB; - v_changed_fields TEXT[]; - v_org_id UUID; - v_record_id TEXT; - v_user_id UUID; - v_key TEXT; - v_org_exists BOOLEAN; -BEGIN - -- Skip audit logging for org DELETE operations - -- When an org is deleted, we can't insert into audit_logs because the org_id - -- foreign key would reference a non-existent org - IF TG_TABLE_NAME = 'orgs' AND TG_OP = 'DELETE' THEN - RETURN OLD; - END IF; - - -- Get current user from auth context or API key - -- Uses get_identity() to support both JWT auth and API key authentication - v_user_id := public.get_identity(); - - -- Skip audit logging if no user is identified - -- We only want to log actions performed by authenticated users - IF v_user_id IS NULL THEN - RETURN COALESCE(NEW, OLD); - END IF; - - -- Convert records to JSONB based on operation type - IF TG_OP = 'DELETE' THEN - v_old_record := to_jsonb(OLD); - v_new_record := NULL; - ELSIF TG_OP = 'INSERT' THEN - v_old_record := NULL; - v_new_record := to_jsonb(NEW); - ELSE -- UPDATE - v_old_record := to_jsonb(OLD); - v_new_record := to_jsonb(NEW); - - -- Calculate changed fields by comparing old and new values - FOR v_key IN SELECT jsonb_object_keys(v_new_record) - LOOP - IF v_old_record->v_key IS DISTINCT FROM v_new_record->v_key THEN - v_changed_fields := array_append(v_changed_fields, v_key); - END IF; - END LOOP; - END IF; - - -- Get org_id and record_id based on table being modified - CASE TG_TABLE_NAME - WHEN 'orgs' THEN - v_org_id := COALESCE(NEW.id, OLD.id); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - WHEN 'apps' THEN - v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); - v_record_id := COALESCE(NEW.app_id, OLD.app_id)::TEXT; - WHEN 'channels' THEN - v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - WHEN 'app_versions' THEN - v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - WHEN 'org_users' THEN - v_org_id := COALESCE(NEW.org_id, OLD.org_id); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - ELSE - -- Fallback for any other table (shouldn't happen with current triggers) - v_org_id := NULL; - v_record_id := NULL; - END CASE; - - -- Only insert if we have a valid org_id and the org still exists - -- This handles edge cases where related tables are deleted after the org - IF v_org_id IS NOT NULL THEN - -- Check if the org still exists (important for DELETE operations on child tables) - SELECT EXISTS(SELECT 1 FROM public.orgs WHERE id = v_org_id) INTO v_org_exists; - - IF v_org_exists THEN - INSERT INTO "public"."audit_logs" ( - table_name, record_id, operation, user_id, org_id, - old_record, new_record, changed_fields - ) VALUES ( - TG_TABLE_NAME, v_record_id, TG_OP, v_user_id, v_org_id, - v_old_record, v_new_record, v_changed_fields - ); - END IF; - END IF; - - RETURN COALESCE(NEW, OLD); -END; -$$; - --- Attach triggers to tracked tables - --- Orgs audit trigger -CREATE TRIGGER audit_orgs_trigger - AFTER INSERT OR UPDATE OR DELETE ON "public"."orgs" - FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); - --- Channels audit trigger -CREATE TRIGGER audit_channels_trigger - AFTER INSERT OR UPDATE OR DELETE ON "public"."channels" - FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); - --- App versions audit trigger -CREATE TRIGGER audit_app_versions_trigger - AFTER INSERT OR UPDATE OR DELETE ON "public"."app_versions" - FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); - --- Org users audit trigger -CREATE TRIGGER audit_org_users_trigger - AFTER INSERT OR UPDATE OR DELETE ON "public"."org_users" - FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); - --- Apps audit trigger -CREATE TRIGGER audit_apps_trigger - AFTER INSERT OR UPDATE OR DELETE ON "public"."apps" - FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); - --- Enable Row Level Security -ALTER TABLE "public"."audit_logs" ENABLE ROW LEVEL SECURITY; - --- RLS Policy: Only super_admins can view audit logs for their organizations -CREATE POLICY "Allow select for auth, api keys (super_admin+)" ON "public"."audit_logs" FOR -SELECT - TO "authenticated", - "anon" USING ( - "public"."check_min_rights" ( - 'super_admin'::"public"."user_min_right", - "public"."get_identity_org_allowed" ( - '{read,upload,write,all}'::"public"."key_mode" [], - "org_id" - ), - "org_id", - NULL::character varying, - NULL::bigint - ) - ); - --- No INSERT/UPDATE/DELETE policies - only triggers can write to this table - --- Cleanup function for 90-day retention -CREATE OR REPLACE FUNCTION "public"."cleanup_old_audit_logs"() -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - DELETE FROM "public"."audit_logs" - WHERE created_at < NOW() - INTERVAL '90 days'; -END; -$$; - --- Update delete_accounts_marked_for_deletion to transfer audit_logs ownership --- This ensures audit log entries are transferred to another super_admin instead of being orphaned -CREATE OR REPLACE FUNCTION "public"."delete_accounts_marked_for_deletion" () -RETURNS TABLE (deleted_count INTEGER, deleted_user_ids UUID[]) -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - account_record RECORD; - org_record RECORD; - deleted_users UUID[] := ARRAY[]::UUID[]; - total_deleted INTEGER := 0; - other_super_admins_count INTEGER; - replacement_owner_id UUID; -BEGIN - -- Loop through all accounts marked for deletion where removal_date has passed - FOR account_record IN - SELECT "account_id", "removal_date", "removed_data" - FROM "public"."to_delete_accounts" - WHERE "removal_date" < NOW() - LOOP - BEGIN - -- Process each org the user belongs to - FOR org_record IN - SELECT DISTINCT "org_id", "user_right" - FROM "public"."org_users" - WHERE "user_id" = account_record.account_id - LOOP - -- Reset replacement_owner_id for each org - replacement_owner_id := NULL; - - -- Check if user is a super_admin in this org - IF org_record.user_right = 'super_admin'::"public"."user_min_right" THEN - -- Count other super_admins in this org (excluding the user being deleted) - SELECT COUNT(*) INTO other_super_admins_count - FROM "public"."org_users" - WHERE "org_id" = org_record.org_id - AND "user_id" != account_record.account_id - AND "user_right" = 'super_admin'::"public"."user_min_right"; - - IF other_super_admins_count = 0 THEN - -- User is the last super_admin: DELETE all org resources - RAISE NOTICE 'User % is last super_admin of org %. Deleting all org resources.', - account_record.account_id, org_record.org_id; - - -- Delete deploy_history for this org - DELETE FROM "public"."deploy_history" WHERE "owner_org" = org_record.org_id; - - -- Delete channel_devices for this org - DELETE FROM "public"."channel_devices" WHERE "owner_org" = org_record.org_id; - - -- Delete channels for this org - DELETE FROM "public"."channels" WHERE "owner_org" = org_record.org_id; - - -- Delete app_versions for this org - DELETE FROM "public"."app_versions" WHERE "owner_org" = org_record.org_id; - - -- Delete apps for this org - DELETE FROM "public"."apps" WHERE "owner_org" = org_record.org_id; - - -- Delete the org itself since user is last super_admin - -- Note: audit_logs will be cascade deleted with the org - DELETE FROM "public"."orgs" WHERE "id" = org_record.org_id; - - -- Skip ownership transfer since all resources are deleted - CONTINUE; - END IF; - END IF; - - -- If we reach here, we need to transfer ownership (either non-super_admin or non-last super_admin) - -- Find a super_admin to transfer ownership to - SELECT "user_id" INTO replacement_owner_id - FROM "public"."org_users" - WHERE "org_id" = org_record.org_id - AND "user_id" != account_record.account_id - AND "user_right" = 'super_admin'::"public"."user_min_right" - LIMIT 1; - - IF replacement_owner_id IS NOT NULL THEN - RAISE NOTICE 'Transferring ownership from user % to user % in org %', - account_record.account_id, replacement_owner_id, org_record.org_id; - - -- Transfer app ownership - UPDATE "public"."apps" - SET "user_id" = replacement_owner_id, "updated_at" = NOW() - WHERE "user_id" = account_record.account_id AND "owner_org" = org_record.org_id; - - -- Transfer app_versions ownership - UPDATE "public"."app_versions" - SET "user_id" = replacement_owner_id, "updated_at" = NOW() - WHERE "user_id" = account_record.account_id AND "owner_org" = org_record.org_id; - - -- Transfer channels ownership - UPDATE "public"."channels" - SET "created_by" = replacement_owner_id, "updated_at" = NOW() - WHERE "created_by" = account_record.account_id AND "owner_org" = org_record.org_id; - - -- Transfer deploy_history ownership - UPDATE "public"."deploy_history" - SET "created_by" = replacement_owner_id, "updated_at" = NOW() - WHERE "created_by" = account_record.account_id AND "owner_org" = org_record.org_id; - - -- Transfer org ownership if user created it - UPDATE "public"."orgs" - SET "created_by" = replacement_owner_id, "updated_at" = NOW() - WHERE "id" = org_record.org_id AND "created_by" = account_record.account_id; - - -- Transfer audit_logs ownership - UPDATE "public"."audit_logs" - SET "user_id" = replacement_owner_id - WHERE "user_id" = account_record.account_id AND "org_id" = org_record.org_id; - ELSE - RAISE WARNING 'No super_admin found to transfer ownership in org % for user %', - org_record.org_id, account_record.account_id; - END IF; - END LOOP; - - -- Delete from public.users table - DELETE FROM "public"."users" WHERE "id" = account_record.account_id; - - -- Delete from auth.users table - DELETE FROM "auth"."users" WHERE "id" = account_record.account_id; - - -- Remove from to_delete_accounts table - DELETE FROM "public"."to_delete_accounts" WHERE "account_id" = account_record.account_id; - - -- Track the deleted user - deleted_users := "array_append"(deleted_users, account_record.account_id); - total_deleted := total_deleted + 1; - - -- Log the deletion - RAISE NOTICE 'Successfully deleted account: % (removal date: %)', - account_record.account_id, account_record.removal_date; - - EXCEPTION - WHEN OTHERS THEN - -- Log the error but continue with other accounts - RAISE WARNING 'Failed to delete account %: %', account_record.account_id, SQLERRM; - END; - END LOOP; - - -- Return results - deleted_count := total_deleted; - deleted_user_ids := deleted_users; - RETURN NEXT; - - RAISE NOTICE 'Deletion process completed. Total accounts deleted: %', total_deleted; -END; -$$; - --- Ensure permissions remain the same (only service_role and postgres can execute) -REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion" () FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion" () FROM anon; -REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion" () FROM authenticated; - -GRANT EXECUTE ON FUNCTION "public"."delete_accounts_marked_for_deletion" () TO postgres; -GRANT EXECUTE ON FUNCTION "public"."delete_accounts_marked_for_deletion" () TO service_role; - --- Update process_all_cron_tasks to include audit log cleanup at 3 AM UTC --- Per AGENTS.md, we don't create new cron jobs but add to the existing consolidated function -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks () RETURNS void LANGUAGE plpgsql -SET - search_path = '' AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; -BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - - -- Every 10 seconds: High-frequency queues (at :00, :10, :20, :30, :40, :50) - IF current_second % 10 = 0 THEN - -- Process high-frequency queues with default batch size (950) - BEGIN - PERFORM public.process_function_queue(ARRAY['on_channel_update', 'on_user_create', 'on_user_update', 'on_version_delete', 'on_version_update', 'on_app_delete', 'on_organization_create', 'on_user_delete', 'on_app_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (high-frequency) failed: %', SQLERRM; - END; - - -- Process channel device counts with batch size 1000 - BEGIN - PERFORM public.process_channel_device_counts_queue(1000); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_channel_device_counts_queue failed: %', SQLERRM; - END; - - END IF; - - -- Every minute (at :00 seconds): Per-minute tasks - IF current_second = 0 THEN - BEGIN - PERFORM public.delete_accounts_marked_for_deletion(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_accounts_marked_for_deletion failed: %', SQLERRM; - END; - - -- Process with batch size 10 - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_sync_sub', 'cron_stat_app'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (per-minute) failed: %', SQLERRM; - END; - - -- on_manifest_create uses default batch size - BEGIN - PERFORM public.process_function_queue(ARRAY['on_manifest_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (manifest_create) failed: %', SQLERRM; - END; - END IF; - - -- Every 5 minutes (at :00 seconds): Org stats with batch size 10 - IF current_minute % 5 = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_stat_org'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (cron_stat_org) failed: %', SQLERRM; - END; - END IF; - - -- Every hour (at :00:00): Hourly cleanup - IF current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_frequent_job_details(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_frequent_job_details failed: %', SQLERRM; - END; - END IF; - - -- Every 2 hours (at :00:00): Low-frequency queues with default batch size - IF current_hour % 2 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['admin_stats', 'cron_email', 'on_version_create', 'on_organization_delete', 'on_deploy_history_create', 'cron_clear_versions']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (low-frequency) failed: %', SQLERRM; - END; - END IF; - - -- Every 6 hours (at :00:00): Stats jobs - IF current_hour % 6 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_stats_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_stats_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:00:00 - Midnight tasks - IF current_hour = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_queue_messages(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_queue_messages failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.delete_old_deleted_apps(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_old_deleted_apps failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.remove_old_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'remove_old_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:40:00 - Old app version retention - IF current_hour = 0 AND current_minute = 40 AND current_second = 0 THEN - BEGIN - PERFORM public.update_app_versions_retention(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'update_app_versions_retention failed: %', SQLERRM; - END; - END IF; - - -- Daily at 01:01:00 - Admin stats creation - IF current_hour = 1 AND current_minute = 1 AND current_second = 0 THEN - BEGIN - PERFORM public.process_admin_stats(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_admin_stats failed: %', SQLERRM; - END; - END IF; - - -- Daily at 03:00:00 - Free trial, credits, and audit log cleanup - IF current_hour = 3 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_free_trial_expired(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_free_trial_expired failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.expire_usage_credits(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'expire_usage_credits failed: %', SQLERRM; - END; - - -- Cleanup old audit logs (90-day retention) - BEGIN - PERFORM public.cleanup_old_audit_logs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_old_audit_logs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 04:00:00 - Sync sub scheduler - IF current_hour = 4 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_sync_sub_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_sync_sub_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 12:00:00 - Noon tasks - IF current_hour = 12 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup job_run_details failed: %', SQLERRM; - END; - - -- Weekly stats email (every Saturday at noon) - IF EXTRACT(DOW FROM NOW()) = 6 THEN - BEGIN - PERFORM public.process_stats_email_weekly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_weekly failed: %', SQLERRM; - END; - END IF; - - -- Monthly stats email (1st of month at noon) - IF EXTRACT(DAY FROM NOW()) = 1 THEN - BEGIN - PERFORM public.process_stats_email_monthly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_monthly failed: %', SQLERRM; - END; - END IF; - END IF; -END; -$$; diff --git a/supabase/migrations/20251227040840_add_production_deploy_install_stats_email.sql b/supabase/migrations/20251227040840_add_production_deploy_install_stats_email.sql deleted file mode 100644 index fb5301d442..0000000000 --- a/supabase/migrations/20251227040840_add_production_deploy_install_stats_email.sql +++ /dev/null @@ -1,287 +0,0 @@ -BEGIN; - -ALTER TABLE public.deploy_history -ADD COLUMN IF NOT EXISTS install_stats_email_sent_at timestamp with time zone; - -CREATE OR REPLACE FUNCTION public.process_deploy_install_stats_email() RETURNS void LANGUAGE plpgsql -SET -search_path = '' AS $$ -DECLARE - record RECORD; -BEGIN - FOR record IN - WITH latest AS ( - SELECT DISTINCT ON (dh.app_id, channel_platform) - dh.id, - dh.app_id, - dh.version_id, - dh.deployed_at, - dh.owner_org, - dh.channel_id, - CASE - WHEN c.ios = true AND c.android = false THEN 'ios' - WHEN c.android = true AND c.ios = false THEN 'android' - ELSE 'all' - END AS channel_platform - FROM public.deploy_history dh - JOIN public.channels c ON c.id = dh.channel_id - WHERE c.public = true - AND (c.ios = true OR c.android = true) - ORDER BY dh.app_id, channel_platform, dh.deployed_at DESC NULLS LAST - ), - eligible AS ( - SELECT l.* - FROM latest l - WHERE l.deployed_at IS NOT NULL - AND l.deployed_at <= NOW() - interval '24 hours' - ), - updated AS ( - UPDATE public.deploy_history dh - SET install_stats_email_sent_at = NOW() - FROM eligible e - WHERE dh.id = e.id - AND dh.install_stats_email_sent_at IS NULL - RETURNING dh.id, dh.app_id, dh.version_id, dh.deployed_at, dh.owner_org, dh.channel_id - ), - details AS ( - SELECT - u.id, - u.app_id, - u.version_id, - u.deployed_at, - u.owner_org, - u.channel_id, - e.channel_platform, - o.management_email, - c.name AS channel_name, - v.name AS version_name, - a.name AS app_name - FROM updated u - JOIN eligible e ON e.id = u.id - JOIN public.orgs o ON o.id = u.owner_org - JOIN public.channels c ON c.id = u.channel_id - JOIN public.app_versions v ON v.id = u.version_id - JOIN public.apps a ON a.app_id = u.app_id - ) - SELECT - d.* - FROM details d - LOOP - IF record.management_email IS NULL OR record.management_email = '' THEN - CONTINUE; - END IF; - - PERFORM pgmq.send('cron_email', - jsonb_build_object( - 'function_name', 'cron_email', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'email', record.management_email, - 'appId', record.app_id, - 'type', 'deploy_install_stats', - 'deployId', record.id, - 'versionId', record.version_id, - 'versionName', record.version_name, - 'channelId', record.channel_id, - 'channelName', record.channel_name, - 'platform', record.channel_platform, - 'appName', record.app_name, - 'deployedAt', record.deployed_at - ) - ) - ); - END LOOP; -END; -$$; - -ALTER FUNCTION public.process_deploy_install_stats_email() OWNER TO postgres; - -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks() RETURNS void LANGUAGE plpgsql -SET -search_path = '' AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; -BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - - -- Every 10 seconds: High-frequency queues (at :00, :10, :20, :30, :40, :50) - IF current_second % 10 = 0 THEN - -- Process high-frequency queues with default batch size (950) - BEGIN - PERFORM public.process_function_queue(ARRAY['on_channel_update', 'on_user_create', 'on_user_update', 'on_version_create', 'on_version_delete', 'on_version_update', 'on_app_delete', 'on_organization_create', 'on_user_delete', 'on_app_create', 'credit_usage_alerts']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (high-frequency) failed: %', SQLERRM; - END; - - -- Process channel device counts with batch size 1000 - BEGIN - PERFORM public.process_channel_device_counts_queue(1000); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_channel_device_counts_queue failed: %', SQLERRM; - END; - - END IF; - - -- Every minute (at :00 seconds): Per-minute tasks - IF current_second = 0 THEN - BEGIN - PERFORM public.delete_accounts_marked_for_deletion(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_accounts_marked_for_deletion failed: %', SQLERRM; - END; - - -- Process with batch size 10 - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_sync_sub', 'cron_stat_app'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (per-minute) failed: %', SQLERRM; - END; - - -- on_manifest_create uses default batch size - BEGIN - PERFORM public.process_function_queue(ARRAY['on_manifest_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (manifest_create) failed: %', SQLERRM; - END; - END IF; - - -- Every 5 minutes (at :00 seconds): Org stats with batch size 10 - IF current_minute % 5 = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_stat_org'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (cron_stat_org) failed: %', SQLERRM; - END; - END IF; - - -- Every hour (at :00:00): Hourly cleanup - IF current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_frequent_job_details(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_frequent_job_details failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.process_deploy_install_stats_email(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_deploy_install_stats_email failed: %', SQLERRM; - END; - END IF; - - -- Every 2 hours (at :00:00): Low-frequency queues with default batch size - IF current_hour % 2 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['admin_stats', 'cron_email', 'on_organization_delete', 'on_deploy_history_create', 'cron_clear_versions']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (low-frequency) failed: %', SQLERRM; - END; - END IF; - - -- Every 6 hours (at :00:00): Stats jobs - IF current_hour % 6 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_stats_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_stats_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:00:00 - Midnight tasks - IF current_hour = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_queue_messages(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_queue_messages failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.delete_old_deleted_apps(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_old_deleted_apps failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.remove_old_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'remove_old_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:40:00 - Old app version retention - IF current_hour = 0 AND current_minute = 40 AND current_second = 0 THEN - BEGIN - PERFORM public.update_app_versions_retention(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'update_app_versions_retention failed: %', SQLERRM; - END; - END IF; - - -- Daily at 01:01:00 - Admin stats creation - IF current_hour = 1 AND current_minute = 1 AND current_second = 0 THEN - BEGIN - PERFORM public.process_admin_stats(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_admin_stats failed: %', SQLERRM; - END; - END IF; - - -- Daily at 03:00:00 - Free trial and credits - IF current_hour = 3 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_free_trial_expired(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_free_trial_expired failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.expire_usage_credits(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'expire_usage_credits failed: %', SQLERRM; - END; - END IF; - - -- Daily at 04:00:00 - Sync sub scheduler - IF current_hour = 4 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_sync_sub_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_sync_sub_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 12:00:00 - Noon tasks - IF current_hour = 12 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup job_run_details failed: %', SQLERRM; - END; - - -- Weekly stats email (every Saturday at noon) - IF EXTRACT(DOW FROM NOW()) = 6 THEN - BEGIN - PERFORM public.process_stats_email_weekly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_weekly failed: %', SQLERRM; - END; - END IF; - - -- Monthly stats email (1st of month at noon) - IF EXTRACT(DAY FROM NOW()) = 1 THEN - BEGIN - PERFORM public.process_stats_email_monthly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_monthly failed: %', SQLERRM; - END; - END IF; - END IF; -END; -$$; - -COMMIT; diff --git a/supabase/migrations/20251228033417_webhooks.sql b/supabase/migrations/20251228033417_webhooks.sql deleted file mode 100644 index 19ada64675..0000000000 --- a/supabase/migrations/20251228033417_webhooks.sql +++ /dev/null @@ -1,552 +0,0 @@ --- Webhooks System Migration --- Allows organizations to receive HTTP notifications for events - --- ===================================================== --- TABLE: webhooks --- Stores webhook endpoint configurations per organization --- ===================================================== -CREATE TABLE IF NOT EXISTS public.webhooks ( - id UUID DEFAULT gen_random_uuid() NOT NULL, - org_id UUID NOT NULL, - name TEXT NOT NULL, - url TEXT NOT NULL, - -- Secret for HMAC-SHA256 signing - secret TEXT DEFAULT 'whsec_' - || replace(gen_random_uuid()::TEXT, '-', '') NOT NULL, - enabled BOOLEAN DEFAULT true NOT NULL, - -- ['apps', 'app_versions', 'channels', 'org_users', 'orgs'] - events TEXT [] NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, - created_by UUID, - CONSTRAINT webhooks_pkey PRIMARY KEY (id), - CONSTRAINT webhooks_org_id_fkey FOREIGN KEY ( - org_id - ) REFERENCES public.orgs (id) ON DELETE CASCADE, - CONSTRAINT webhooks_created_by_fkey FOREIGN KEY ( - created_by - ) REFERENCES public.users (id) ON DELETE SET NULL -); - --- Add comment for secret column -COMMENT ON COLUMN public.webhooks.secret IS 'Secret key for HMAC-SHA256 signature verification. Format: whsec_{32-char-hex}'; - --- Indexes for efficient org lookups -CREATE INDEX IF NOT EXISTS webhooks_org_id_idx ON public.webhooks ( - org_id -); -CREATE INDEX IF NOT EXISTS webhooks_enabled_idx ON public.webhooks ( - org_id, enabled -) WHERE enabled -= true; - --- ===================================================== --- TABLE: webhook_deliveries --- Stores delivery history for each webhook call (Stripe-like experience) --- ===================================================== -CREATE TABLE IF NOT EXISTS public.webhook_deliveries ( - id UUID DEFAULT gen_random_uuid() NOT NULL, - webhook_id UUID NOT NULL, - org_id UUID NOT NULL, - -- Reference to audit_logs (nullable for test events) - audit_log_id BIGINT, - -- table_name.operation (e.g., 'app_versions.INSERT') - event_type TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', -- pending, success, failed - request_payload JSONB NOT NULL, - response_status INTEGER, - response_body TEXT, - response_headers JSONB, - attempt_count INTEGER DEFAULT 0 NOT NULL, - max_attempts INTEGER DEFAULT 3 NOT NULL, - next_retry_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT now() NOT NULL, - completed_at TIMESTAMPTZ, - duration_ms INTEGER, - CONSTRAINT webhook_deliveries_pkey PRIMARY KEY (id), - CONSTRAINT webhook_deliveries_webhook_id_fkey FOREIGN KEY ( - webhook_id - ) REFERENCES public.webhooks (id) ON DELETE CASCADE, - CONSTRAINT webhook_deliveries_org_id_fkey FOREIGN KEY ( - org_id - ) REFERENCES public.orgs (id) ON DELETE CASCADE -); - --- Indexes for efficient queries -CREATE INDEX IF NOT EXISTS webhook_deliveries_webhook_id_idx ON public.webhook_deliveries ( - webhook_id -); -CREATE INDEX IF NOT EXISTS webhook_deliveries_org_id_created_idx ON public.webhook_deliveries ( - org_id, created_at DESC -); -CREATE INDEX IF NOT EXISTS webhook_deliveries_pending_retry_idx ON public.webhook_deliveries ( - status, next_retry_at -) WHERE status -= 'pending'; - --- ===================================================== --- Enable RLS --- ===================================================== -ALTER TABLE public.webhooks ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.webhook_deliveries ENABLE ROW LEVEL SECURITY; - --- ===================================================== --- RLS Policies for webhooks table --- ===================================================== - --- Allow org members to view webhooks -CREATE POLICY "Allow org members to select webhooks" -ON public.webhooks -FOR SELECT -TO authenticated -USING ( - public.check_min_rights( - 'read'::public.USER_MIN_RIGHT, - (SELECT public.get_identity()), - org_id, - null::CHARACTER VARYING, - null::BIGINT - ) -); - --- Only admin/super_admin can create webhooks -CREATE POLICY "Allow admin to insert webhooks" -ON public.webhooks -FOR INSERT -TO authenticated -WITH CHECK ( - public.check_min_rights( - 'admin'::public.USER_MIN_RIGHT, - (SELECT public.get_identity()), - org_id, - null::CHARACTER VARYING, - null::BIGINT - ) -); - --- Only admin/super_admin can update webhooks -CREATE POLICY "Allow admin to update webhooks" -ON public.webhooks -FOR UPDATE -TO authenticated -USING ( - public.check_min_rights( - 'admin'::public.USER_MIN_RIGHT, - (SELECT public.get_identity()), - org_id, - null::CHARACTER VARYING, - null::BIGINT - ) -) -WITH CHECK ( - public.check_min_rights( - 'admin'::public.USER_MIN_RIGHT, - (SELECT public.get_identity()), - org_id, - null::CHARACTER VARYING, - null::BIGINT - ) -); - --- Only admin/super_admin can delete webhooks -CREATE POLICY "Allow admin to delete webhooks" -ON public.webhooks -FOR DELETE -TO authenticated -USING ( - public.check_min_rights( - 'admin'::public.USER_MIN_RIGHT, - (SELECT public.get_identity()), - org_id, - null::CHARACTER VARYING, - null::BIGINT - ) -); - --- ===================================================== --- RLS Policies for webhook_deliveries table --- ===================================================== - --- Allow org members to view delivery logs -CREATE POLICY "Allow org members to select webhook_deliveries" -ON public.webhook_deliveries -FOR SELECT -TO authenticated -USING ( - public.check_min_rights( - 'read'::public.USER_MIN_RIGHT, - (SELECT public.get_identity()), - org_id, - null::CHARACTER VARYING, - null::BIGINT - ) -); - --- Only admin/super_admin can insert (for test events via API) -CREATE POLICY "Allow admin to insert webhook_deliveries" -ON public.webhook_deliveries -FOR INSERT -TO authenticated -WITH CHECK ( - public.check_min_rights( - 'admin'::public.USER_MIN_RIGHT, - (SELECT public.get_identity()), - org_id, - null::CHARACTER VARYING, - null::BIGINT - ) -); - --- Only admin/super_admin can update (for retry functionality) -CREATE POLICY "Allow admin to update webhook_deliveries" -ON public.webhook_deliveries -FOR UPDATE -TO authenticated -USING ( - public.check_min_rights( - 'admin'::public.USER_MIN_RIGHT, - (SELECT public.get_identity()), - org_id, - null::CHARACTER VARYING, - null::BIGINT - ) -); - --- ===================================================== --- Service role policies (for triggers and background jobs) --- ===================================================== - --- Allow service role full access to webhooks -CREATE POLICY "Allow service_role full access to webhooks" -ON public.webhooks -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); - --- Allow service role full access to webhook_deliveries -CREATE POLICY "Allow service_role full access to webhook_deliveries" -ON public.webhook_deliveries -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); - --- ===================================================== --- PGMQ Queue for webhook delivery --- ===================================================== -SELECT pgmq.create('webhook_dispatcher'); -SELECT pgmq.create('webhook_delivery'); - --- ===================================================== --- Trigger function: Queue webhook on audit_log INSERT --- ===================================================== -CREATE OR REPLACE FUNCTION public.trigger_webhook_on_audit_log() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- Queue the audit log event for webhook dispatch - PERFORM pgmq.send( - 'webhook_dispatcher', - jsonb_build_object( - 'function_name', 'webhook_dispatcher', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'audit_log_id', NEW.id, - 'table_name', NEW.table_name, - 'operation', NEW.operation, - 'org_id', NEW.org_id, - 'record_id', NEW.record_id, - 'old_record', NEW.old_record, - 'new_record', NEW.new_record, - 'changed_fields', NEW.changed_fields, - 'user_id', NEW.user_id, - 'created_at', NEW.created_at - ) - ) - ); - RETURN NEW; -END; -$$; - --- ===================================================== --- Create trigger on audit_logs table --- Note: This will only work after audit_logs table is created --- ===================================================== -DO $$ -BEGIN - -- Check if audit_logs table exists before creating trigger - IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'audit_logs') THEN - -- Drop trigger if exists to recreate - DROP TRIGGER IF EXISTS "on_audit_log_webhook" ON "public"."audit_logs"; - - -- Create the trigger - CREATE TRIGGER "on_audit_log_webhook" - AFTER INSERT ON "public"."audit_logs" - FOR EACH ROW - EXECUTE FUNCTION "public"."trigger_webhook_on_audit_log"(); - END IF; -END -$$; - --- ===================================================== --- Updated_at trigger for webhooks --- ===================================================== -CREATE OR REPLACE FUNCTION public.update_webhook_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -SET search_path = '' -AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$; - -CREATE TRIGGER update_webhooks_updated_at -BEFORE UPDATE ON public.webhooks -FOR EACH ROW -EXECUTE FUNCTION public.update_webhook_updated_at(); - --- ===================================================== --- Cleanup function for old webhook deliveries (7 days) --- ===================================================== -CREATE OR REPLACE FUNCTION public.cleanup_webhook_deliveries() -RETURNS VOID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - DELETE FROM "public"."webhook_deliveries" - WHERE "created_at" < NOW() - INTERVAL '7 days'; -END; -$$; - --- ===================================================== --- Grant permissions --- ===================================================== - --- Webhooks table grants -GRANT ALL ON TABLE public.webhooks TO anon; -GRANT ALL ON TABLE public.webhooks TO authenticated; -GRANT ALL ON TABLE public.webhooks TO service_role; - --- Webhook deliveries table grants -GRANT ALL ON TABLE public.webhook_deliveries TO anon; -GRANT ALL ON TABLE public.webhook_deliveries TO authenticated; -GRANT ALL ON TABLE public.webhook_deliveries TO service_role; - --- Function grants -GRANT ALL ON FUNCTION public.trigger_webhook_on_audit_log() TO service_role; -GRANT ALL ON FUNCTION public.update_webhook_updated_at() TO service_role; -GRANT ALL ON FUNCTION public.cleanup_webhook_deliveries() TO service_role; - --- ===================================================== --- Add webhook_dispatcher and webhook_delivery to CRON processing --- Update process_all_cron_tasks to include webhook queues --- ===================================================== -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks() RETURNS VOID LANGUAGE plpgsql -SET -search_path = '' AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; -BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - - -- Every 10 seconds: High-frequency queues (at :00, :10, :20, :30, :40, :50) - IF current_second % 10 = 0 THEN - -- Process high-frequency queues with default batch size (950) - BEGIN - PERFORM public.process_function_queue(ARRAY['on_channel_update', 'on_user_create', 'on_user_update', 'on_version_create', 'on_version_delete', 'on_version_update', 'on_app_delete', 'on_organization_create', 'on_user_delete', 'on_app_create', 'credit_usage_alerts', 'webhook_dispatcher', 'webhook_delivery']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (high-frequency) failed: %', SQLERRM; - END; - - -- Process channel device counts with batch size 1000 - BEGIN - PERFORM public.process_channel_device_counts_queue(1000); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_channel_device_counts_queue failed: %', SQLERRM; - END; - - END IF; - - -- Every minute (at :00 seconds): Per-minute tasks - IF current_second = 0 THEN - BEGIN - PERFORM public.delete_accounts_marked_for_deletion(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_accounts_marked_for_deletion failed: %', SQLERRM; - END; - - -- Process with batch size 10 - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_sync_sub', 'cron_stat_app'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (per-minute) failed: %', SQLERRM; - END; - - -- on_manifest_create uses default batch size - BEGIN - PERFORM public.process_function_queue(ARRAY['on_manifest_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (manifest_create) failed: %', SQLERRM; - END; - END IF; - - -- Every 5 minutes (at :00 seconds): Org stats with batch size 10 - IF current_minute % 5 = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_stat_org'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (cron_stat_org) failed: %', SQLERRM; - END; - END IF; - - -- Every hour (at :00:00): Hourly cleanup - IF current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_frequent_job_details(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_frequent_job_details failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.process_deploy_install_stats_email(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_deploy_install_stats_email failed: %', SQLERRM; - END; - END IF; - - -- Every 2 hours (at :00:00): Low-frequency queues with default batch size - IF current_hour % 2 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['admin_stats', 'cron_email', 'on_version_create', 'on_organization_delete', 'on_deploy_history_create', 'cron_clear_versions']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (low-frequency) failed: %', SQLERRM; - END; - END IF; - - -- Every 6 hours (at :00:00): Stats jobs - IF current_hour % 6 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_stats_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_stats_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:00:00 - Midnight tasks - IF current_hour = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_queue_messages(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_queue_messages failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.delete_old_deleted_apps(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_old_deleted_apps failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.remove_old_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'remove_old_jobs failed: %', SQLERRM; - END; - - -- Cleanup old webhook deliveries (7 days retention) - BEGIN - PERFORM public.cleanup_webhook_deliveries(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_webhook_deliveries failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:40:00 - Old app version retention - IF current_hour = 0 AND current_minute = 40 AND current_second = 0 THEN - BEGIN - PERFORM public.update_app_versions_retention(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'update_app_versions_retention failed: %', SQLERRM; - END; - END IF; - - -- Daily at 01:01:00 - Admin stats creation - IF current_hour = 1 AND current_minute = 1 AND current_second = 0 THEN - BEGIN - PERFORM public.process_admin_stats(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_admin_stats failed: %', SQLERRM; - END; - END IF; - - -- Daily at 03:00:00 - Free trial, credits, and audit log cleanup - IF current_hour = 3 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_free_trial_expired(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_free_trial_expired failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.expire_usage_credits(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'expire_usage_credits failed: %', SQLERRM; - END; - - -- Cleanup old audit logs (90-day retention) - BEGIN - PERFORM public.cleanup_old_audit_logs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_old_audit_logs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 04:00:00 - Sync sub scheduler - IF current_hour = 4 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_sync_sub_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_sync_sub_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 12:00:00 - Noon tasks - IF current_hour = 12 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup job_run_details failed: %', SQLERRM; - END; - - -- Weekly stats email (every Saturday at noon) - IF EXTRACT(DOW FROM NOW()) = 6 THEN - BEGIN - PERFORM public.process_stats_email_weekly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_weekly failed: %', SQLERRM; - END; - END IF; - - -- Monthly stats email (1st of month at noon) - IF EXTRACT(DAY FROM NOW()) = 1 THEN - BEGIN - PERFORM public.process_stats_email_monthly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_monthly failed: %', SQLERRM; - END; - END IF; - END IF; -END; -$$; diff --git a/supabase/migrations/20251228063320_fix_audit_log_apikey.sql b/supabase/migrations/20251228063320_fix_audit_log_apikey.sql deleted file mode 100644 index 01369642b4..0000000000 --- a/supabase/migrations/20251228063320_fix_audit_log_apikey.sql +++ /dev/null @@ -1,102 +0,0 @@ --- Fix audit_log_trigger to properly identify users authenticated via API keys --- Previously, the trigger called get_identity() without parameters, which only checks auth.uid() --- This meant API key users (CLI, API) were not logged because get_identity() returned NULL --- Now we call get_identity with key_mode parameter to also check for API key authentication - -CREATE OR REPLACE FUNCTION public.audit_log_trigger() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_old_record JSONB; - v_new_record JSONB; - v_changed_fields TEXT[]; - v_org_id UUID; - v_record_id TEXT; - v_user_id UUID; - v_key TEXT; - v_org_exists BOOLEAN; -BEGIN - -- Skip audit logging for org DELETE operations - -- When an org is deleted, we can't insert into audit_logs because the org_id - -- foreign key would reference a non-existent org - IF TG_TABLE_NAME = 'orgs' AND TG_OP = 'DELETE' THEN - RETURN OLD; - END IF; - - -- Get current user from auth context or API key - -- Uses get_identity() WITH key_mode parameter to support both JWT auth and API key authentication - -- This is the fix: previously called get_identity() without parameters which only checked auth.uid() - v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); - - -- Skip audit logging if no user is identified - -- We only want to log actions performed by authenticated users - IF v_user_id IS NULL THEN - RETURN COALESCE(NEW, OLD); - END IF; - - -- Convert records to JSONB based on operation type - IF TG_OP = 'DELETE' THEN - v_old_record := to_jsonb(OLD); - v_new_record := NULL; - ELSIF TG_OP = 'INSERT' THEN - v_old_record := NULL; - v_new_record := to_jsonb(NEW); - ELSE -- UPDATE - v_old_record := to_jsonb(OLD); - v_new_record := to_jsonb(NEW); - - -- Calculate changed fields by comparing old and new values - FOR v_key IN SELECT jsonb_object_keys(v_new_record) - LOOP - IF v_old_record->v_key IS DISTINCT FROM v_new_record->v_key THEN - v_changed_fields := array_append(v_changed_fields, v_key); - END IF; - END LOOP; - END IF; - - -- Get org_id and record_id based on table being modified - CASE TG_TABLE_NAME - WHEN 'orgs' THEN - v_org_id := COALESCE(NEW.id, OLD.id); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - WHEN 'apps' THEN - v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); - v_record_id := COALESCE(NEW.app_id, OLD.app_id)::TEXT; - WHEN 'channels' THEN - v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - WHEN 'app_versions' THEN - v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - WHEN 'org_users' THEN - v_org_id := COALESCE(NEW.org_id, OLD.org_id); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - ELSE - -- Fallback for any other table (shouldn't happen with current triggers) - v_org_id := NULL; - v_record_id := NULL; - END CASE; - - -- Only insert if we have a valid org_id and the org still exists - -- This handles edge cases where related tables are deleted after the org - IF v_org_id IS NOT NULL THEN - -- Check if the org still exists (important for DELETE operations on child tables) - SELECT EXISTS(SELECT 1 FROM public.orgs WHERE id = v_org_id) INTO v_org_exists; - - IF v_org_exists THEN - INSERT INTO "public"."audit_logs" ( - table_name, record_id, operation, user_id, org_id, - old_record, new_record, changed_fields - ) VALUES ( - TG_TABLE_NAME, v_record_id, TG_OP, v_user_id, v_org_id, - v_old_record, v_new_record, v_changed_fields - ); - END IF; - END IF; - - RETURN COALESCE(NEW, OLD); -END; -$$; diff --git a/supabase/migrations/20251228065406_user_email_preferences.sql b/supabase/migrations/20251228065406_user_email_preferences.sql deleted file mode 100644 index d4d5202102..0000000000 --- a/supabase/migrations/20251228065406_user_email_preferences.sql +++ /dev/null @@ -1,50 +0,0 @@ --- Migration: Add granular email notification preferences for users and organizations --- This allows users and organizations to opt in/out of specific email notification types - --- Add JSONB column for granular email preferences to users table -ALTER TABLE public.users -ADD COLUMN IF NOT EXISTS email_preferences jsonb DEFAULT '{ - "usage_limit": true, - "credit_usage": true, - "onboarding": true, - "weekly_stats": true, - "monthly_stats": true, - "deploy_stats_24h": true, - "bundle_created": true, - "bundle_deployed": true, - "device_error": true, - "channel_self_rejected": true -}'::jsonb NOT NULL; - --- Index for performance when filtering by preferences -CREATE INDEX IF NOT EXISTS idx_users_email_preferences ON public.users USING gin ( - email_preferences -); - --- Add comment describing the column -COMMENT ON COLUMN public.users.email_preferences IS 'Per-user email notification preferences. Keys: usage_limit, credit_usage, onboarding, weekly_stats, monthly_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected. Values are booleans.'; - --- Add email_preferences JSONB column to orgs table --- This allows organizations to control which email notifications are sent to the org's management email --- The management_email is used for billing/invoice emails and can optionally receive other notifications -ALTER TABLE public.orgs -ADD COLUMN IF NOT EXISTS email_preferences jsonb NOT NULL DEFAULT '{ - "usage_limit": true, - "credit_usage": true, - "onboarding": true, - "weekly_stats": true, - "monthly_stats": true, - "deploy_stats_24h": true, - "bundle_created": true, - "bundle_deployed": true, - "device_error": true, - "channel_self_rejected": true -}'::jsonb; - --- Add GIN index for efficient JSONB queries on orgs -CREATE INDEX IF NOT EXISTS idx_orgs_email_preferences ON public.orgs USING gin ( - email_preferences -); - --- Add comment explaining the column -COMMENT ON COLUMN public.orgs.email_preferences IS 'JSONB object containing email notification preferences for the organization. When enabled, emails are also sent to the management_email if it differs from admin user emails. Keys: usage_limit, credit_usage, onboarding, weekly_stats, monthly_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected. All default to true.'; diff --git a/supabase/migrations/20251228080032_hashed_api_keys.sql b/supabase/migrations/20251228080032_hashed_api_keys.sql deleted file mode 100644 index 709f2e6467..0000000000 --- a/supabase/migrations/20251228080032_hashed_api_keys.sql +++ /dev/null @@ -1,390 +0,0 @@ --- ============================================================================ --- Hashed API Keys Migration --- ============================================================================ --- This migration adds support for hashed API keys with organization-level --- enforcement. Hashed keys are stored as SHA-256 hashes, with the plain key --- only visible once during creation. --- ============================================================================ - --- ============================================================================ --- Section 1: Add key_hash column to apikeys table --- ============================================================================ - --- Add key_hash column for storing hashed API keys -ALTER TABLE "public"."apikeys" -ADD COLUMN IF NOT EXISTS "key_hash" text; - --- Allow NULL in the key column for hashed keys (key is NULL when key_hash is set) -ALTER TABLE "public"."apikeys" -ALTER COLUMN "key" DROP NOT NULL; - --- Add a partial index for efficient hash lookups -CREATE INDEX IF NOT EXISTS idx_apikeys_key_hash ON public.apikeys(key_hash) -WHERE key_hash IS NOT NULL; - --- Add comment to document the column -COMMENT ON COLUMN "public"."apikeys"."key_hash" IS 'SHA-256 hash of the API key. When set, the key column is cleared to null for security.'; - --- ============================================================================ --- Section 2: Add enforce_hashed_api_keys column to orgs table --- ============================================================================ - --- Add organization-level enforcement setting -ALTER TABLE "public"."orgs" -ADD COLUMN IF NOT EXISTS "enforce_hashed_api_keys" boolean NOT NULL DEFAULT false; - --- Add comment to document the column -COMMENT ON COLUMN "public"."orgs"."enforce_hashed_api_keys" IS 'When true, only hashed API keys can access this organization. Plain-text keys will be rejected.'; - --- ============================================================================ --- Section 3: Create hash verification function --- ============================================================================ - --- Function to verify if a plain key matches a stored hash -CREATE OR REPLACE FUNCTION "public"."verify_api_key_hash"( - "plain_key" text, - "stored_hash" text -) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -BEGIN - RETURN encode(extensions.digest(plain_key, 'sha256'), 'hex') = stored_hash; -END; -$$; - -ALTER FUNCTION "public"."verify_api_key_hash"(text, text) OWNER TO "postgres"; - --- Grant permissions -GRANT EXECUTE ON FUNCTION "public"."verify_api_key_hash"(text, text) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."verify_api_key_hash"(text, text) TO "service_role"; - --- ============================================================================ --- Section 4: Create function to find apikey by value (plain or hashed) --- ============================================================================ - --- Function to find apikey by plain key value (checks both plain and hashed) -CREATE OR REPLACE FUNCTION "public"."find_apikey_by_value"( - "key_value" text -) RETURNS SETOF "public"."apikeys" -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - found_key public.apikeys%ROWTYPE; -BEGIN - -- First try plain-text lookup - SELECT * INTO found_key FROM public.apikeys WHERE key = key_value LIMIT 1; - IF FOUND THEN - RETURN NEXT found_key; - RETURN; - END IF; - - -- Try hashed lookup - SELECT * INTO found_key FROM public.apikeys - WHERE key_hash = encode(extensions.digest(key_value, 'sha256'), 'hex') - LIMIT 1; - IF FOUND THEN - RETURN NEXT found_key; - RETURN; - END IF; - - -- No key found - RETURN; -END; -$$; - -ALTER FUNCTION "public"."find_apikey_by_value"(text) OWNER TO "postgres"; - --- Grant permissions (only service_role - this function is for internal backend use only) -GRANT EXECUTE ON FUNCTION "public"."find_apikey_by_value"(text) TO "service_role"; - --- ============================================================================ --- Section 5: Create function to check if org enforces hashed API keys --- ============================================================================ - --- Function to check if an org requires hashed API keys -CREATE OR REPLACE FUNCTION "public"."check_org_hashed_key_enforcement"( - "org_id" uuid, - "apikey_row" public.apikeys -) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - org_enforcing boolean; - is_hashed_key boolean; -BEGIN - -- Check if org exists and get enforcement setting - SELECT enforce_hashed_api_keys INTO org_enforcing - FROM public.orgs - WHERE id = check_org_hashed_key_enforcement.org_id; - - IF NOT FOUND THEN - RETURN true; -- Org not found, allow (will fail on other checks) - END IF; - - -- If org doesn't enforce hashed keys, allow - IF org_enforcing = false THEN - RETURN true; - END IF; - - -- Check if this is a hashed key (key is null, key_hash is not null) - is_hashed_key := (apikey_row.key IS NULL AND apikey_row.key_hash IS NOT NULL); - - IF NOT is_hashed_key THEN - PERFORM public.pg_log('deny: ORG_REQUIRES_HASHED_API_KEY', - jsonb_build_object('org_id', org_id, 'apikey_id', apikey_row.id)); - RETURN false; - END IF; - - RETURN true; -END; -$$; - -ALTER FUNCTION "public"."check_org_hashed_key_enforcement"(uuid, public.apikeys) OWNER TO "postgres"; - --- Grant permissions -GRANT EXECUTE ON FUNCTION "public"."check_org_hashed_key_enforcement"(uuid, public.apikeys) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."check_org_hashed_key_enforcement"(uuid, public.apikeys) TO "service_role"; - --- ============================================================================ --- Section 6: Update get_orgs_v7 to include enforce_hashed_api_keys --- ============================================================================ - --- Drop and recreate get_orgs_v7(userid uuid) to add enforce_hashed_api_keys field -DROP FUNCTION IF EXISTS public.get_orgs_v7(uuid); - -CREATE FUNCTION public.get_orgs_v7(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - -- Calculate current billing cycle for each org - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - -- 2fa_has_access: true if enforcing_2fa is false OR (enforcing_2fa is true AND user has 2FA) - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - -- should_redact: true if org enforces 2FA and user doesn't have 2FA - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- Redact sensitive fields if user doesn't have 2FA access - CASE - WHEN tfa.should_redact THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "anon"; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "authenticated"; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO "postgres"; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO "service_role"; - --- ============================================================================ --- Section 7: Update get_orgs_v7() wrapper to match new signature --- ============================================================================ - -DROP FUNCTION IF EXISTS public.get_orgs_v7(); - -CREATE OR REPLACE FUNCTION public.get_orgs_v7() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "anon"; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "authenticated"; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "service_role"; - diff --git a/supabase/migrations/20251228080037_apikey_expiration.sql b/supabase/migrations/20251228080037_apikey_expiration.sql deleted file mode 100644 index e84532a503..0000000000 --- a/supabase/migrations/20251228080037_apikey_expiration.sql +++ /dev/null @@ -1,349 +0,0 @@ --- API Key Expiration Feature --- Adds optional expiration dates to API keys with organization-level policies - --- ============================================================================= --- 1. Add expires_at column to apikeys table --- ============================================================================= -ALTER TABLE "public"."apikeys" -ADD COLUMN IF NOT EXISTS "expires_at" timestamp with time zone DEFAULT NULL; - -COMMENT ON COLUMN "public"."apikeys"."expires_at" IS 'When this API key expires. NULL means never expires.'; - --- Index for efficient expiration queries -CREATE INDEX IF NOT EXISTS idx_apikeys_expires_at ON "public"."apikeys" ("expires_at") -WHERE expires_at IS NOT NULL; - --- ============================================================================= --- 2. Add organization policy columns to orgs table --- ============================================================================= -ALTER TABLE "public"."orgs" -ADD COLUMN IF NOT EXISTS "require_apikey_expiration" boolean NOT NULL DEFAULT false; - -ALTER TABLE "public"."orgs" -ADD COLUMN IF NOT EXISTS "max_apikey_expiration_days" integer DEFAULT NULL; - -COMMENT ON COLUMN "public"."orgs"."require_apikey_expiration" IS 'When true, API keys used with this organization must have an expiration date set.'; -COMMENT ON COLUMN "public"."orgs"."max_apikey_expiration_days" IS 'Maximum number of days an API key can be valid when creating/updating keys limited to this org. NULL means no maximum.'; - --- ============================================================================= --- 3. Helper function to check if API key is expired --- ============================================================================= -CREATE OR REPLACE FUNCTION "public"."is_apikey_expired"("key_expires_at" timestamp with time zone) -RETURNS boolean -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- NULL expires_at means key never expires - IF key_expires_at IS NULL THEN - RETURN false; - END IF; - - -- Check if current time is past expiration - RETURN NOW() > key_expires_at; -END; -$$; - --- ============================================================================= --- 4. Cleanup function for expired API keys (30-day grace period) --- ============================================================================= -CREATE OR REPLACE FUNCTION "public"."cleanup_expired_apikeys"() -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - DELETE FROM "public"."apikeys" - WHERE expires_at IS NOT NULL - AND expires_at < NOW() - INTERVAL '30 days'; -END; -$$; - --- ============================================================================= --- 5. Update get_identity functions to check expiration --- ============================================================================= - --- Update get_identity(keymode key_mode[]) to check expiration -CREATE OR REPLACE FUNCTION "public"."get_identity" ("keymode" "public"."key_mode" []) RETURNS "uuid" -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key record; -Begin - SELECT auth.uid() into auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT "public"."get_apikey_header"() into api_key_text; - - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - -- Fetch the api key - select * FROM public.apikeys - where key=api_key_text AND - mode=ANY(keymode) - limit 1 into api_key; - - if api_key IS DISTINCT FROM NULL THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RETURN NULL; - END IF; - - RETURN api_key.user_id; - END IF; - - RETURN NULL; -End; -$$; - --- Update get_identity_apikey_only(keymode key_mode[]) to check expiration -CREATE OR REPLACE FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode" []) RETURNS "uuid" -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key record; -Begin - SELECT "public"."get_apikey_header"() into api_key_text; - - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - -- Fetch the api key - select * FROM public.apikeys - where key=api_key_text AND - mode=ANY(keymode) - limit 1 into api_key; - - if api_key IS DISTINCT FROM NULL THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RETURN NULL; - END IF; - - RETURN api_key.user_id; - END IF; - - RETURN NULL; -End; -$$; - --- ============================================================================= --- 6. Update consolidated cron function to include expired apikey cleanup --- ============================================================================= -CREATE OR REPLACE FUNCTION "public"."process_all_cron_tasks" () -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - current_second integer := EXTRACT(SECOND FROM NOW())::integer; - current_minute integer := EXTRACT(MINUTE FROM NOW())::integer; - current_hour integer := EXTRACT(HOUR FROM NOW())::integer; -BEGIN - -- Every 10 seconds - High-frequency tasks (combined processing) - IF current_second % 10 = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY[ - 'channel_update', - 'on_app_create', - 'user_create', - 'on_app_version_create', - 'on_channel_create' - ]); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'High-frequency queue processing failed: %', SQLERRM; - END; - END IF; - - -- Every 60 seconds - Medium-frequency tasks (combined processing) - IF current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY[ - 'app_version_on_delete', - 'cache_invalidation', - 'on_app_update', - 'on_org_create', - 'on_version_update', - 'on_version_delete', - 'on_channel_delete', - 'on_channel_update', - 'on_bundle_retry', - 'on_device_insert' - ]); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'Medium-frequency queue processing failed: %', SQLERRM; - END; - - -- Clear tmp_users table every minute - BEGIN - DELETE FROM "public"."tmp_users" WHERE created_at < NOW() - INTERVAL '1 hour'; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'tmp_users cleanup failed: %', SQLERRM; - END; - END IF; - - -- Every 5 minutes (at :00, :05, :10, etc.) - Metrics-related processing - IF current_second = 0 AND current_minute % 5 = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY[ - 'update_app_metrics', - 'update_channel_device_counts', - 'on_bundle_counts' - ]); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'Metrics queue processing failed: %', SQLERRM; - END; - END IF; - - -- Every 15 minutes (at :00, :15, :30, :45) - Plan/subscription updates - IF current_second = 0 AND current_minute % 15 = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY[ - 'cron_good_plan', - 'on_stripe_event' - ]); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'Plan/subscription queue processing failed: %', SQLERRM; - END; - END IF; - - -- Hourly at the start of each hour - Account deletion and cleanup - IF current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.delete_accounts_marked_for_deletion(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_accounts_marked_for_deletion failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:00:00 - Stats processing - IF current_hour = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY[ - 'cron_stats' - ]); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'Stats queue processing failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:01:00 - Manifest stats - IF current_hour = 0 AND current_minute = 1 AND current_second = 0 THEN - BEGIN - PERFORM public.process_manifest_daily_stats(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_manifest_daily_stats failed: %', SQLERRM; - END; - END IF; - - -- Daily at 01:01:00 - Admin stats creation - IF current_hour = 1 AND current_minute = 1 AND current_second = 0 THEN - BEGIN - PERFORM public.process_admin_stats(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_admin_stats failed: %', SQLERRM; - END; - END IF; - - -- Daily at 03:00:00 - Free trial, credits, audit log, and expired API key cleanup - IF current_hour = 3 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_free_trial_expired(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_free_trial_expired failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.expire_usage_credits(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'expire_usage_credits failed: %', SQLERRM; - END; - - -- Cleanup old audit logs (90-day retention) - BEGIN - PERFORM public.cleanup_old_audit_logs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_old_audit_logs failed: %', SQLERRM; - END; - - -- Cleanup expired API keys (30-day grace period after expiration) - BEGIN - PERFORM public.cleanup_expired_apikeys(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_expired_apikeys failed: %', SQLERRM; - END; - END IF; - - -- Daily at 04:00:00 - Sync sub scheduler - IF current_hour = 4 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_sync_sub_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_sync_sub_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 12:00:00 - Noon tasks - IF current_hour = 12 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup job_run_details failed: %', SQLERRM; - END; - - -- Weekly stats email (every Saturday at noon) - IF EXTRACT(DOW FROM NOW()) = 6 THEN - BEGIN - PERFORM public.process_stats_email_weekly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_weekly failed: %', SQLERRM; - END; - END IF; - - -- Monthly stats email (1st of month at noon) - IF EXTRACT(DAY FROM NOW()) = 1 THEN - BEGIN - PERFORM public.process_stats_email_monthly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_monthly failed: %', SQLERRM; - END; - END IF; - END IF; - - -- Daily at 06:00:00 - Production deploy stats email - IF current_hour = 6 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_production_deploy_stats_email(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_production_deploy_stats_email failed: %', SQLERRM; - END; - END IF; - - -- Daily at 07:00:00 - Install stats email - IF current_hour = 7 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_install_stats_email(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_install_stats_email failed: %', SQLERRM; - END; - END IF; -END; -$$; diff --git a/supabase/migrations/20251228082157_add_apikey_policy_to_get_orgs.sql b/supabase/migrations/20251228082157_add_apikey_policy_to_get_orgs.sql deleted file mode 100644 index f30b52df39..0000000000 --- a/supabase/migrations/20251228082157_add_apikey_policy_to_get_orgs.sql +++ /dev/null @@ -1,231 +0,0 @@ --- Add API key policy columns to get_orgs_v6 function return type --- This allows the frontend to access require_apikey_expiration and max_apikey_expiration_days - --- Drop both overloads of get_orgs_v6 (with and without parameters) -DROP FUNCTION IF EXISTS public.get_orgs_v6(); -DROP FUNCTION IF EXISTS public.get_orgs_v6(uuid); - -CREATE FUNCTION public.get_orgs_v6(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - require_apikey_expiration boolean, - max_apikey_expiration_days integer -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - -- should_redact: true if org enforces 2FA and user doesn't have 2FA - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- Redact sensitive fields if user doesn't have 2FA access - CASE - WHEN tfa.should_redact THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration, - o.require_apikey_expiration, - o.max_apikey_expiration_days - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v6(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM public; -REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM authenticated; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v6(uuid) TO postgres; -GRANT EXECUTE ON FUNCTION public.get_orgs_v6(uuid) TO service_role; - --- Create the wrapper function (no parameters) that calls get_orgs_v6(userid) -CREATE OR REPLACE FUNCTION public.get_orgs_v6() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - require_apikey_expiration boolean, - max_apikey_expiration_days integer -) LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.apikeys WHERE key = api_key_text INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - -- Check if API key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v6(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v6(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v6() OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v6() TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v6() TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v6() TO service_role; diff --git a/supabase/migrations/20251228100000_password_policy_enforcement.sql b/supabase/migrations/20251228100000_password_policy_enforcement.sql deleted file mode 100644 index 4b3f80a7f6..0000000000 --- a/supabase/migrations/20251228100000_password_policy_enforcement.sql +++ /dev/null @@ -1,811 +0,0 @@ --- ============================================================================ --- Password Policy Enforcement for Organizations --- Better approach: Users verify their existing password meets requirements --- No forced password reset - just validation via login attempt --- ============================================================================ - --- ============================================================================ --- Section 1: Add password policy columns to orgs table --- ============================================================================ - --- Add password policy configuration (JSONB) column to orgs table -ALTER TABLE "public"."orgs" -ADD COLUMN IF NOT EXISTS "password_policy_config" jsonb DEFAULT NULL; - --- Add comments to document the column -COMMENT ON COLUMN "public"."orgs"."password_policy_config" IS - 'JSON configuration for password policy: {enabled: boolean, min_length: number, require_uppercase: boolean, require_number: boolean, require_special: boolean}'; - --- ============================================================================ --- Section 2: Create user_password_compliance table --- ============================================================================ - --- Table to track which users have verified their passwords meet org requirements --- Users can only READ this table, not write to it (writes done by service_role only) -CREATE TABLE IF NOT EXISTS "public"."user_password_compliance" ( - "id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - "user_id" uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - "org_id" uuid NOT NULL REFERENCES public.orgs(id) ON DELETE CASCADE, - "validated_at" timestamptz NOT NULL DEFAULT NOW(), - "policy_hash" text NOT NULL, -- Hash of the policy config when validated (to detect policy changes) - "created_at" timestamptz NOT NULL DEFAULT NOW(), - "updated_at" timestamptz NOT NULL DEFAULT NOW(), - UNIQUE(user_id, org_id) -); - --- Index for fast lookups -CREATE INDEX IF NOT EXISTS "idx_user_password_compliance_user_org" -ON "public"."user_password_compliance" ("user_id", "org_id"); - --- Add comments -COMMENT ON TABLE "public"."user_password_compliance" IS - 'Tracks which users have verified their passwords meet their org password policy requirements'; -COMMENT ON COLUMN "public"."user_password_compliance"."policy_hash" IS - 'MD5 hash of the password_policy_config when the user validated. If policy changes, user must re-validate.'; - --- RLS policies for user_password_compliance -ALTER TABLE "public"."user_password_compliance" ENABLE ROW LEVEL SECURITY; - --- Users can only read their own compliance records -CREATE POLICY "Users can read own password compliance" -ON "public"."user_password_compliance" -FOR SELECT -TO authenticated -USING (user_id = (select auth.uid())); - --- No INSERT/UPDATE/DELETE for authenticated users - only service_role can write --- (Default behavior when no policy exists for those operations) - --- Grant permissions -GRANT SELECT ON "public"."user_password_compliance" TO "authenticated"; -GRANT ALL ON "public"."user_password_compliance" TO "service_role"; -GRANT ALL ON "public"."user_password_compliance" TO "postgres"; - --- ============================================================================ --- Section 3: Helper function to compute policy hash --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."get_password_policy_hash"("policy_config" jsonb) - RETURNS text - LANGUAGE "plpgsql" IMMUTABLE - SET "search_path" TO '' - AS $$ -BEGIN - IF policy_config IS NULL THEN - RETURN NULL; - END IF; - -- Create a deterministic hash of the policy config - RETURN md5(policy_config::text); -END; -$$; - -ALTER FUNCTION "public"."get_password_policy_hash"(jsonb) OWNER TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."get_password_policy_hash"(jsonb) TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."get_password_policy_hash"(jsonb) TO "service_role"; - --- ============================================================================ --- Section 4: user_meets_password_policy function --- ============================================================================ - --- Function to check if a specific user meets an org's password policy --- Returns true if: policy is disabled, OR user has a valid compliance record with matching policy hash -CREATE OR REPLACE FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - org_policy_config jsonb; - org_policy_hash text; - compliance_record_hash text; -BEGIN - -- Get org's password policy config - SELECT password_policy_config - INTO org_policy_config - FROM public.orgs - WHERE public.orgs.id = user_meets_password_policy.org_id; - - -- If no policy or policy is disabled, user passes - IF org_policy_config IS NULL OR COALESCE((org_policy_config->>'enabled')::boolean, false) = false THEN - RETURN true; - END IF; - - -- Compute the hash of the current policy - org_policy_hash := public.get_password_policy_hash(org_policy_config); - - -- Check if user has a valid compliance record with matching policy hash - SELECT policy_hash INTO compliance_record_hash - FROM public.user_password_compliance - WHERE public.user_password_compliance.user_id = user_meets_password_policy.user_id - AND public.user_password_compliance.org_id = user_meets_password_policy.org_id; - - -- User passes if they have a compliance record AND the policy hash matches - -- (If policy changed, they need to re-validate) - RETURN compliance_record_hash IS NOT NULL AND compliance_record_hash = org_policy_hash; -END; -$$; - -ALTER FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") OWNER TO "postgres"; - --- Grant permissions - only to postgres and service_role (private function) -REVOKE ALL ON FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") -FROM PUBLIC; - -REVOKE ALL ON FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") -FROM "anon"; - -REVOKE ALL ON FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") -FROM "authenticated"; - -GRANT EXECUTE ON FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") TO "service_role"; - --- ============================================================================ --- Section 5: check_org_members_password_policy function --- ============================================================================ - --- Function to check password policy compliance for all members of an organization --- This function is accessible only to super_admins of the organization -CREATE OR REPLACE FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") - RETURNS TABLE ( - "user_id" "uuid", - "email" text, - "first_name" text, - "last_name" text, - "password_policy_compliant" boolean - ) - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - -- Check if org exists - IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = check_org_members_password_policy.org_id) THEN - RAISE EXCEPTION 'Organization does not exist'; - END IF; - - -- Check if the current user is a super_admin of the organization - IF NOT ( - public.check_min_rights( - 'super_admin'::public.user_min_right, - (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], check_org_members_password_policy.org_id)), - check_org_members_password_policy.org_id, - NULL::character varying, - NULL::bigint - ) - ) THEN - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - -- Return list of org members with their password policy compliance status - RETURN QUERY - SELECT - ou.user_id, - au.email::text, - u.first_name::text, - u.last_name::text, - public.user_meets_password_policy(ou.user_id, check_org_members_password_policy.org_id) AS "password_policy_compliant" - FROM public.org_users ou - JOIN auth.users au ON au.id = ou.user_id - LEFT JOIN public.users u ON u.id = ou.user_id - WHERE ou.org_id = check_org_members_password_policy.org_id; -END; -$$; - -ALTER FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") OWNER TO "postgres"; - --- Grant permissions - accessible to authenticated users (permission check is inside the function) -GRANT EXECUTE ON FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") TO "service_role"; - --- ============================================================================ --- Section 6: reject_access_due_to_password_policy function --- ============================================================================ - --- Function to check if access should be rejected due to password policy enforcement --- Returns true if org requires password policy and user doesn't meet it, false otherwise -CREATE OR REPLACE FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") - RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - org_has_policy boolean; -BEGIN - -- Check if org exists - IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = reject_access_due_to_password_policy.org_id) THEN - RETURN false; - END IF; - - -- Check if org has password policy enabled - SELECT - password_policy_config IS NOT NULL - AND (password_policy_config->>'enabled')::boolean = true - INTO org_has_policy - FROM public.orgs - WHERE public.orgs.id = reject_access_due_to_password_policy.org_id; - - -- If no policy enabled, don't reject - IF NOT COALESCE(org_has_policy, false) THEN - RETURN false; - END IF; - - -- If org requires policy and user doesn't meet it, reject access - IF NOT public.user_meets_password_policy(user_id, org_id) THEN - PERFORM public.pg_log('deny: REJECT_ACCESS_DUE_TO_PASSWORD_POLICY', jsonb_build_object('org_id', org_id, 'user_id', user_id)); - RETURN true; - END IF; - - RETURN false; -END; -$$; - -ALTER FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") OWNER TO "postgres"; - --- Revoke all permissions from PUBLIC, anon, and authenticated (private function) -REVOKE ALL ON FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") -FROM PUBLIC; - -REVOKE ALL ON FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") -FROM "anon"; - -REVOKE ALL ON FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") -FROM "authenticated"; - -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") TO "service_role"; - --- ============================================================================ --- Section 7: Modify check_min_rights to enforce password policy --- ============================================================================ - --- Modify check_min_rights to check password policy enforcement rules --- If org has password policy enabled and user doesn't meet it, deny access -CREATE OR REPLACE FUNCTION "public"."check_min_rights" ( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - user_right_record RECORD; - org_enforcing_2fa boolean; -BEGIN - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_NO_UID', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text)); - RETURN false; - END IF; - - -- Check if org has 2FA enforcement enabled - SELECT enforcing_2fa INTO org_enforcing_2fa - FROM public.orgs - WHERE public.orgs.id = check_min_rights.org_id; - - -- If org enforces 2FA and user doesn't have 2FA enabled, deny access - IF org_enforcing_2fa = true AND NOT public.has_2fa_enabled(user_id) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); - RETURN false; - END IF; - - -- Check password policy enforcement - IF NOT public.user_meets_password_policy(user_id, org_id) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); - RETURN false; - END IF; - - FOR user_right_record IN - SELECT org_users.user_right, org_users.app_id, org_users.channel_id - FROM public.org_users - WHERE org_users.org_id = check_min_rights.org_id AND org_users.user_id = check_min_rights.user_id - LOOP - IF (user_right_record.user_right >= min_right AND user_right_record.app_id IS NULL AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights.app_id AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights.app_id AND user_right_record.channel_id = check_min_rights.channel_id) - THEN - RETURN true; - END IF; - END LOOP; - - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); - RETURN false; -END; -$$; - --- ============================================================================ --- Section 8: Modify get_orgs_v7 to add password policy fields --- ============================================================================ - --- Drop and recreate get_orgs_v7(userid uuid) with password policy fields -DROP FUNCTION IF EXISTS public.get_orgs_v7(uuid); - -CREATE FUNCTION public.get_orgs_v7(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - -- Calculate current billing cycle for each org - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - -- 2fa_has_access: true if enforcing_2fa is false OR (enforcing_2fa is true AND user has 2FA) - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - -- should_redact: true if org enforces 2FA and user doesn't have 2FA - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ), - -- Calculate password policy access status for user/org combinations - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - -- password_has_access: true if no policy OR (has policy AND user meets it) - public.user_meets_password_policy(userid, o.id) AS password_has_access, - -- should_redact: true if org has policy and user doesn't meet it - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- Redact sensitive fields if user doesn't have 2FA or password policy access - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - JOIN two_fa_access tfa ON tfa.org_id = o.id - JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "anon"; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "authenticated"; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO "postgres"; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO "service_role"; - --- Update the get_orgs_v7() wrapper function with updated return type -DROP FUNCTION IF EXISTS public.get_orgs_v7(); - -CREATE OR REPLACE FUNCTION public.get_orgs_v7() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "anon"; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "authenticated"; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "service_role"; - --- ============================================================================ --- Section 9: Update get_orgs_v6 to also check password policy for data redaction --- ============================================================================ - -DROP FUNCTION IF EXISTS public.get_orgs_v6(uuid); - -CREATE FUNCTION public.get_orgs_v6(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - require_apikey_expiration boolean, - max_apikey_expiration_days integer -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - -- should_redact: true if org enforces 2FA and user doesn't have 2FA - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ), - -- Calculate password policy access status for user/org combinations - password_policy_access AS ( - SELECT - o.id AS org_id, - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- Redact sensitive fields if user doesn't have 2FA or password policy access - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration, - o.require_apikey_expiration, - o.max_apikey_expiration_days - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - JOIN two_fa_access tfa ON tfa.org_id = o.id - JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v6(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM "anon"; -REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM "authenticated"; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v6(uuid) TO "postgres"; -GRANT EXECUTE ON FUNCTION public.get_orgs_v6(uuid) TO "service_role"; - --- Update the get_orgs_v6() wrapper to use updated get_orgs_v6(uuid) -DROP FUNCTION IF EXISTS public.get_orgs_v6(); - -CREATE OR REPLACE FUNCTION public.get_orgs_v6() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - require_apikey_expiration boolean, - max_apikey_expiration_days integer -) LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - -- Check if API key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v6(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v6(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v6() OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v6() TO "anon"; -GRANT ALL ON FUNCTION public.get_orgs_v6() TO "authenticated"; -GRANT ALL ON FUNCTION public.get_orgs_v6() TO "service_role"; diff --git a/supabase/migrations/20251228150000_reject_access_due_to_2fa_for_app.sql b/supabase/migrations/20251228150000_reject_access_due_to_2fa_for_app.sql deleted file mode 100644 index bd8a3ce725..0000000000 --- a/supabase/migrations/20251228150000_reject_access_due_to_2fa_for_app.sql +++ /dev/null @@ -1,71 +0,0 @@ --- ============================================================================ --- Public function to check if access should be rejected due to 2FA enforcement --- for a given app. This is intended for CLI and frontend use. --- ============================================================================ - --- Function to check if access should be rejected due to 2FA enforcement for an app --- Takes app_id, gets the owner_org, gets current user identity, and checks 2FA compliance --- Returns true if access should be REJECTED, false if access should be ALLOWED -CREATE OR REPLACE FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) - RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_owner_org uuid; - v_user_id uuid; - v_org_enforcing_2fa boolean; -BEGIN - -- Get the owner organization for this app - SELECT owner_org INTO v_owner_org - FROM public.apps - WHERE public.apps.app_id = reject_access_due_to_2fa_for_app.app_id; - - -- If app not found or no owner_org, reject access - IF v_owner_org IS NULL THEN - RETURN true; - END IF; - - -- Get the current user identity (works for both JWT auth and API key) - -- Using get_identity with key_mode array to support CLI API key authentication - v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); - - -- If no user identity found, reject access - IF v_user_id IS NULL THEN - RETURN true; - END IF; - - -- Check if org has 2FA enforcement enabled - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE public.orgs.id = v_owner_org; - - -- If org not found, reject access - IF v_org_enforcing_2fa IS NULL THEN - RETURN true; - END IF; - - -- If org does not enforce 2FA, allow access - IF v_org_enforcing_2fa = false THEN - RETURN false; - END IF; - - -- If org enforces 2FA and user doesn't have 2FA enabled, reject access - -- Use has_2fa_enabled(user_id) to check the specific user (works for API key auth) - IF v_org_enforcing_2fa = true AND NOT public.has_2fa_enabled(v_user_id) THEN - RETURN true; - END IF; - - -- Otherwise, allow access - RETURN false; -END; -$$; - -ALTER FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) OWNER TO "postgres"; - --- Grant permissions - accessible to authenticated, anon (for API key usage), and service_role --- Note: anon is needed because API key requests come in as anon role with capgkey header -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) TO "service_role"; - diff --git a/supabase/migrations/20251228160000_get_org_members_apikey_support.sql b/supabase/migrations/20251228160000_get_org_members_apikey_support.sql deleted file mode 100644 index 76bdf8a161..0000000000 --- a/supabase/migrations/20251228160000_get_org_members_apikey_support.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Update get_org_members to support API key authentication --- Previously used auth.uid() which only works with JWT authentication --- Now uses get_identity() which supports both JWT and API key authentication - -CREATE OR REPLACE FUNCTION "public"."get_org_members" ("guild_id" "uuid") RETURNS TABLE ( - "aid" bigint, - "uid" "uuid", - "email" character varying, - "image_url" character varying, - "role" "public"."user_min_right", - "is_tmp" boolean -) LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - v_user_id uuid; -BEGIN - -- Get user ID supporting both JWT and API key authentication - v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); - - IF NOT (public.check_min_rights('read'::public.user_min_right, v_user_id, get_org_members.guild_id, NULL::character varying, NULL::bigint)) THEN - PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('guild_id', get_org_members.guild_id, 'uid', v_user_id)); - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - RETURN QUERY SELECT * FROM public.get_org_members(v_user_id, get_org_members.guild_id); -END; -$$; - --- Revoke public access to inner function to prevent bypassing authorization --- The inner function should only be called by the wrapper or service_role -REVOKE ALL ON FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) FROM "authenticated"; - diff --git a/supabase/migrations/20251228215402_add_orphan_images_cleanup.sql b/supabase/migrations/20251228215402_add_orphan_images_cleanup.sql deleted file mode 100644 index 0015c88e12..0000000000 --- a/supabase/migrations/20251228215402_add_orphan_images_cleanup.sql +++ /dev/null @@ -1,342 +0,0 @@ --- Add orphan images cleanup to the existing queue processing system --- Also introduces a table-driven approach for cron tasks to make them more maintainable - --- Create the queue for orphan image cleanup -SELECT pgmq.create('cron_clean_orphan_images'); - --- Create enum for task types -DO $$ BEGIN - CREATE TYPE public.cron_task_type AS ENUM ( - 'function', -- Call a SQL function directly - 'queue', -- Send a message to a pgmq queue - 'function_queue' -- Process a function queue with batch size - ); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - --- Create the cron_tasks table -CREATE TABLE IF NOT EXISTS public.cron_tasks ( - id serial PRIMARY KEY, - name text NOT NULL UNIQUE, - description text, - task_type public.cron_task_type NOT NULL DEFAULT 'function', - -- For 'function' type: the function to call (e.g., 'public.cleanup_queue_messages') - -- For 'queue' type: the queue name to send message to - -- For 'function_queue' type: array of queue names as JSON - target text NOT NULL, - -- Optional batch size for function_queue type - batch_size int, - -- Optional payload for queue type (as JSONB) - payload jsonb, - -- Schedule configuration - -- Run every N seconds (e.g., 10 for every 10 seconds) - second_interval int, - -- Run every N minutes (e.g., 5 for every 5 minutes) - minute_interval int, - hour_interval int, -- Run every N hours (e.g., 2 for every 2 hours) - run_at_hour int, -- Run at specific hour (0-23) - run_at_minute int, -- Run at specific minute (0-59) - run_at_second int DEFAULT 0,-- Run at specific second (0-59) - -- Run on specific day of week (0=Sunday, 6=Saturday) - run_on_dow int, - run_on_day int, -- Run on specific day of month (1-31) - enabled boolean NOT NULL DEFAULT true, - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW() -); - --- Create index for enabled tasks -CREATE INDEX IF NOT EXISTS idx_cron_tasks_enabled ON public.cron_tasks ( - enabled -) WHERE enabled -= true; - --- Security: Restrict access to cron_tasks table to service_role only -REVOKE ALL ON public.cron_tasks FROM public; -REVOKE ALL ON SEQUENCE public.cron_tasks_id_seq FROM public; -GRANT ALL ON public.cron_tasks TO service_role; -GRANT ALL ON SEQUENCE public.cron_tasks_id_seq TO service_role; -ALTER TABLE public.cron_tasks ENABLE ROW LEVEL SECURITY; - --- Insert all existing cron tasks -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - batch_size, - second_interval, - minute_interval, - hour_interval, - run_at_hour, - run_at_minute, - run_at_second, - run_on_dow, - run_on_day -) VALUES --- Every 10 seconds: High-frequency queues -( - 'high_frequency_queues', - 'Process high-frequency event queues', - 'function_queue', - '["on_channel_update", "on_user_create", "on_user_update", "on_version_create", "on_version_delete", "on_version_update", "on_app_delete", "on_organization_create", "on_user_delete", "on_app_create", "credit_usage_alerts"]', - null, 10, null, null, null, null, null, null, null -), - -( - 'channel_device_counts', 'Process channel device counts queue', 'function', - 'public.process_channel_device_counts_queue(1000)', - null, 10, null, null, null, null, null, null, null -), - --- Every minute -( - 'delete_marked_accounts', 'Delete accounts marked for deletion', 'function', - 'public.delete_accounts_marked_for_deletion()', - null, null, 1, null, null, null, 0, null, null -), - -( - 'per_minute_queues', 'Process per-minute queues', 'function_queue', - '["cron_sync_sub", "cron_stat_app"]', - 10, null, 1, null, null, null, 0, null, null -), - -( - 'manifest_create_queue', 'Process manifest create queue', 'function_queue', - '["on_manifest_create"]', - null, null, 1, null, null, null, 0, null, null -), - -( - 'orphan_images_queue', - 'Process orphan images cleanup queue', - 'function_queue', - '["cron_clean_orphan_images"]', - null, null, 1, null, null, null, 0, null, null -), - --- Every 5 minutes -( - 'org_stats_queue', 'Process org stats queue', 'function_queue', - '["cron_stat_org"]', - 10, null, 5, null, null, null, 0, null, null -), - --- Every hour -( - 'cleanup_job_details', 'Cleanup frequent job details', 'function', - 'public.cleanup_frequent_job_details()', - null, null, null, null, null, 0, 0, null, null -), - -( - 'deploy_install_stats_email', - 'Process deploy install stats email', - 'function', - 'public.process_deploy_install_stats_email()', - null, null, null, null, null, 0, 0, null, null -), - --- Every 2 hours -( - 'low_frequency_queues', 'Process low-frequency queues', 'function_queue', - '["admin_stats", "cron_email", "on_organization_delete", "on_deploy_history_create", "cron_clear_versions"]', - null, null, null, 2, null, 0, 0, null, null -), - --- Every 6 hours -( - 'stats_jobs', 'Process cron stats jobs', 'function', - 'public.process_cron_stats_jobs()', - null, null, null, 6, null, 0, 0, null, null -), - --- Daily at 00:00:00 -( - 'cleanup_queue_messages', 'Cleanup old queue messages', 'function', - 'public.cleanup_queue_messages()', - null, null, null, null, 0, 0, 0, null, null -), - -( - 'delete_old_apps', 'Delete old deleted apps', 'function', - 'public.delete_old_deleted_apps()', - null, null, null, null, 0, 0, 0, null, null -), - -( - 'remove_old_jobs', 'Remove old cron jobs', 'function', - 'public.remove_old_jobs()', - null, null, null, null, 0, 0, 0, null, null -), - --- Daily at 00:40:00 -( - 'version_retention', 'Update app versions retention', 'function', - 'public.update_app_versions_retention()', - null, null, null, null, 0, 40, 0, null, null -), - --- Daily at 01:01:00 -( - 'admin_stats', 'Process admin stats', 'function', - 'public.process_admin_stats()', - null, null, null, null, 1, 1, 0, null, null -), - --- Daily at 03:00:00 -( - 'free_trial_expired', 'Process free trial expired', 'function', - 'public.process_free_trial_expired()', - null, null, null, null, 3, 0, 0, null, null -), - -( - 'expire_credits', 'Expire usage credits', 'function', - 'public.expire_usage_credits()', - null, null, null, null, 3, 0, 0, null, null -), - --- Weekly on Sunday at 03:00:00 -( - 'orphan_images_cleanup', 'Queue orphan images cleanup job', 'queue', - 'cron_clean_orphan_images', - null, null, null, null, 3, 0, 0, 0, null -), - --- Daily at 04:00:00 -( - 'sync_sub_jobs', 'Process cron sync sub jobs', 'function', - 'public.process_cron_sync_sub_jobs()', - null, null, null, null, 4, 0, 0, null, null -), - --- Daily at 12:00:00 -( - 'cleanup_job_run_details', 'Cleanup old job run details', 'function', - 'public.cleanup_job_run_details_7days()', - null, null, null, null, 12, 0, 0, null, null -), - --- Weekly on Saturday at 12:00:00 -( - 'weekly_stats_email', 'Process weekly stats email', 'function', - 'public.process_stats_email_weekly()', - null, null, null, null, 12, 0, 0, 6, null -), - --- Monthly on 1st at 12:00:00 -( - 'monthly_stats_email', 'Process monthly stats email', 'function', - 'public.process_stats_email_monthly()', - null, null, null, null, 12, 0, 0, null, 1 -) -ON CONFLICT (name) DO NOTHING; - --- Create helper function to cleanup job run details (extracted from inline SQL) -CREATE OR REPLACE FUNCTION public.cleanup_job_run_details_7days() RETURNS void -LANGUAGE plpgsql -SET search_path TO '' -AS $$ -BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; -END; -$$; - --- Security: internal function only -REVOKE EXECUTE ON FUNCTION public.cleanup_job_run_details_7days() FROM public; -GRANT EXECUTE ON FUNCTION public.cleanup_job_run_details_7days() TO service_role; - --- Create the new table-driven process_all_cron_tasks function -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks() RETURNS void -LANGUAGE plpgsql -SET search_path TO '' -AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; - current_dow int; - current_day int; - task RECORD; - queue_names text[]; - should_run boolean; -BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - current_dow := EXTRACT(DOW FROM NOW()); - current_day := EXTRACT(DAY FROM NOW()); - - -- Loop through all enabled tasks - FOR task IN SELECT * FROM public.cron_tasks WHERE enabled = true LOOP - should_run := false; - - -- Check if task should run based on its schedule - IF task.second_interval IS NOT NULL THEN - -- Run every N seconds - should_run := (current_second % task.second_interval = 0); - ELSIF task.minute_interval IS NOT NULL THEN - -- Run every N minutes at specific second - should_run := (current_minute % task.minute_interval = 0) - AND (current_second = COALESCE(task.run_at_second, 0)); - ELSIF task.hour_interval IS NOT NULL THEN - -- Run every N hours at specific minute and second - should_run := (current_hour % task.hour_interval = 0) - AND (current_minute = COALESCE(task.run_at_minute, 0)) - AND (current_second = COALESCE(task.run_at_second, 0)); - ELSIF task.run_at_hour IS NOT NULL THEN - -- Run at specific time - should_run := (current_hour = task.run_at_hour) - AND (current_minute = COALESCE(task.run_at_minute, 0)) - AND (current_second = COALESCE(task.run_at_second, 0)); - - -- Check day of week constraint - IF should_run AND task.run_on_dow IS NOT NULL THEN - should_run := (current_dow = task.run_on_dow); - END IF; - - -- Check day of month constraint - IF should_run AND task.run_on_day IS NOT NULL THEN - should_run := (current_day = task.run_on_day); - END IF; - END IF; - - -- Execute the task if it should run - IF should_run THEN - BEGIN - CASE task.task_type - WHEN 'function' THEN - EXECUTE 'SELECT ' || task.target; - - WHEN 'queue' THEN - PERFORM pgmq.send( - task.target, - COALESCE(task.payload, jsonb_build_object('function_name', task.target)) - ); - - WHEN 'function_queue' THEN - -- Parse JSON array of queue names - SELECT array_agg(value::text) INTO queue_names - FROM jsonb_array_elements_text(task.target::jsonb); - - IF task.batch_size IS NOT NULL THEN - PERFORM public.process_function_queue(queue_names, task.batch_size); - ELSE - PERFORM public.process_function_queue(queue_names); - END IF; - END CASE; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cron task "%" failed: %', task.name, SQLERRM; - END; - END IF; - END LOOP; -END; -$$; - --- Security: internal function only -REVOKE EXECUTE ON FUNCTION public.process_all_cron_tasks() FROM public; -GRANT EXECUTE ON FUNCTION public.process_all_cron_tasks() TO service_role; diff --git a/supabase/migrations/20251229030503_add_cron_tasks_rls_policy.sql b/supabase/migrations/20251229030503_add_cron_tasks_rls_policy.sql deleted file mode 100644 index 24fa7321d5..0000000000 --- a/supabase/migrations/20251229030503_add_cron_tasks_rls_policy.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Add RLS policy for cron_tasks table --- This table has RLS enabled but was missing the policy --- Only service_role should access this table (service_role bypasses RLS) - -CREATE POLICY "Deny all access" ON public.cron_tasks FOR ALL USING (false) -WITH CHECK (false); diff --git a/supabase/migrations/20251229100000_fix_check_org_members_password_policy_service_role.sql b/supabase/migrations/20251229100000_fix_check_org_members_password_policy_service_role.sql deleted file mode 100644 index b3f718a3c9..0000000000 --- a/supabase/migrations/20251229100000_fix_check_org_members_password_policy_service_role.sql +++ /dev/null @@ -1,61 +0,0 @@ --- ============================================================================ --- Fix check_org_members_password_policy to allow service_role bypass --- ============================================================================ - --- Modify the function to bypass auth check when called by service_role --- This is needed for testing and administrative purposes -CREATE OR REPLACE FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") - RETURNS TABLE ( - "user_id" "uuid", - "email" text, - "first_name" text, - "last_name" text, - "password_policy_compliant" boolean - ) - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_is_service_role boolean; -BEGIN - -- Check if org exists - IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = check_org_members_password_policy.org_id) THEN - RAISE EXCEPTION 'Organization does not exist'; - END IF; - - -- Check if called by service_role or postgres (similar pattern to existing codebase) - v_is_service_role := ( - ((SELECT auth.jwt() ->> 'role') = 'service_role') - OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres') - ); - - -- Allow service_role/postgres to bypass the auth check (for testing and admin purposes) - IF NOT v_is_service_role THEN - -- Check if the current user is a super_admin of the organization - IF NOT ( - public.check_min_rights( - 'super_admin'::public.user_min_right, - (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], check_org_members_password_policy.org_id)), - check_org_members_password_policy.org_id, - NULL::character varying, - NULL::bigint - ) - ) THEN - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - END IF; - - -- Return list of org members with their password policy compliance status - RETURN QUERY - SELECT - ou.user_id, - au.email::text, - u.first_name::text, - u.last_name::text, - public.user_meets_password_policy(ou.user_id, check_org_members_password_policy.org_id) AS "password_policy_compliant" - FROM public.org_users ou - JOIN auth.users au ON au.id = ou.user_id - LEFT JOIN public.users u ON u.id = ou.user_id - WHERE ou.org_id = check_org_members_password_policy.org_id; -END; -$$; diff --git a/supabase/migrations/20251229233706_replace_uuid_generate_v4_with_gen_random_uuid.sql b/supabase/migrations/20251229233706_replace_uuid_generate_v4_with_gen_random_uuid.sql deleted file mode 100644 index 41866f5461..0000000000 --- a/supabase/migrations/20251229233706_replace_uuid_generate_v4_with_gen_random_uuid.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Migration: Replace extensions.uuid_generate_v4() with gen_random_uuid() --- --- gen_random_uuid() is: --- - Built-in since PostgreSQL 13 (no extension needed) --- - ~3.5x faster than uuid_generate_v4() --- - Functionally equivalent (both generate UUID v4) --- - Recommended by PostgreSQL documentation - --- Update apps table -ALTER TABLE public.apps -ALTER COLUMN id SET DEFAULT gen_random_uuid(); - --- Update build_logs table -ALTER TABLE public.build_logs -ALTER COLUMN id SET DEFAULT gen_random_uuid(); - --- Update build_requests table -ALTER TABLE public.build_requests -ALTER COLUMN id SET DEFAULT gen_random_uuid(); - --- Update deleted_account table -ALTER TABLE public.deleted_account -ALTER COLUMN id SET DEFAULT gen_random_uuid(); - --- Update plans table -ALTER TABLE public.plans -ALTER COLUMN id SET DEFAULT gen_random_uuid(); - --- Update usage_credit_grants table -ALTER TABLE public.usage_credit_grants -ALTER COLUMN id SET DEFAULT gen_random_uuid(); - --- Update usage_overage_events table -ALTER TABLE public.usage_overage_events -ALTER COLUMN id SET DEFAULT gen_random_uuid(); - -DROP EXTENSION IF EXISTS "uuid-ossp"; diff --git a/supabase/migrations/20251230114041_reject_access_due_to_2fa_for_org.sql b/supabase/migrations/20251230114041_reject_access_due_to_2fa_for_org.sql deleted file mode 100644 index ebb25fc052..0000000000 --- a/supabase/migrations/20251230114041_reject_access_due_to_2fa_for_org.sql +++ /dev/null @@ -1,62 +0,0 @@ --- ============================================================================ --- Public function to check if access should be rejected due to 2FA enforcement --- for a given org. This is intended for CLI and frontend use. --- ============================================================================ - --- Function to check if access should be rejected due to 2FA enforcement for an org --- Takes org_id directly, gets current user identity, and checks 2FA compliance --- Returns true if access should be REJECTED, false if access should be ALLOWED -CREATE OR REPLACE FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" uuid) - RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_user_id uuid; - v_org_enforcing_2fa boolean; -BEGIN - -- Get the current user identity (works for both JWT auth and API key) - -- NOTE: We use get_identity_org_allowed (not get_identity like the app version) because - -- this function takes an org_id directly, so we must validate that the API key - -- has access to this specific org before checking 2FA compliance. - -- This prevents org-limited API keys from bypassing org access restrictions. - v_user_id := public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], reject_access_due_to_2fa_for_org.org_id); - - -- If no user identity found, reject access - IF v_user_id IS NULL THEN - RETURN true; - END IF; - - -- Check if org has 2FA enforcement enabled - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE public.orgs.id = reject_access_due_to_2fa_for_org.org_id; - - -- If org not found, allow access (no 2FA enforcement can apply to a non-existent org) - IF v_org_enforcing_2fa IS NULL THEN - RETURN false; - END IF; - - -- If org does not enforce 2FA, allow access - IF v_org_enforcing_2fa = false THEN - RETURN false; - END IF; - - -- If org enforces 2FA and user doesn't have 2FA enabled, reject access - -- Use has_2fa_enabled(user_id) to check the specific user (works for API key auth) - IF v_org_enforcing_2fa = true AND NOT public.has_2fa_enabled(v_user_id) THEN - RETURN true; - END IF; - - -- Otherwise, allow access - RETURN false; -END; -$$; - -ALTER FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" uuid) OWNER TO "postgres"; - --- Grant permissions - accessible to authenticated, anon (for API key usage), and service_role --- Note: anon is needed because API key requests come in as anon role with capgkey header -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" uuid) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" uuid) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" uuid) TO "service_role"; diff --git a/supabase/migrations/20251231060433_add_billing_period_stats_email.sql b/supabase/migrations/20251231060433_add_billing_period_stats_email.sql deleted file mode 100644 index 939ad94342..0000000000 --- a/supabase/migrations/20251231060433_add_billing_period_stats_email.sql +++ /dev/null @@ -1,324 +0,0 @@ --- Add billing period stats email functionality --- This email is sent on each organization's billing anniversary date (renewal day) --- with their usage stats for the billing period - --- Add billing_period_stats preference to existing email_preferences --- Set it to true by default for all existing users and orgs -UPDATE public.users -SET - email_preferences - = email_preferences || '{"billing_period_stats": true}'::jsonb -WHERE - email_preferences IS NOT NULL - AND NOT (email_preferences ? 'billing_period_stats'); - -UPDATE public.orgs -SET - email_preferences - = email_preferences || '{"billing_period_stats": true}'::jsonb -WHERE - email_preferences IS NOT NULL - AND NOT (email_preferences ? 'billing_period_stats'); - --- Update the default value for email_preferences on users table -ALTER TABLE public.users -ALTER COLUMN email_preferences SET DEFAULT '{ - "usage_limit": true, - "credit_usage": true, - "onboarding": true, - "weekly_stats": true, - "monthly_stats": true, - "billing_period_stats": true, - "deploy_stats_24h": true, - "bundle_created": true, - "bundle_deployed": true, - "device_error": true, - "channel_self_rejected": true -}'::jsonb; - --- Update the default value for email_preferences on orgs table -ALTER TABLE public.orgs -ALTER COLUMN email_preferences SET DEFAULT '{ - "usage_limit": true, - "credit_usage": true, - "onboarding": true, - "weekly_stats": true, - "monthly_stats": true, - "billing_period_stats": true, - "deploy_stats_24h": true, - "bundle_created": true, - "bundle_deployed": true, - "device_error": true, - "channel_self_rejected": true -}'::jsonb; - --- Update column comments -COMMENT ON COLUMN public.users.email_preferences IS 'Per-user email notification preferences. Keys: usage_limit, credit_usage, onboarding, weekly_stats, monthly_stats, billing_period_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected. Values are booleans.'; -COMMENT ON COLUMN public.orgs.email_preferences IS 'JSONB object containing email notification preferences for the organization. When enabled, emails are also sent to the management_email if it differs from admin user emails. Keys: usage_limit, credit_usage, onboarding, weekly_stats, monthly_stats, billing_period_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected. All default to true.'; - --- Create the function to process billing period stats emails --- This function finds all orgs whose billing cycle ended TODAY (the previous cycle) --- and queues emails with their usage stats for that completed billing period -CREATE OR REPLACE FUNCTION public.process_billing_period_stats_email() RETURNS void -LANGUAGE plpgsql -SET search_path TO '' -AS $$ -DECLARE - org_record RECORD; -BEGIN - -- Find all orgs whose billing cycle ends today - -- We calculate the PREVIOUS cycle's dates to ensure we report on completed data - FOR org_record IN ( - SELECT - o.id AS org_id, - o.management_email, - si.subscription_anchor_start, - -- Calculate the previous billing cycle dates - -- We use (NOW() - interval '1 day') to get yesterday's cycle end date calculation - -- This ensures we're always looking at the just-completed cycle - CASE - WHEN COALESCE( - si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), - '0 DAYS'::INTERVAL - ) > (NOW() - interval '1 day') - date_trunc('MONTH', NOW() - interval '1 day') - THEN date_trunc('MONTH', (NOW() - interval '1 day') - INTERVAL '1 MONTH') + - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW() - interval '1 day') + - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS prev_cycle_start, - CASE - WHEN COALESCE( - si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), - '0 DAYS'::INTERVAL - ) > (NOW() - interval '1 day') - date_trunc('MONTH', NOW() - interval '1 day') - THEN (date_trunc('MONTH', (NOW() - interval '1 day') - INTERVAL '1 MONTH') + - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL)) + INTERVAL '1 MONTH' - ELSE (date_trunc('MONTH', NOW() - interval '1 day') + - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL)) + INTERVAL '1 MONTH' - END AS prev_cycle_end - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE si.status = 'succeeded' - AND o.management_email IS NOT NULL - ) - LOOP - -- If today is the billing cycle end date, queue the email - -- We pass the calculated previous cycle dates to ensure correct data - IF org_record.prev_cycle_end::date = CURRENT_DATE THEN - PERFORM pgmq.send('cron_email', - jsonb_build_object( - 'function_name', 'cron_email', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'email', org_record.management_email, - 'orgId', org_record.org_id, - 'type', 'billing_period_stats', - 'cycleStart', org_record.prev_cycle_start, - 'cycleEnd', org_record.prev_cycle_end - ) - ) - ); - END IF; - END LOOP; -END; -$$; - --- Security: internal function only - only service_role can execute -REVOKE EXECUTE ON FUNCTION public.process_billing_period_stats_email() FROM public; -GRANT EXECUTE ON FUNCTION public.process_billing_period_stats_email() TO service_role; - --- Update process_all_cron_tasks to include billing period stats email at 12:00 UTC daily -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks() RETURNS void LANGUAGE plpgsql -SET -search_path = '' AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; -BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - - -- Every 10 seconds: High-frequency queues (at :00, :10, :20, :30, :40, :50) - IF current_second % 10 = 0 THEN - -- Process high-frequency queues with default batch size (950) - BEGIN - PERFORM public.process_function_queue(ARRAY['on_channel_update', 'on_user_create', 'on_user_update', 'on_version_create', 'on_version_delete', 'on_version_update', 'on_app_delete', 'on_organization_create', 'on_user_delete', 'on_app_create', 'credit_usage_alerts']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (high-frequency) failed: %', SQLERRM; - END; - - -- Process channel device counts with batch size 1000 - BEGIN - PERFORM public.process_channel_device_counts_queue(1000); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_channel_device_counts_queue failed: %', SQLERRM; - END; - - END IF; - - -- Every minute (at :00 seconds): Per-minute tasks - IF current_second = 0 THEN - BEGIN - PERFORM public.delete_accounts_marked_for_deletion(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_accounts_marked_for_deletion failed: %', SQLERRM; - END; - - -- Process with batch size 10 - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_sync_sub', 'cron_stat_app'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (per-minute) failed: %', SQLERRM; - END; - - -- on_manifest_create uses default batch size - BEGIN - PERFORM public.process_function_queue(ARRAY['on_manifest_create']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (manifest_create) failed: %', SQLERRM; - END; - END IF; - - -- Every 5 minutes (at :00 seconds): Org stats with batch size 10 - IF current_minute % 5 = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['cron_stat_org'], 10); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (cron_stat_org) failed: %', SQLERRM; - END; - END IF; - - -- Every hour (at :00:00): Hourly cleanup - IF current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_frequent_job_details(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_frequent_job_details failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.process_deploy_install_stats_email(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_deploy_install_stats_email failed: %', SQLERRM; - END; - END IF; - - -- Every 2 hours (at :00:00): Low-frequency queues with default batch size - IF current_hour % 2 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_function_queue(ARRAY['admin_stats', 'cron_email', 'on_organization_delete', 'on_deploy_history_create', 'cron_clear_versions']); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_function_queue (low-frequency) failed: %', SQLERRM; - END; - END IF; - - -- Every 6 hours (at :00:00): Stats jobs - IF current_hour % 6 = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_stats_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_stats_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:00:00 - Midnight tasks - IF current_hour = 0 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.cleanup_queue_messages(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup_queue_messages failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.delete_old_deleted_apps(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'delete_old_deleted_apps failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.remove_old_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'remove_old_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 00:40:00 - Old app version retention - IF current_hour = 0 AND current_minute = 40 AND current_second = 0 THEN - BEGIN - PERFORM public.update_app_versions_retention(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'update_app_versions_retention failed: %', SQLERRM; - END; - END IF; - - -- Daily at 01:01:00 - Admin stats creation - IF current_hour = 1 AND current_minute = 1 AND current_second = 0 THEN - BEGIN - PERFORM public.process_admin_stats(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_admin_stats failed: %', SQLERRM; - END; - END IF; - - -- Daily at 03:00:00 - Free trial and credits - IF current_hour = 3 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_free_trial_expired(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_free_trial_expired failed: %', SQLERRM; - END; - - BEGIN - PERFORM public.expire_usage_credits(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'expire_usage_credits failed: %', SQLERRM; - END; - END IF; - - -- Daily at 04:00:00 - Sync sub scheduler - IF current_hour = 4 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - PERFORM public.process_cron_sync_sub_jobs(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_cron_sync_sub_jobs failed: %', SQLERRM; - END; - END IF; - - -- Daily at 12:00:00 - Noon tasks - IF current_hour = 12 AND current_minute = 0 AND current_second = 0 THEN - BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cleanup job_run_details failed: %', SQLERRM; - END; - - -- Billing period stats email (daily at noon) - BEGIN - PERFORM public.process_billing_period_stats_email(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_billing_period_stats_email failed: %', SQLERRM; - END; - - -- Weekly stats email (every Saturday at noon) - IF EXTRACT(DOW FROM NOW()) = 6 THEN - BEGIN - PERFORM public.process_stats_email_weekly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_weekly failed: %', SQLERRM; - END; - END IF; - - -- Monthly stats email (1st of month at noon) - IF EXTRACT(DAY FROM NOW()) = 1 THEN - BEGIN - PERFORM public.process_stats_email_monthly(); - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_stats_email_monthly failed: %', SQLERRM; - END; - END IF; - END IF; -END; -$$; diff --git a/supabase/migrations/20260101042511_enforce_encrypted_bundles.sql b/supabase/migrations/20260101042511_enforce_encrypted_bundles.sql deleted file mode 100644 index 32582023c8..0000000000 --- a/supabase/migrations/20260101042511_enforce_encrypted_bundles.sql +++ /dev/null @@ -1,665 +0,0 @@ --- ============================================================================ --- Enforce Encrypted Bundles for Organizations --- ============================================================================ --- This migration adds support for enforcing encrypted bundles at the --- organization level. When enabled, all bundles uploaded to apps in the --- organization must include encryption data (session_key). --- --- Optional: Organizations can also require a specific encryption key by --- setting required_encryption_key (first 21 chars of public key). When set, --- only bundles encrypted with that specific key will be accepted. --- ============================================================================ - --- ============================================================================ --- Section 1: Add enforce_encrypted_bundles and required_encryption_key columns --- ============================================================================ - --- Add organization-level enforcement setting -ALTER TABLE "public"."orgs" -ADD COLUMN IF NOT EXISTS "enforce_encrypted_bundles" boolean NOT NULL DEFAULT false; - --- Add optional required encryption key fingerprint (first 21 chars of base64 public key) -ALTER TABLE "public"."orgs" -ADD COLUMN IF NOT EXISTS "required_encryption_key" character varying(21) DEFAULT NULL; - --- Add comments to document the columns -COMMENT ON COLUMN "public"."orgs"."enforce_encrypted_bundles" IS 'When true, all bundles uploaded to this organization must be encrypted (have session_key set). Unencrypted bundles will be rejected.'; -COMMENT ON COLUMN "public"."orgs"."required_encryption_key" IS 'Optional: First 21 characters of the base64-encoded public key. When set, only bundles encrypted with this specific key (matching key_id) will be accepted.'; - --- ============================================================================ --- Section 2: Create helper function to check if a bundle is encrypted --- ============================================================================ - --- Function to check if a bundle (app_version) is encrypted -CREATE OR REPLACE FUNCTION "public"."is_bundle_encrypted"( - "session_key" text -) RETURNS boolean -LANGUAGE "plpgsql" IMMUTABLE -SET "search_path" TO '' -AS $$ -BEGIN - -- A bundle is considered encrypted if it has a non-empty session_key - RETURN session_key IS NOT NULL; -END; -$$; - -ALTER FUNCTION "public"."is_bundle_encrypted"(text) OWNER TO "postgres"; - --- Grant permissions -GRANT EXECUTE ON FUNCTION "public"."is_bundle_encrypted"(text) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_bundle_encrypted"(text) TO "service_role"; - --- ============================================================================ --- Section 3: Create function to check org encryption enforcement --- ============================================================================ - --- Function to check if an org requires encrypted bundles --- Returns true if upload should be allowed, false if it should be rejected -CREATE OR REPLACE FUNCTION "public"."check_org_encrypted_bundle_enforcement"( - "org_id" uuid, - "session_key" text -) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - org_enforcing boolean; - is_encrypted boolean; -BEGIN - -- Check if org exists and get enforcement setting - SELECT enforce_encrypted_bundles INTO org_enforcing - FROM public.orgs - WHERE id = check_org_encrypted_bundle_enforcement.org_id; - - IF NOT FOUND THEN - RETURN true; -- Org not found, allow (will fail on other checks) - END IF; - - -- If org doesn't enforce encrypted bundles, allow - IF org_enforcing = false THEN - RETURN true; - END IF; - - -- Check if this bundle is encrypted - is_encrypted := public.is_bundle_encrypted(session_key); - - IF NOT is_encrypted THEN - PERFORM public.pg_log('deny: ORG_REQUIRES_ENCRYPTED_BUNDLES', - jsonb_build_object('org_id', org_id)); - RETURN false; - END IF; - - RETURN true; -END; -$$; - -ALTER FUNCTION "public"."check_org_encrypted_bundle_enforcement"(uuid, text) OWNER TO "postgres"; - --- Grant permissions -GRANT EXECUTE ON FUNCTION "public"."check_org_encrypted_bundle_enforcement"(uuid, text) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."check_org_encrypted_bundle_enforcement"(uuid, text) TO "service_role"; - --- ============================================================================ --- Section 4: Database Trigger to Enforce Encrypted Bundles on INSERT --- ============================================================================ --- This trigger runs BEFORE INSERT on app_versions to enforce encrypted bundles --- at the database level, preventing bypass through direct SDK inserts. - -CREATE OR REPLACE FUNCTION "public"."check_encrypted_bundle_on_insert"() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - org_id uuid; - org_enforcing boolean; - org_required_key varchar(21); - bundle_is_encrypted boolean; - bundle_key_id varchar(20); -BEGIN - -- Derive org_id from app_id directly to avoid trigger ordering issues. - -- The force_valid_owner_org_app_versions trigger runs after this one - -- (alphabetically), so NEW.owner_org may not be populated yet. - -- We look up the org from the apps table using the app_id. - IF NEW.owner_org IS NOT NULL THEN - org_id := NEW.owner_org; - ELSE - SELECT apps.owner_org INTO org_id - FROM public.apps - WHERE apps.app_id = NEW.app_id; - END IF; - - -- If org not found, allow (will fail on other checks) - IF org_id IS NULL THEN - RETURN NEW; - END IF; - - -- Get the org's enforcement settings - SELECT enforce_encrypted_bundles, required_encryption_key - INTO org_enforcing, org_required_key - FROM public.orgs - WHERE id = org_id; - - -- If org doesn't exist or doesn't enforce encrypted bundles, allow - IF org_enforcing IS NULL OR org_enforcing = false THEN - RETURN NEW; - END IF; - - -- Check if this bundle is encrypted (has a non-empty session_key) - bundle_is_encrypted := NEW.session_key IS NOT NULL AND NEW.session_key <> ''; - bundle_key_id := NEW.key_id; - - IF NOT bundle_is_encrypted THEN - -- Log the rejection for audit - PERFORM public.pg_log('deny: ORG_REQUIRES_ENCRYPTED_BUNDLES_TRIGGER', - jsonb_build_object( - 'org_id', org_id, - 'app_id', NEW.app_id, - 'version_name', NEW.name, - 'user_id', NEW.user_id, - 'reason', 'not_encrypted' - )); - RAISE EXCEPTION 'encryption_required: This organization requires all bundles to be encrypted. Please upload an encrypted bundle with a session_key.'; - END IF; - - -- If org requires a specific key, check the key_id matches - IF org_required_key IS NOT NULL AND org_required_key <> '' THEN - -- Bundle must have a key_id that starts with the required key fingerprint - IF bundle_key_id IS NULL OR bundle_key_id = '' THEN - PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', - jsonb_build_object( - 'org_id', org_id, - 'app_id', NEW.app_id, - 'version_name', NEW.name, - 'user_id', NEW.user_id, - 'required_key', org_required_key, - 'bundle_key_id', bundle_key_id, - 'reason', 'missing_key_id' - )); - RAISE EXCEPTION 'encryption_key_required: This organization requires bundles to be encrypted with a specific key. The uploaded bundle does not have a key_id.'; - END IF; - - -- Check if the bundle's key_id starts with the required key fingerprint - -- We use starts_with because key_id is 20 chars and required_encryption_key is up to 21 chars - IF NOT (bundle_key_id = LEFT(org_required_key, 20) OR LEFT(bundle_key_id, LENGTH(org_required_key)) = org_required_key) THEN - PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', - jsonb_build_object( - 'org_id', org_id, - 'app_id', NEW.app_id, - 'version_name', NEW.name, - 'user_id', NEW.user_id, - 'required_key', org_required_key, - 'bundle_key_id', bundle_key_id, - 'reason', 'key_mismatch' - )); - RAISE EXCEPTION 'encryption_key_mismatch: This organization requires bundles to be encrypted with a specific key. The uploaded bundle was encrypted with a different key.'; - END IF; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."check_encrypted_bundle_on_insert"() OWNER TO "postgres"; - --- Create the trigger on app_versions table -DROP TRIGGER IF EXISTS enforce_encrypted_bundle_trigger ON public.app_versions; - -CREATE TRIGGER enforce_encrypted_bundle_trigger - BEFORE INSERT ON public.app_versions - FOR EACH ROW - EXECUTE FUNCTION public.check_encrypted_bundle_on_insert(); - --- Grant permissions -GRANT EXECUTE ON FUNCTION "public"."check_encrypted_bundle_on_insert"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."check_encrypted_bundle_on_insert"() TO "service_role"; - --- ============================================================================ --- Section 5: Update get_orgs_v7 to include enforce_encrypted_bundles --- ============================================================================ - --- Drop and recreate get_orgs_v7(userid uuid) to add enforce_encrypted_bundles field -DROP FUNCTION IF EXISTS public.get_orgs_v7(uuid); - -CREATE FUNCTION public.get_orgs_v7(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > now()) - AND si.subscription_anchor_end > now()) - OR si.trial_at > now() - ) - ), - -- Calculate current billing cycle for each org - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > now() - date_trunc('MONTH', now()) - THEN date_trunc('MONTH', now() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', now()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - -- 2fa_has_access: true if enforcing_2fa is false OR (enforcing_2fa is true AND user has 2FA) - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - -- should_redact_2fa: true if org enforces 2FA and user doesn't have 2FA - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ), - -- Calculate password policy access status for user/org combinations - password_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - public.user_meets_password_policy(userid, o.id) AS "password_has_access", - -- should_redact_password: true if org has policy and user doesn't meet it - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- Redact sensitive fields if user doesn't have 2FA or password policy access - CASE - WHEN tfa.should_redact_2fa OR pa.should_redact_password THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR pa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - now()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR pa.should_redact_password THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - now()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR pa.should_redact_password THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR pa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR pa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR pa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR pa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR pa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', now()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - pa.password_policy_config, - pa."password_has_access", - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - JOIN two_fa_access tfa ON tfa.org_id = o.id - JOIN password_access pa ON pa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "anon"; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "authenticated"; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO "postgres"; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO "service_role"; - --- ============================================================================ --- Section 6: Update get_orgs_v7() wrapper to match new signature --- ============================================================================ - -DROP FUNCTION IF EXISTS public.get_orgs_v7(); - -CREATE OR REPLACE FUNCTION public.get_orgs_v7() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - -- Check if API key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "anon"; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "authenticated"; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO "service_role"; - --- ============================================================================ --- Section 7: Functions for counting and deleting non-compliant bundles --- ============================================================================ --- These functions are used when enabling encryption enforcement to: --- 1. Count how many bundles would be affected (for UI warning) --- 2. Mark non-compliant bundles as deleted when enforcement is enabled - --- Function to count non-compliant bundles for an organization --- Returns the count of bundles that would be marked as deleted if enforcement is enabled --- SECURITY: Caller must be a super_admin of the organization -CREATE OR REPLACE FUNCTION "public"."count_non_compliant_bundles"( - "org_id" uuid, - "required_key" text DEFAULT NULL -) RETURNS TABLE ( - non_encrypted_count bigint, - wrong_key_count bigint, - total_non_compliant bigint -) -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - non_encrypted bigint := 0; - wrong_key bigint := 0; - caller_user_id uuid; - caller_right public.user_min_right; -BEGIN - -- Get the current user's ID (supports both JWT and API key authentication) - SELECT public.get_identity('{read,upload,write,all}'::public.key_mode[]) INTO caller_user_id; - - IF caller_user_id IS NULL THEN - RAISE EXCEPTION 'Unauthorized: Authentication required'; - END IF; - - -- Check if the caller is a super_admin of this organization - SELECT user_right INTO caller_right - FROM public.org_users - WHERE org_users.user_id = caller_user_id - AND org_users.org_id = count_non_compliant_bundles.org_id; - - IF caller_right IS NULL OR caller_right <> 'super_admin'::public.user_min_right THEN - RAISE EXCEPTION 'Unauthorized: Only super_admin can access this function'; - END IF; - - -- Count bundles without encryption (no session_key) - SELECT COUNT(*) INTO non_encrypted - FROM public.app_versions av - JOIN public.apps a ON a.app_id = av.app_id - WHERE a.owner_org = count_non_compliant_bundles.org_id - AND av.deleted = false - AND (av.session_key IS NULL OR av.session_key = ''); - - -- Count bundles with wrong key (if required_key is specified) - IF required_key IS NOT NULL AND required_key <> '' THEN - SELECT COUNT(*) INTO wrong_key - FROM public.app_versions av - JOIN public.apps a ON a.app_id = av.app_id - WHERE a.owner_org = count_non_compliant_bundles.org_id - AND av.deleted = false - AND av.session_key IS NOT NULL - AND av.session_key <> '' - AND ( - av.key_id IS NULL - OR av.key_id = '' - OR NOT (av.key_id = LEFT(required_key, 20) OR LEFT(av.key_id, LENGTH(required_key)) = required_key) - ); - END IF; - - RETURN QUERY SELECT non_encrypted, wrong_key, (non_encrypted + wrong_key); -END; -$$; - -ALTER FUNCTION "public"."count_non_compliant_bundles"(uuid, text) OWNER TO "postgres"; - -GRANT EXECUTE ON FUNCTION "public"."count_non_compliant_bundles"(uuid, text) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."count_non_compliant_bundles"(uuid, text) TO "service_role"; - --- Function to mark non-compliant bundles as deleted when enabling enforcement --- This is called when the user confirms they want to enable enforcement --- Returns the number of bundles that were marked as deleted --- SECURITY: Caller must be a super_admin of the organization -CREATE OR REPLACE FUNCTION "public"."delete_non_compliant_bundles"( - "org_id" uuid, - "required_key" text DEFAULT NULL -) RETURNS bigint -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - deleted_count bigint := 0; - bundle_ids bigint[]; - caller_user_id uuid; - caller_right public.user_min_right; -BEGIN - -- Get the current user's ID (supports both JWT and API key authentication) - SELECT public.get_identity('{read,upload,write,all}'::public.key_mode[]) INTO caller_user_id; - - IF caller_user_id IS NULL THEN - RAISE EXCEPTION 'Unauthorized: Authentication required'; - END IF; - - -- Check if the caller is a super_admin of this organization - SELECT user_right INTO caller_right - FROM public.org_users - WHERE org_users.user_id = caller_user_id - AND org_users.org_id = delete_non_compliant_bundles.org_id; - - IF caller_right IS NULL OR caller_right <> 'super_admin'::public.user_min_right THEN - RAISE EXCEPTION 'Unauthorized: Only super_admin can access this function'; - END IF; - - -- First, collect all bundle IDs that will be deleted - IF required_key IS NULL OR required_key = '' THEN - -- Only delete non-encrypted bundles - SELECT ARRAY_AGG(av.id) INTO bundle_ids - FROM public.app_versions av - JOIN public.apps a ON a.app_id = av.app_id - WHERE a.owner_org = delete_non_compliant_bundles.org_id - AND av.deleted = false - AND (av.session_key IS NULL OR av.session_key = ''); - ELSE - -- Delete non-encrypted bundles AND bundles with wrong key - SELECT ARRAY_AGG(av.id) INTO bundle_ids - FROM public.app_versions av - JOIN public.apps a ON a.app_id = av.app_id - WHERE a.owner_org = delete_non_compliant_bundles.org_id - AND av.deleted = false - AND ( - -- Non-encrypted bundles - (av.session_key IS NULL OR av.session_key = '') - OR - -- Encrypted but with wrong key - ( - av.session_key IS NOT NULL - AND av.session_key <> '' - AND ( - av.key_id IS NULL - OR av.key_id = '' - OR NOT (av.key_id = LEFT(required_key, 20) OR LEFT(av.key_id, LENGTH(required_key)) = required_key) - ) - ) - ); - END IF; - - -- If there are bundles to delete, mark them as deleted - IF bundle_ids IS NOT NULL AND array_length(bundle_ids, 1) > 0 THEN - UPDATE public.app_versions - SET deleted = true - WHERE id = ANY(bundle_ids); - - deleted_count := array_length(bundle_ids, 1); - - -- Log the action - PERFORM public.pg_log('action: DELETED_NON_COMPLIANT_BUNDLES', - jsonb_build_object( - 'org_id', org_id, - 'required_key', required_key, - 'deleted_count', deleted_count, - 'bundle_ids', bundle_ids, - 'caller_user_id', caller_user_id - )); - END IF; - - RETURN deleted_count; -END; -$$; - -ALTER FUNCTION "public"."delete_non_compliant_bundles"(uuid, text) OWNER TO "postgres"; - --- Grant to authenticated role (with authorization checks inside the function) -GRANT EXECUTE ON FUNCTION "public"."delete_non_compliant_bundles"(uuid, text) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."delete_non_compliant_bundles"(uuid, text) TO "service_role"; diff --git a/supabase/migrations/20260102120000_fix_get_org_members_include_tmp_users.sql b/supabase/migrations/20260102120000_fix_get_org_members_include_tmp_users.sql deleted file mode 100644 index 8532a0b805..0000000000 --- a/supabase/migrations/20260102120000_fix_get_org_members_include_tmp_users.sql +++ /dev/null @@ -1,42 +0,0 @@ --- Fix get_org_members to include tmp_users (pending invitations) --- This was a regression from migration 20250913161225_lint_warning_fixes_followup.sql --- which removed the UNION with tmp_users table - -DROP FUNCTION IF EXISTS public.get_org_members(uuid, uuid); - -CREATE FUNCTION public.get_org_members( - "user_id" uuid, "guild_id" uuid -) RETURNS TABLE ( - aid bigint, - uid uuid, - email varchar, - image_url varchar, - role public.user_min_right, - is_tmp boolean -) LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -BEGIN - PERFORM user_id; - RETURN QUERY - -- Get existing org members - SELECT o.id AS aid, users.id AS uid, users.email, users.image_url, o.user_right AS role, false AS is_tmp - FROM public.org_users o - JOIN public.users ON users.id = o.user_id - WHERE o.org_id = get_org_members.guild_id - AND public.is_member_of_org(users.id, o.org_id) - UNION - -- Get pending invitations from tmp_users - SELECT - ((SELECT COALESCE(MAX(id), 0) FROM public.org_users) + tmp.id)::bigint AS aid, - tmp.future_uuid AS uid, - tmp.email::varchar, - ''::varchar AS image_url, - public.transform_role_to_invite(tmp.role) AS role, - true AS is_tmp - FROM public.tmp_users tmp - WHERE tmp.org_id = get_org_members.guild_id - AND tmp.cancelled_at IS NULL - AND tmp.created_at > (CURRENT_TIMESTAMP - INTERVAL '7 days'); -END; -$$; diff --git a/supabase/migrations/20260102140000_fix_get_identity_hashed_apikeys.sql b/supabase/migrations/20260102140000_fix_get_identity_hashed_apikeys.sql deleted file mode 100644 index 06c594c626..0000000000 --- a/supabase/migrations/20260102140000_fix_get_identity_hashed_apikeys.sql +++ /dev/null @@ -1,222 +0,0 @@ --- ============================================================================ --- Fix get_identity functions to support hashed API keys --- ============================================================================ --- The get_identity functions are used by RLS policies to resolve user identity --- from API keys. Previously, they only checked the plain 'key' column, which --- breaks hashed API keys (where key is NULL and key_hash contains the SHA-256). --- --- This migration updates all get_identity functions to use find_apikey_by_value() --- which checks both plain and hashed keys. --- ============================================================================ - --- ============================================================================ --- Section 1: Update get_identity(keymode key_mode[]) --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."get_identity" ("keymode" "public"."key_mode" []) RETURNS "uuid" -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key record; -Begin - SELECT auth.uid() into auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT "public"."get_apikey_header"() into api_key_text; - - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - -- Use find_apikey_by_value to support both plain and hashed keys - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - -- Check if key was found (api_key.id will be NULL if no match) and mode matches - IF api_key.id IS NOT NULL AND api_key.mode = ANY(keymode) THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RETURN NULL; - END IF; - - RETURN api_key.user_id; - END IF; - - RETURN NULL; -End; -$$; - --- ============================================================================ --- Section 2: Update get_identity_apikey_only(keymode key_mode[]) --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode" []) RETURNS "uuid" -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key record; -Begin - SELECT "public"."get_apikey_header"() into api_key_text; - - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - -- Use find_apikey_by_value to support both plain and hashed keys - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - -- Check if key was found (api_key.id will be NULL if no match) and mode matches - IF api_key.id IS NOT NULL AND api_key.mode = ANY(keymode) THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RETURN NULL; - END IF; - - RETURN api_key.user_id; - END IF; - - RETURN NULL; -End; -$$; - --- ============================================================================ --- Section 3: Update get_identity_org_allowed(keymode key_mode[], org_id uuid) --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode" [], "org_id" "uuid") RETURNS "uuid" -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key record; -Begin - SELECT auth.uid() into auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT "public"."get_apikey_header"() into api_key_text; - - -- No api key found in headers, return - IF api_key_text IS NULL THEN - PERFORM public.pg_log('deny: IDENTITY_ORG_NO_AUTH', jsonb_build_object('org_id', org_id)); - RETURN NULL; - END IF; - - -- Use find_apikey_by_value to support both plain and hashed keys - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - -- Check if key was found (api_key.id will be NULL if no match) and mode matches - IF api_key.id IS NOT NULL AND api_key.mode = ANY(keymode) THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id, 'org_id', org_id)); - RETURN NULL; - END IF; - - -- Check org restrictions - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: IDENTITY_ORG_UNALLOWED', jsonb_build_object('org_id', org_id)); - RETURN NULL; - END IF; - END IF; - - RETURN api_key.user_id; - END IF; - - PERFORM public.pg_log('deny: IDENTITY_ORG_NO_MATCH', jsonb_build_object('org_id', org_id)); - RETURN NULL; -End; -$$; - --- ============================================================================ --- Section 4: Update get_identity_org_appid(keymode, org_id, app_id) --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."get_identity_org_appid" ( - "keymode" "public"."key_mode" [], - "org_id" "uuid", - "app_id" character varying -) RETURNS "uuid" -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key record; -Begin - SELECT auth.uid() into auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT "public"."get_apikey_header"() into api_key_text; - - -- No api key found in headers, return - IF api_key_text IS NULL THEN - PERFORM public.pg_log('deny: IDENTITY_APP_NO_AUTH', jsonb_build_object('org_id', org_id, 'app_id', app_id)); - RETURN NULL; - END IF; - - -- Use find_apikey_by_value to support both plain and hashed keys - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - -- Check if key was found (api_key.id will be NULL if no match) and mode matches - IF api_key.id IS NOT NULL AND api_key.mode = ANY(keymode) THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id, 'org_id', org_id, 'app_id', app_id)); - RETURN NULL; - END IF; - - -- Check org restrictions - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: IDENTITY_APP_ORG_UNALLOWED', jsonb_build_object('org_id', org_id, 'app_id', app_id)); - RETURN NULL; - END IF; - END IF; - - -- Check app restrictions - IF api_key.limited_to_apps IS DISTINCT FROM '{}' THEN - IF NOT (app_id = ANY(api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: IDENTITY_APP_UNALLOWED', jsonb_build_object('app_id', app_id)); - RETURN NULL; - END IF; - END IF; - - RETURN api_key.user_id; - END IF; - - PERFORM public.pg_log('deny: IDENTITY_APP_NO_MATCH', jsonb_build_object('org_id', org_id, 'app_id', app_id)); - RETURN NULL; -End; -$$; - --- ============================================================================ --- Section 5: Grant execute on find_apikey_by_value to anon and authenticated --- ============================================================================ --- The function was previously only granted to service_role, but it needs to --- be callable from RLS policies which run as anon/authenticated - -GRANT EXECUTE ON FUNCTION "public"."find_apikey_by_value"(text) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."find_apikey_by_value"(text) TO "authenticated"; diff --git a/supabase/migrations/20260103030451_add_advisory_lock_to_cron.sql b/supabase/migrations/20260103030451_add_advisory_lock_to_cron.sql deleted file mode 100644 index 4db8cdb97b..0000000000 --- a/supabase/migrations/20260103030451_add_advisory_lock_to_cron.sql +++ /dev/null @@ -1,125 +0,0 @@ --- Add advisory lock to process_all_cron_tasks to prevent concurrent execution --- This ensures that if a previous execution is still running, the new invocation --- will skip instead of running in parallel (which could cause duplicate work or race conditions) --- --- IMPORTANT: Since pg_cron '10 seconds' interval is not clock-aligned (starts from job creation time), --- we use current_second < 10 instead of current_second = 0 for second-based checks. --- This ensures tasks run correctly regardless of the cron start offset. - -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks() RETURNS void -LANGUAGE plpgsql -SET search_path TO '' -AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; - current_dow int; - current_day int; - task RECORD; - queue_names text[]; - should_run boolean; - lock_acquired boolean; -BEGIN - -- Try to acquire an advisory lock (non-blocking) - -- Lock ID 1 is reserved for process_all_cron_tasks - -- pg_try_advisory_lock returns true if lock acquired, false if already held - lock_acquired := pg_try_advisory_lock(1); - - IF NOT lock_acquired THEN - -- Another instance is already running, skip this execution - RAISE NOTICE 'process_all_cron_tasks: skipped, another instance is already running'; - RETURN; - END IF; - - -- Wrap everything in a block so we can ensure the lock is released - BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - current_dow := EXTRACT(DOW FROM NOW()); - current_day := EXTRACT(DAY FROM NOW()); - - -- Loop through all enabled tasks - FOR task IN SELECT * FROM public.cron_tasks WHERE enabled = true LOOP - should_run := false; - - -- Check if task should run based on its schedule - IF task.second_interval IS NOT NULL THEN - -- Run every N seconds - -- Since pg_cron interval is not clock-aligned, we run on every invocation - -- for second_interval tasks (the cron job itself runs every 10 seconds) - should_run := true; - ELSIF task.minute_interval IS NOT NULL THEN - -- Run every N minutes - -- Use current_second < 10 to catch first run of each minute (works with any cron offset) - should_run := (current_minute % task.minute_interval = 0) - AND (current_second < 10); - ELSIF task.hour_interval IS NOT NULL THEN - -- Run every N hours at specific minute - -- Use current_second < 10 to catch first run - should_run := (current_hour % task.hour_interval = 0) - AND (current_minute = COALESCE(task.run_at_minute, 0)) - AND (current_second < 10); - ELSIF task.run_at_hour IS NOT NULL THEN - -- Run at specific time - -- Use current_second < 10 to catch first run - should_run := (current_hour = task.run_at_hour) - AND (current_minute = COALESCE(task.run_at_minute, 0)) - AND (current_second < 10); - - -- Check day of week constraint - IF should_run AND task.run_on_dow IS NOT NULL THEN - should_run := (current_dow = task.run_on_dow); - END IF; - - -- Check day of month constraint - IF should_run AND task.run_on_day IS NOT NULL THEN - should_run := (current_day = task.run_on_day); - END IF; - END IF; - - -- Execute the task if it should run - IF should_run THEN - BEGIN - CASE task.task_type - WHEN 'function' THEN - EXECUTE 'SELECT ' || task.target; - - WHEN 'queue' THEN - PERFORM pgmq.send( - task.target, - COALESCE(task.payload, jsonb_build_object('function_name', task.target)) - ); - - WHEN 'function_queue' THEN - -- Parse JSON array of queue names - SELECT array_agg(value::text) INTO queue_names - FROM jsonb_array_elements_text(task.target::jsonb); - - IF task.batch_size IS NOT NULL THEN - PERFORM public.process_function_queue(queue_names, task.batch_size); - ELSE - PERFORM public.process_function_queue(queue_names); - END IF; - END CASE; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cron task "%" failed: %', task.name, SQLERRM; - END; - END IF; - END LOOP; - - EXCEPTION WHEN OTHERS THEN - -- Release the lock even if an error occurred - PERFORM pg_advisory_unlock(1); - RAISE; - END; - - -- Release the advisory lock - PERFORM pg_advisory_unlock(1); -END; -$$; - --- Add comment explaining the lock mechanism -COMMENT ON FUNCTION public.process_all_cron_tasks() IS 'Consolidated cron task processor that runs every 10 seconds. Uses advisory lock (ID=1) to prevent concurrent execution - if a previous run is still executing, the new invocation will skip.'; diff --git a/supabase/migrations/20260104100000_add_allow_preview_to_apps.sql b/supabase/migrations/20260104100000_add_allow_preview_to_apps.sql deleted file mode 100644 index 5f7b1d8db7..0000000000 --- a/supabase/migrations/20260104100000_add_allow_preview_to_apps.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Add allow_preview column to apps table --- When true, bundle preview is enabled for this app - -ALTER TABLE apps -ADD COLUMN IF NOT EXISTS allow_preview boolean DEFAULT false NOT NULL; - -COMMENT ON COLUMN apps.allow_preview IS 'When true, bundle preview is enabled for this app'; diff --git a/supabase/migrations/20260104110000_add_apikey_policy_to_get_orgs_v7.sql b/supabase/migrations/20260104110000_add_apikey_policy_to_get_orgs_v7.sql deleted file mode 100644 index 4cc5825062..0000000000 --- a/supabase/migrations/20260104110000_add_apikey_policy_to_get_orgs_v7.sql +++ /dev/null @@ -1,273 +0,0 @@ --- Add API key policy columns to get_orgs_v7 function return type --- This was missing from the original get_orgs_v7 implementation which was based on get_orgs_v6 --- The require_apikey_expiration and max_apikey_expiration_days fields were added to get_orgs_v6 --- but not carried over when get_orgs_v7 was created - --- Drop both overloads of get_orgs_v7 (with and without parameters) -DROP FUNCTION IF EXISTS public.get_orgs_v7(); -DROP FUNCTION IF EXISTS public.get_orgs_v7(uuid); - -CREATE FUNCTION public.get_orgs_v7(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - -- Calculate current billing cycle for each org - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - -- 2fa_has_access: true if enforcing_2fa is false OR (enforcing_2fa is true AND user has 2FA) - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - -- should_redact: true if org enforces 2FA and user doesn't have 2FA - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ), - -- Calculate password policy access status for user/org combinations - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - -- password_has_access: true if no policy OR (has policy AND user meets it) - public.user_meets_password_policy(userid, o.id) AS password_has_access, - -- should_redact: true if org has policy and user doesn't meet it - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- Redact sensitive fields if user doesn't have 2FA or password policy access - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - COALESCE(ucb.available_credits, 0) AS credit_available, - COALESCE(ucb.total_credits, 0) AS credit_total, - ucb.next_expiration AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - JOIN two_fa_access tfa ON tfa.org_id = o.id - JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM public; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM authenticated; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO postgres; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO service_role; - --- Update the get_orgs_v7() wrapper function with updated return type -CREATE OR REPLACE FUNCTION public.get_orgs_v7() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - -- Check if API key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v7() TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO service_role; diff --git a/supabase/migrations/20260104120000_revoke_process_function_queue_public_access.sql b/supabase/migrations/20260104120000_revoke_process_function_queue_public_access.sql deleted file mode 100644 index 34c07e6ab5..0000000000 --- a/supabase/migrations/20260104120000_revoke_process_function_queue_public_access.sql +++ /dev/null @@ -1,369 +0,0 @@ --- Revoke public access to internal cron/admin functions --- These functions are internal utilities that should only be called by postgres/service_role --- Many expose internal API secrets via get_apikey() or perform admin operations --- They should not be accessible to anon/authenticated users - -CREATE OR REPLACE FUNCTION "public"."cleanup_frequent_job_details"() RETURNS "void" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - DELETE FROM cron.job_run_details - WHERE job_pid IN ( - SELECT jobid - FROM cron.job - WHERE schedule = '5 seconds' OR schedule = '1 seconds' OR schedule = '10 seconds' - ) - AND end_time < NOW() - interval '1 hour'; -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."cleanup_job_run_details_7days"() RETURNS "void" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."remove_old_jobs"() RETURNS "void" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - DELETE FROM cron.job_run_details - WHERE end_time < NOW() - interval '1 day'; -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."noupdate" () RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $_$ -DECLARE - val RECORD; - is_different boolean; -BEGIN - -- API key? We do not care - IF (SELECT auth.uid()) IS NULL THEN - RETURN NEW; - END IF; - - -- If the user has the 'admin' role then we do not care - IF public.check_min_rights('admin'::"public"."user_min_right", (SELECT auth.uid()), OLD.owner_org, NULL::character varying, NULL::bigint) THEN - RETURN NEW; - END IF; - - FOR val IN - SELECT * from json_each_text(row_to_json(NEW)) - LOOP - -- raise warning '?? % % %', val.key, val.value, format('SELECT (NEW."%s" <> OLD."%s")', val.key, val.key); - - EXECUTE format('SELECT ($1."%s" is distinct from $2."%s")', val.key, val.key) USING NEW, OLD - INTO is_different; - - IF is_different AND val.key <> 'version' AND val.key <> 'updated_at' THEN - RAISE EXCEPTION 'not allowed %', val.key; - END IF; - END LOOP; - - RETURN NEW; -END;$_$; - --- ============================================================================= --- PROCESS_FUNCTION_QUEUE - Core queue processing (uses get_apikey()) --- ============================================================================= -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_names" "text"[], "batch_size" integer) FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_names" "text"[], "batch_size" integer) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_names" "text"[], "batch_size" integer) FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_names" "text"[], "batch_size" integer) FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_function_queue" ("queue_name" "text", "batch_size" integer) FROM "service_role"; - --- ============================================================================= --- CRON/QUEUE PROCESSING FUNCTIONS (internal scheduler functions) --- ============================================================================= -REVOKE ALL ON FUNCTION "public"."process_admin_stats"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_admin_stats"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_admin_stats"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_admin_stats"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_all_cron_tasks"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_all_cron_tasks"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_all_cron_tasks"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_all_cron_tasks"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_billing_period_stats_email"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_billing_period_stats_email"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_billing_period_stats_email"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_billing_period_stats_email"() FROM "service_role"; - -ALTER FUNCTION "public"."process_channel_device_counts_queue" ("batch_size" integer) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."process_channel_device_counts_queue"("batch_size" integer) FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_channel_device_counts_queue"("batch_size" integer) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_channel_device_counts_queue"("batch_size" integer) FROM "authenticated"; --- Keep service_role access as it's called via Supabase RPC from tests/backend -GRANT EXECUTE ON FUNCTION "public"."process_channel_device_counts_queue"("batch_size" integer) TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_cron_stats_jobs"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_cron_stats_jobs"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_cron_stats_jobs"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_cron_stats_jobs"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_deploy_install_stats_email"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_deploy_install_stats_email"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_deploy_install_stats_email"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_deploy_install_stats_email"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_stats_email_monthly"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_stats_email_monthly"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_stats_email_monthly"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_stats_email_monthly"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."process_stats_email_weekly"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_stats_email_weekly"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_stats_email_weekly"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_stats_email_weekly"() FROM "service_role"; - --- ============================================================================= --- CLEANUP/MAINTENANCE FUNCTIONS (should only run via cron) --- ============================================================================= -REVOKE ALL ON FUNCTION "public"."cleanup_expired_apikeys"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."cleanup_expired_apikeys"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."cleanup_expired_apikeys"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."cleanup_expired_apikeys"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."cleanup_frequent_job_details"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."cleanup_frequent_job_details"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."cleanup_frequent_job_details"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."cleanup_frequent_job_details"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."cleanup_job_run_details_7days"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."cleanup_job_run_details_7days"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."cleanup_job_run_details_7days"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."cleanup_job_run_details_7days"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."cleanup_old_audit_logs"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."cleanup_old_audit_logs"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."cleanup_old_audit_logs"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."cleanup_old_audit_logs"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."cleanup_queue_messages"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."cleanup_queue_messages"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."cleanup_queue_messages"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."cleanup_queue_messages"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."cleanup_webhook_deliveries"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."cleanup_webhook_deliveries"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."cleanup_webhook_deliveries"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."cleanup_webhook_deliveries"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."remove_old_jobs"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."remove_old_jobs"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."remove_old_jobs"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."remove_old_jobs"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."expire_usage_credits"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."expire_usage_credits"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."expire_usage_credits"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."expire_usage_credits"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."delete_old_deleted_apps"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."delete_old_deleted_apps"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."delete_old_deleted_apps"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."delete_old_deleted_apps"() FROM "service_role"; - --- ============================================================================= --- SENSITIVE DATA/ADMIN FUNCTIONS --- ============================================================================= --- get_db_url exposes database connection string -REVOKE ALL ON FUNCTION "public"."get_db_url"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."get_db_url"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_db_url"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."get_db_url"() FROM "service_role"; - --- Admin statistics functions - internal use only -REVOKE ALL ON FUNCTION "public"."get_customer_counts"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."get_customer_counts"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_customer_counts"() FROM "authenticated"; - -REVOKE ALL ON FUNCTION "public"."get_update_stats"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."get_update_stats"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_update_stats"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."get_update_stats"() FROM "service_role"; - --- ============================================================================= --- TRIGGER FUNCTIONS (should never be called directly) --- ============================================================================= -REVOKE ALL ON FUNCTION "public"."enqueue_channel_device_counts"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."enqueue_channel_device_counts"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."enqueue_channel_device_counts"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."enqueue_channel_device_counts"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."enqueue_credit_usage_alert"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."enqueue_credit_usage_alert"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."enqueue_credit_usage_alert"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."enqueue_credit_usage_alert"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."audit_log_trigger"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."audit_log_trigger"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."audit_log_trigger"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."audit_log_trigger"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."auto_apikey_name_by_id"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."auto_apikey_name_by_id"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."auto_apikey_name_by_id"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."auto_apikey_name_by_id"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."auto_owner_org_by_app_id"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."auto_owner_org_by_app_id"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."auto_owner_org_by_app_id"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."auto_owner_org_by_app_id"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."check_if_org_can_exist"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."check_if_org_can_exist"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."check_if_org_can_exist"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."check_if_org_can_exist"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."check_org_user_privileges"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."check_org_user_privileges"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."check_org_user_privileges"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."check_org_user_privileges"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."force_valid_user_id_on_app"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."force_valid_user_id_on_app"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."force_valid_user_id_on_app"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."force_valid_user_id_on_app"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."generate_org_on_user_create"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."generate_org_on_user_create"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."generate_org_on_user_create"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."generate_org_on_user_create"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."generate_org_user_on_org_create"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."generate_org_user_on_org_create"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."generate_org_user_on_org_create"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."generate_org_user_on_org_create"() FROM "service_role"; - --- NOTE: noupdate() is a trigger function used on the channels table. --- Users need EXECUTE permission on trigger functions to perform table operations. --- Revoking access would break channel updates for authenticated users. -REVOKE ALL ON FUNCTION "public"."noupdate"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."noupdate"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."noupdate"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."noupdate"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."record_deployment_history"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."record_deployment_history"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."record_deployment_history"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."record_deployment_history"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."trigger_webhook_on_audit_log"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."trigger_webhook_on_audit_log"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."trigger_webhook_on_audit_log"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."trigger_webhook_on_audit_log"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."update_webhook_updated_at"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."update_webhook_updated_at"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."update_webhook_updated_at"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."update_webhook_updated_at"() FROM "service_role"; - --- ============================================================================= --- INTERNAL CREDIT/BILLING FUNCTIONS (admin operations) --- ============================================================================= -REVOKE ALL ON FUNCTION "public"."apply_usage_overage"("p_org_id" "uuid", "p_metric" "public"."credit_metric_type", "p_overage_amount" numeric, "p_billing_cycle_start" timestamp with time zone, "p_billing_cycle_end" timestamp with time zone, "p_details" "jsonb") FROM "public"; -REVOKE ALL ON FUNCTION "public"."apply_usage_overage"("p_org_id" "uuid", "p_metric" "public"."credit_metric_type", "p_overage_amount" numeric, "p_billing_cycle_start" timestamp with time zone, "p_billing_cycle_end" timestamp with time zone, "p_details" "jsonb") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."apply_usage_overage"("p_org_id" "uuid", "p_metric" "public"."credit_metric_type", "p_overage_amount" numeric, "p_billing_cycle_start" timestamp with time zone, "p_billing_cycle_end" timestamp with time zone, "p_details" "jsonb") FROM "authenticated"; --- Do not revoke from service_role as it is used in billing operations - -REVOKE ALL ON FUNCTION "public"."calculate_credit_cost"("p_metric" "public"."credit_metric_type", "p_overage_amount" numeric) FROM "public"; -REVOKE ALL ON FUNCTION "public"."calculate_credit_cost"("p_metric" "public"."credit_metric_type", "p_overage_amount" numeric) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."calculate_credit_cost"("p_metric" "public"."credit_metric_type", "p_overage_amount" numeric) FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."calculate_credit_cost"("p_metric" "public"."credit_metric_type", "p_overage_amount" numeric) FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."top_up_usage_credits"("p_org_id" "uuid", "p_amount" numeric, "p_expires_at" timestamp with time zone, "p_source" "text", "p_source_ref" "jsonb", "p_notes" "text") FROM "public"; -REVOKE ALL ON FUNCTION "public"."top_up_usage_credits"("p_org_id" "uuid", "p_amount" numeric, "p_expires_at" timestamp with time zone, "p_source" "text", "p_source_ref" "jsonb", "p_notes" "text") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."top_up_usage_credits"("p_org_id" "uuid", "p_amount" numeric, "p_expires_at" timestamp with time zone, "p_source" "text", "p_source_ref" "jsonb", "p_notes" "text") FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."top_up_usage_credits"("p_org_id" "uuid", "p_amount" numeric, "p_expires_at" timestamp with time zone, "p_source" "text", "p_source_ref" "jsonb", "p_notes" "text") FROM "service_role"; - --- ============================================================================= --- HTTP/QUEUE INTERNAL FUNCTIONS --- ============================================================================= -REVOKE ALL ON FUNCTION "public"."delete_http_response"("request_id" bigint) FROM "public"; -REVOKE ALL ON FUNCTION "public"."delete_http_response"("request_id" bigint) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."delete_http_response"("request_id" bigint) FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."delete_http_response"("request_id" bigint) FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."mass_edit_queue_messages_cf_ids"("updates" "public"."message_update"[]) FROM "public"; -REVOKE ALL ON FUNCTION "public"."mass_edit_queue_messages_cf_ids"("updates" "public"."message_update"[]) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."mass_edit_queue_messages_cf_ids"("updates" "public"."message_update"[]) FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."mass_edit_queue_messages_cf_ids"("updates" "public"."message_update"[]) FROM "service_role"; - --- ============================================================================= --- PG_LOG FUNCTION (internal debugging - could leak sensitive info) --- ============================================================================= -REVOKE ALL ON FUNCTION "public"."pg_log"("decision" "text", "input" "jsonb") FROM "public"; -REVOKE ALL ON FUNCTION "public"."pg_log"("decision" "text", "input" "jsonb") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."pg_log"("decision" "text", "input" "jsonb") FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."pg_log"("decision" "text", "input" "jsonb") FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."trigger_http_queue_post_to_function"() FROM "public"; -REVOKE ALL ON FUNCTION "public"."trigger_http_queue_post_to_function"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."trigger_http_queue_post_to_function"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."trigger_http_queue_post_to_function"() FROM "service_role"; - -REVOKE ALL ON FUNCTION "public"."count_all_need_upgrade" () FROM "public"; -REVOKE ALL ON FUNCTION "public"."count_all_need_upgrade" () FROM "anon"; -REVOKE ALL ON FUNCTION "public"."count_all_need_upgrade" () FROM "authenticated"; - --- count_all_onboarded -REVOKE ALL ON FUNCTION "public"."count_all_onboarded" () FROM "public"; -REVOKE ALL ON FUNCTION "public"."count_all_onboarded" () FROM "anon"; -REVOKE ALL ON FUNCTION "public"."count_all_onboarded" () FROM "authenticated"; --- count_all_plans_v2 -REVOKE ALL ON FUNCTION "public"."count_all_plans_v2" () FROM "public"; -REVOKE ALL ON FUNCTION "public"."count_all_plans_v2" () FROM "anon"; -REVOKE ALL ON FUNCTION "public"."count_all_plans_v2" () FROM "authenticated"; --- get_versions_with_no_metadata -REVOKE ALL ON FUNCTION "public"."get_versions_with_no_metadata" () FROM "public"; -REVOKE ALL ON FUNCTION "public"."get_versions_with_no_metadata" () FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_versions_with_no_metadata" () FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."get_versions_with_no_metadata" () FROM "service_role"; --- total_bundle_storage_bytes -REVOKE ALL ON FUNCTION "public"."total_bundle_storage_bytes" () FROM "public"; -REVOKE ALL ON FUNCTION "public"."total_bundle_storage_bytes" () FROM "anon"; -REVOKE ALL ON FUNCTION "public"."total_bundle_storage_bytes" () FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."total_bundle_storage_bytes" () FROM "service_role"; --- process_failed_uploads -REVOKE ALL ON FUNCTION "public"."process_failed_uploads" () FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_failed_uploads" () FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_failed_uploads" () FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_failed_uploads" () FROM "service_role"; --- process_free_trial_expired -REVOKE ALL ON FUNCTION "public"."process_free_trial_expired" () FROM "public"; -REVOKE ALL ON FUNCTION "public"."process_free_trial_expired" () FROM "anon"; -REVOKE ALL ON FUNCTION "public"."process_free_trial_expired" () FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."process_free_trial_expired" () FROM "service_role"; --- set_bandwidth_exceeded_by_org -REVOKE ALL ON FUNCTION "public"."set_bandwidth_exceeded_by_org" (org_id uuid, disabled boolean) FROM "public"; -REVOKE ALL ON FUNCTION "public"."set_bandwidth_exceeded_by_org" (org_id uuid, disabled boolean) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."set_bandwidth_exceeded_by_org" (org_id uuid, disabled boolean) FROM "authenticated"; --- Do not revoke from service_role as it is used in billing operations --- set_build_time_exceeded_by_org -REVOKE ALL ON FUNCTION "public"."set_build_time_exceeded_by_org" (org_id uuid, disabled boolean) FROM "public"; -REVOKE ALL ON FUNCTION "public"."set_build_time_exceeded_by_org" (org_id uuid, disabled boolean) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."set_build_time_exceeded_by_org" (org_id uuid, disabled boolean) FROM "authenticated"; --- Do not revoke from service_role as it is used in billing operations --- set_mau_exceeded_by_org -REVOKE ALL ON FUNCTION "public"."set_mau_exceeded_by_org" (org_id uuid, disabled boolean) FROM "public"; -REVOKE ALL ON FUNCTION "public"."set_mau_exceeded_by_org" (org_id uuid, disabled boolean) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."set_mau_exceeded_by_org" (org_id uuid, disabled boolean) FROM "authenticated"; --- Do not revoke from service_role as it is used in billing operations --- set_storage_exceeded_by_org -REVOKE ALL ON FUNCTION "public"."set_storage_exceeded_by_org" (org_id uuid, disabled boolean) FROM "public"; -REVOKE ALL ON FUNCTION "public"."set_storage_exceeded_by_org" (org_id uuid, disabled boolean) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."set_storage_exceeded_by_org" (org_id uuid, disabled boolean) FROM "authenticated"; --- Do not revoke from service_role as it is used in billing operations diff --git a/supabase/migrations/20260105014309_remove_metered.sql b/supabase/migrations/20260105014309_remove_metered.sql deleted file mode 100644 index a2c259fa5f..0000000000 --- a/supabase/migrations/20260105014309_remove_metered.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE public.stripe_info -DROP COLUMN IF EXISTS subscription_metered; - -DROP FUNCTION IF EXISTS get_metered_usage(orgid uuid); -DROP FUNCTION IF EXISTS get_metered_usage(); diff --git a/supabase/migrations/20260105150626_fix_is_allowed_capgkey_hashed_apikeys.sql b/supabase/migrations/20260105150626_fix_is_allowed_capgkey_hashed_apikeys.sql deleted file mode 100644 index 4930989c00..0000000000 --- a/supabase/migrations/20260105150626_fix_is_allowed_capgkey_hashed_apikeys.sql +++ /dev/null @@ -1,120 +0,0 @@ --- ============================================================================ --- Fix is_allowed_capgkey and get_user_id to support hashed API keys --- ============================================================================ --- The is_allowed_capgkey functions are used by RLS policies to check if an --- API key is valid for a given mode. Previously, they only checked the plain --- 'key' column, which breaks hashed API keys (where key is NULL and key_hash --- contains the SHA-256 hash). --- --- Similarly, get_user_id only checked the plain 'key' column. --- --- This migration updates these functions to use find_apikey_by_value() --- which checks both plain and hashed keys, and adds expiration checking. --- --- Also optimizes find_apikey_by_value to use a single query instead of two --- sequential queries for better performance. --- ============================================================================ - --- ============================================================================ --- Section 1: Optimize find_apikey_by_value to use single query --- ============================================================================ --- The original implementation did two sequential queries. This optimization --- combines both checks into a single query using OR, which is more efficient --- as it only requires one database round-trip and PostgreSQL can potentially --- use index union optimization. - -CREATE OR REPLACE FUNCTION "public"."find_apikey_by_value"("key_value" "text") RETURNS SETOF "public"."apikeys" - LANGUAGE "sql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ - SELECT * FROM public.apikeys - WHERE key = key_value - OR key_hash = encode(extensions.digest(key_value, 'sha256'), 'hex') - LIMIT 1; -$$; - --- ============================================================================ --- Section 2: Update is_allowed_capgkey(apikey, keymode) --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[]) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - api_key record; -BEGIN - -- Use find_apikey_by_value to support both plain and hashed keys - SELECT * FROM public.find_apikey_by_value(apikey) INTO api_key; - - -- Check if key was found and mode matches - IF api_key.id IS NOT NULL AND api_key.mode = ANY(keymode) THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - RETURN false; - END IF; - RETURN true; - END IF; - - RETURN false; -END; -$$; - --- ============================================================================ --- Section 3: Update is_allowed_capgkey(apikey, keymode, app_id) --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[], "app_id" character varying) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - api_key record; -BEGIN - -- Use find_apikey_by_value to support both plain and hashed keys - SELECT * FROM public.find_apikey_by_value(apikey) INTO api_key; - - -- Check if key was found and mode matches - IF api_key.id IS NOT NULL AND api_key.mode = ANY(keymode) THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - RETURN false; - END IF; - - -- Check if user is app owner - IF NOT public.is_app_owner(api_key.user_id, app_id) THEN - RETURN false; - END IF; - - RETURN true; - END IF; - - RETURN false; -END; -$$; - --- ============================================================================ --- Section 4: Update get_user_id(apikey) to support hashed keys --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."get_user_id"("apikey" "text") RETURNS "uuid" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - api_key record; -BEGIN - -- Use find_apikey_by_value to support both plain and hashed keys - SELECT * FROM public.find_apikey_by_value(apikey) INTO api_key; - - IF api_key.id IS NOT NULL THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - RETURN NULL; - END IF; - RETURN api_key.user_id; - END IF; - - RETURN NULL; -END; -$$; diff --git a/supabase/migrations/20260107000000_add_anon_role_to_webhooks_rls.sql b/supabase/migrations/20260107000000_add_anon_role_to_webhooks_rls.sql deleted file mode 100644 index 074ae58c7c..0000000000 --- a/supabase/migrations/20260107000000_add_anon_role_to_webhooks_rls.sql +++ /dev/null @@ -1,176 +0,0 @@ --- ============================================================================= --- Migration: Add anon role support to webhooks and webhook_deliveries RLS policies --- --- This allows API key-based authentication (which uses anon role with capgkey header) --- to access webhook endpoints through RLS, matching how other tables work. --- The get_identity() function already supports reading the capgkey header and --- returning the user_id, so we just need to add anon to the policy roles. --- ============================================================================= - --- ===================================================== --- Update webhooks table policies to include anon role --- ===================================================== - --- Drop existing policies -DROP POLICY IF EXISTS "Allow org members to select webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to insert webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to update webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to delete webhooks" ON public.webhooks; - --- Recreate policies with both authenticated and anon roles -CREATE POLICY "Allow org members to select webhooks" -ON public.webhooks -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'read'::public.user_min_right, - ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhooks" -ON public.webhooks -FOR INSERT -TO authenticated, anon -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to update webhooks" -ON public.webhooks -FOR UPDATE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ), - org_id, - null::character varying, - null::bigint - ) -) -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to delete webhooks" -ON public.webhooks -FOR DELETE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - --- ===================================================== --- Update webhook_deliveries table policies to include anon role --- ===================================================== - --- Drop existing policies -DROP POLICY IF EXISTS "Allow org members to select webhook_deliveries" ON public.webhook_deliveries; -DROP POLICY IF EXISTS "Allow admin to insert webhook_deliveries" ON public.webhook_deliveries; -DROP POLICY IF EXISTS "Allow admin to update webhook_deliveries" ON public.webhook_deliveries; - --- Recreate policies with both authenticated and anon roles -CREATE POLICY "Allow org members to select webhook_deliveries" -ON public.webhook_deliveries -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'read'::public.user_min_right, - ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhook_deliveries" -ON public.webhook_deliveries -FOR INSERT -TO authenticated, anon -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to update webhook_deliveries" -ON public.webhook_deliveries -FOR UPDATE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity( - '{read,upload,write,all}'::public.key_mode [] - ) - ), - org_id, - null::character varying, - null::bigint - ) -); diff --git a/supabase/migrations/20260108000000_add_electron_platform.sql b/supabase/migrations/20260108000000_add_electron_platform.sql deleted file mode 100644 index a97d0059aa..0000000000 --- a/supabase/migrations/20260108000000_add_electron_platform.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Add electron platform support - --- 1. Add 'electron' to platform_os enum -ALTER TYPE public.platform_os ADD VALUE IF NOT EXISTS 'electron'; - --- 2. Add 'disablePlatformElectron' to stats_action enum -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'disablePlatformElectron'; - --- 3. Add electron boolean column to channels table -ALTER TABLE public.channels ADD COLUMN IF NOT EXISTS electron boolean DEFAULT true NOT NULL; diff --git a/supabase/migrations/20260108024031_add_devices_platform_columns.sql b/supabase/migrations/20260108024031_add_devices_platform_columns.sql deleted file mode 100644 index d8224688b5..0000000000 --- a/supabase/migrations/20260108024031_add_devices_platform_columns.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add columns for tracking devices by platform (iOS and Android) -ALTER TABLE global_stats -ADD COLUMN IF NOT EXISTS devices_last_month_ios bigint DEFAULT 0, -ADD COLUMN IF NOT EXISTS devices_last_month_android bigint DEFAULT 0; diff --git a/supabase/migrations/20260109000000_fix_build_system_rls_consistency.sql b/supabase/migrations/20260109000000_fix_build_system_rls_consistency.sql deleted file mode 100644 index e675828485..0000000000 --- a/supabase/migrations/20260109000000_fix_build_system_rls_consistency.sql +++ /dev/null @@ -1,96 +0,0 @@ --- ============================================================================= --- Migration: Fix build system RLS policies for consistency --- --- This migration updates the RLS policies for build_requests, build_logs, and --- daily_build_time tables to use the consistent pattern used across the codebase: --- 1. Use check_min_rights() function instead of direct EXISTS queries --- 2. Use get_identity_org_appid() when app_id is available (preferred) --- 3. Use get_identity_org_allowed() only when app_id is not available (fallback) --- 4. Support both authenticated and anon roles (for API key support) --- --- This matches the pattern used in apps, channels, app_versions, etc. --- ============================================================================= - --- ===================================================== --- Update build_requests table policies --- ===================================================== - --- Drop existing policy -DROP POLICY IF EXISTS "Users read own org build requests" ON public.build_requests; - --- Recreate with consistent pattern using get_identity_org_appid (has app_id) -CREATE POLICY "Allow org members to select build_requests" -ON public.build_requests -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode [], - owner_org, - app_id - ), - owner_org, - app_id, - NULL::bigint - ) -); - --- ===================================================== --- Update build_logs table policies --- ===================================================== - --- Drop existing policy -DROP POLICY IF EXISTS "Users read own or org admin builds" ON public.build_logs; - --- Recreate with consistent pattern using get_identity_org_allowed (no app_id available) -CREATE POLICY "Allow org members to select build_logs" -ON public.build_logs -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ), - org_id, - NULL::character varying, - NULL::bigint - ) -); - --- ===================================================== --- Update daily_build_time table policies --- ===================================================== - --- Drop existing policy -DROP POLICY IF EXISTS "Users read own org build time" ON public.daily_build_time; - --- Recreate with consistent pattern using get_identity_org_appid (has app_id) --- Joins through apps table to get owner_org -CREATE POLICY "Allow org members to select daily_build_time" -ON public.daily_build_time -FOR SELECT -TO authenticated, anon -USING ( - EXISTS ( - SELECT 1 - FROM public.apps - WHERE - apps.app_id = daily_build_time.app_id - AND public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - apps.app_id, - NULL::bigint - ) - ) -); diff --git a/supabase/migrations/20260109000001_remove_both_platform_option.sql b/supabase/migrations/20260109000001_remove_both_platform_option.sql deleted file mode 100644 index b2e047e7d7..0000000000 --- a/supabase/migrations/20260109000001_remove_both_platform_option.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Remove 'both' as a valid platform option from build_requests --- Platform should only be 'ios' or 'android' - --- First, update any existing records that have 'both' to a default value --- (there shouldn't be any in production, but just in case) -UPDATE public.build_requests -SET platform = 'ios' -WHERE platform = 'both'; - --- Drop the old constraint and add the new one -ALTER TABLE public.build_requests -DROP CONSTRAINT IF EXISTS build_requests_platform_check; - -ALTER TABLE public.build_requests -ADD CONSTRAINT build_requests_platform_check -CHECK (platform IN ('ios', 'android')); diff --git a/supabase/migrations/20260110044840_improve_usage_credit_rls.sql b/supabase/migrations/20260110044840_improve_usage_credit_rls.sql deleted file mode 100644 index c8d1350a51..0000000000 --- a/supabase/migrations/20260110044840_improve_usage_credit_rls.sql +++ /dev/null @@ -1,117 +0,0 @@ --- ============================================================================= --- Migration: Improve usage credit RLS policies --- --- This migration updates the RLS policies for usage credit tables to use the --- consistent pattern used across the codebase: --- 1. Use check_min_rights() function with get_identity_org_allowed() --- 2. Support both authenticated and anon roles (for API key support) --- --- These tables only have org_id (no app_id) as credits are organization-level --- resources, so we use get_identity_org_allowed() per AGENTS.md guidelines. --- --- Tables affected: --- - usage_credit_grants --- - usage_credit_transactions --- - usage_overage_events --- - usage_credit_consumptions --- ============================================================================= - --- ===================================================== --- Update usage_credit_grants table policies --- ===================================================== - --- Drop existing policy -DROP POLICY IF EXISTS "Allow read for org admin" ON public.usage_credit_grants; - --- Recreate with consistent pattern using get_identity_org_allowed (no app_id on table) -CREATE POLICY "Allow org members to select usage_credit_grants" -ON public.usage_credit_grants -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ), - org_id, - NULL::character varying, - NULL::bigint - ) -); - --- ===================================================== --- Update usage_credit_transactions table policies --- ===================================================== - --- Drop existing policy -DROP POLICY IF EXISTS "Allow read for org admin" ON public.usage_credit_transactions; - --- Recreate with consistent pattern using get_identity_org_allowed (no app_id on table) -CREATE POLICY "Allow org members to select usage_credit_transactions" -ON public.usage_credit_transactions -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ), - org_id, - NULL::character varying, - NULL::bigint - ) -); - --- ===================================================== --- Update usage_overage_events table policies --- ===================================================== - --- Drop existing policy -DROP POLICY IF EXISTS "Allow read for org admin" ON public.usage_overage_events; - --- Recreate with consistent pattern using get_identity_org_allowed (no app_id on table) -CREATE POLICY "Allow org members to select usage_overage_events" -ON public.usage_overage_events -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ), - org_id, - NULL::character varying, - NULL::bigint - ) -); - --- ===================================================== --- Update usage_credit_consumptions table policies --- ===================================================== - --- Drop existing policy -DROP POLICY IF EXISTS "Allow read for org admin" ON public.usage_credit_consumptions; - --- Recreate with consistent pattern using get_identity_org_allowed (no app_id on table) -CREATE POLICY "Allow org members to select usage_credit_consumptions" -ON public.usage_credit_consumptions -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ), - org_id, - NULL::character varying, - NULL::bigint - ) -); diff --git a/supabase/migrations/20260112140000_cleanup_old_channel_devices.sql b/supabase/migrations/20260112140000_cleanup_old_channel_devices.sql deleted file mode 100644 index 106e1fd259..0000000000 --- a/supabase/migrations/20260112140000_cleanup_old_channel_devices.sql +++ /dev/null @@ -1,101 +0,0 @@ --- Add cleanup function for channel_devices older than one month --- This removes stale entries from older plugin versions that stored channel assignments server-side --- Newer plugins (v5.34.0+) store channel assignments locally and don't need this table - --- Create the cleanup function -CREATE OR REPLACE FUNCTION public.cleanup_old_channel_devices() RETURNS void -LANGUAGE plpgsql -SET search_path TO '' -AS $$ -DECLARE - deleted_count bigint; - purged_count bigint; -BEGIN - -- Disable triggers on channel_devices to avoid unnecessary queue operations during bulk cleanup - -- This prevents the enqueue_channel_device_counts trigger from firing for each deleted row - ALTER TABLE public.channel_devices DISABLE TRIGGER channel_device_count_enqueue; - - -- Use nested block with exception handler to ensure trigger is re-enabled on any failure - BEGIN - -- Delete channel_devices where the last activity (updated_at or created_at) is older than 1 month - DELETE FROM public.channel_devices - WHERE COALESCE(updated_at, created_at) < NOW() - INTERVAL '1 month'; - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - -- Re-enable triggers before any further operations - ALTER TABLE public.channel_devices ENABLE TRIGGER channel_device_count_enqueue; - - IF deleted_count > 0 THEN - RAISE NOTICE 'cleanup_old_channel_devices: Deleted % stale channel device entries', deleted_count; - - -- Purge any pending messages in the channel_device_counts queue before recomputing - -- This prevents stale deltas from being applied after the full recount - SELECT pgmq.purge_queue('channel_device_counts') INTO purged_count; - IF purged_count > 0 THEN - RAISE NOTICE 'cleanup_old_channel_devices: Purged % pending queue messages', purged_count; - END IF; - - -- Recalculate channel_device_count for all apps since we bypassed the trigger - -- This is more efficient than firing triggers for potentially thousands of rows - UPDATE public.apps - SET channel_device_count = COALESCE(( - SELECT COUNT(*) - FROM public.channel_devices cd - WHERE cd.app_id = apps.app_id - ), 0); - - RAISE NOTICE 'cleanup_old_channel_devices: Recalculated channel_device_count for all apps'; - END IF; - EXCEPTION WHEN OTHERS THEN - -- Ensure trigger is re-enabled even on failure - ALTER TABLE public.channel_devices ENABLE TRIGGER channel_device_count_enqueue; - RAISE; - END; -END; -$$; - --- Security: internal function only -REVOKE EXECUTE ON FUNCTION public.cleanup_old_channel_devices() FROM public; -GRANT EXECUTE ON FUNCTION public.cleanup_old_channel_devices() TO service_role; - --- Register cron task to run cleanup daily at 02:30:00 UTC --- Note: The cron_tasks table is the canonical way to register tasks in this codebase. --- The process_all_cron_tasks function reads from this table to execute scheduled tasks. -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - batch_size, - second_interval, - minute_interval, - hour_interval, - run_at_hour, - run_at_minute, - run_at_second, - run_on_dow, - run_on_day -) VALUES ( - 'cleanup_old_channel_devices', - 'Delete channel_devices older than one month', - 'function', - 'public.cleanup_old_channel_devices()', - null, -- batch_size not needed for function type - null, -- second_interval - null, -- minute_interval - null, -- hour_interval - 2, -- run_at_hour (02:00 UTC) - 30, -- run_at_minute (02:30) - 0, -- run_at_second - null, -- run_on_dow (any day) - null -- run_on_day (any day) -) -ON CONFLICT (name) DO UPDATE SET - description = excluded.description, - task_type = excluded.task_type, - target = excluded.target, - run_at_hour = excluded.run_at_hour, - run_at_minute = excluded.run_at_minute, - run_at_second = excluded.run_at_second, - updated_at = NOW(); diff --git a/supabase/migrations/20260113000000_add_plugin_breakdown_to_global_stats.sql b/supabase/migrations/20260113000000_add_plugin_breakdown_to_global_stats.sql deleted file mode 100644 index fd96886146..0000000000 --- a/supabase/migrations/20260113000000_add_plugin_breakdown_to_global_stats.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Add plugin version breakdown columns to global_stats table --- This stores JSON breakdowns of plugin versions installed on devices - --- Full plugin version breakdown (e.g., {"6.2.5": 45.2, "6.1.0": 30.1, ...}) -ALTER TABLE public.global_stats -ADD COLUMN plugin_version_breakdown jsonb DEFAULT '{}'::jsonb NOT NULL; - --- Major version breakdown (e.g., {"6": 75.3, "5": 20.5, ...}) -ALTER TABLE public.global_stats -ADD COLUMN plugin_major_breakdown jsonb DEFAULT '{}'::jsonb NOT NULL; - -COMMENT ON COLUMN public.global_stats.plugin_version_breakdown IS 'JSON breakdown of plugin version percentages. Format: {"version": percentage, ...}'; -COMMENT ON COLUMN public.global_stats.plugin_major_breakdown IS 'JSON breakdown of plugin major version percentages. Format: {"major_version": percentage, ...}'; diff --git a/supabase/migrations/20260113132114_missing_index.sql b/supabase/migrations/20260113132114_missing_index.sql deleted file mode 100644 index 8932e6eb09..0000000000 --- a/supabase/migrations/20260113132114_missing_index.sql +++ /dev/null @@ -1,5 +0,0 @@ -create index on public.channel_devices using btree (device_id); - -create index on public.notifications using btree (uniq_id); - -create index on public.app_versions using btree (cli_version); diff --git a/supabase/migrations/20260113160650_delete_old_deleted_versions.sql b/supabase/migrations/20260113160650_delete_old_deleted_versions.sql deleted file mode 100644 index 9f4552dc75..0000000000 --- a/supabase/migrations/20260113160650_delete_old_deleted_versions.sql +++ /dev/null @@ -1,100 +0,0 @@ --- Fix: update_app_versions_retention should set updated_at when marking versions as deleted --- This ensures we can track when a version was soft-deleted -CREATE OR REPLACE FUNCTION "public"."update_app_versions_retention" () RETURNS void LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - -- Use a more efficient approach with direct timestamp comparison - -- Also set updated_at to track when the version was marked as deleted - UPDATE public.app_versions - SET deleted = true, updated_at = NOW() - WHERE app_versions.deleted = false - AND (SELECT retention FROM public.apps WHERE apps.app_id = app_versions.app_id) >= 0 - AND (SELECT retention FROM public.apps WHERE apps.app_id = app_versions.app_id) < 63113904 - AND app_versions.created_at < ( - SELECT NOW() - make_interval(secs => apps.retention) - FROM public.apps - WHERE apps.app_id = app_versions.app_id - ) - AND NOT EXISTS ( - SELECT 1 - FROM public.channels - WHERE channels.app_id = app_versions.app_id - AND channels.version = app_versions.id - ); -END; -$$; - --- Create a function to permanently delete app versions that are: --- 1. Already marked as deleted (soft deleted) --- 2. Soft-deleted more than one year ago (using updated_at, not created_at) --- This helps with database cleanup and compliance with data retention policies. --- Note: Foreign keys have ON DELETE CASCADE, so related records in --- app_versions_meta, channels, deploy_history, and manifest will be automatically cleaned up. - -CREATE OR REPLACE FUNCTION "public"."delete_old_deleted_versions" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - deleted_count bigint; -BEGIN - -- Delete versions that are: - -- 1. Marked as deleted (soft deleted) - -- 2. Soft-deleted more than 1 year ago (based on updated_at, which is set when deleted=true) - -- 3. NOT currently linked to any channel (safety check, though should not happen for deleted versions) - DELETE FROM "public"."app_versions" - WHERE deleted = true - AND updated_at < NOW() - INTERVAL '1 year' - AND NOT EXISTS ( - SELECT 1 FROM "public"."channels" - WHERE channels.version = app_versions.id - ); - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - IF deleted_count > 0 THEN - RAISE NOTICE 'delete_old_deleted_versions: permanently deleted % app versions', deleted_count; - END IF; -END; -$$; - -ALTER FUNCTION "public"."delete_old_deleted_versions" () OWNER TO "postgres"; - --- Security: internal function only -REVOKE EXECUTE ON FUNCTION "public"."delete_old_deleted_versions" () FROM public; -GRANT EXECUTE ON FUNCTION "public"."delete_old_deleted_versions" () TO service_role; - --- Add to cron_tasks table to run daily at 03:00:00 --- Runs AFTER: --- - 00:40 update_app_versions_retention() marks versions as deleted --- - 02:00 cron_clear_versions queue processes S3 cleanup --- Scheduled at 03:00 to ensure S3 cleanup is complete before hard deletion -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - batch_size, - second_interval, - minute_interval, - hour_interval, - run_at_hour, - run_at_minute, - run_at_second, - run_on_dow, - run_on_day -) VALUES ( - 'delete_old_versions', - 'Permanently delete app versions that are soft-deleted and older than 1 year', - 'function', - 'public.delete_old_deleted_versions()', - null, -- batch_size (not needed for function type) - null, -- second_interval - null, -- minute_interval - null, -- hour_interval - 3, -- run_at_hour: 03:00 (after S3 cleanup at 02:00) - 0, -- run_at_minute - 0, -- run_at_second - null, -- run_on_dow (no day-of-week restriction) - null -- run_on_day (no day-of-month restriction) -); diff --git a/supabase/migrations/20260114214731_add_deleted_at_column.sql b/supabase/migrations/20260114214731_add_deleted_at_column.sql deleted file mode 100644 index ca103ddbd4..0000000000 --- a/supabase/migrations/20260114214731_add_deleted_at_column.sql +++ /dev/null @@ -1,96 +0,0 @@ --- Add deleted_at column to track when versions were soft-deleted --- This replaces using updated_at which is unreliable (touched by many operations) --- Step 1: Add deleted_at column -ALTER TABLE public.app_versions -ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone DEFAULT NULL; - --- Step 2: Migrate existing deleted versions --- Use updated_at (which was set by previous retention logic) instead of created_at --- to avoid premature hard-deletion of recently-deleted old versions -UPDATE public.app_versions -SET - deleted_at = updated_at -WHERE - deleted = true - AND deleted_at IS NULL; - --- Step 3: Add index for cleanup queries -CREATE INDEX IF NOT EXISTS idx_app_versions_deleted_at ON public.app_versions (deleted_at) -WHERE - deleted_at IS NOT NULL; - --- Step 4: Create trigger function to automatically set deleted_at when deleted becomes true --- This ensures deleted_at is always set correctly, regardless of how the deletion happens -CREATE OR REPLACE FUNCTION public.set_deleted_at_on_soft_delete () RETURNS TRIGGER LANGUAGE plpgsql -SET - search_path = '' AS $$ -BEGIN - -- Only set deleted_at when deleted changes from false to true - -- and deleted_at is not already set (allows manual override if needed) - IF NEW.deleted = true AND (OLD.deleted = false OR OLD.deleted IS NULL) AND NEW.deleted_at IS NULL THEN - NEW.deleted_at = NOW(); - END IF; - RETURN NEW; -END; -$$; - --- Step 5: Create trigger on app_versions table -DROP TRIGGER IF EXISTS set_deleted_at_trigger ON public.app_versions; - -CREATE TRIGGER set_deleted_at_trigger BEFORE -UPDATE ON public.app_versions FOR EACH ROW -EXECUTE FUNCTION public.set_deleted_at_on_soft_delete (); - --- Step 6: Simplify retention function - trigger handles deleted_at automatically -CREATE OR REPLACE FUNCTION "public"."update_app_versions_retention" () RETURNS void LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - UPDATE public.app_versions - SET deleted = true - WHERE app_versions.deleted = false - AND (SELECT retention FROM public.apps WHERE apps.app_id = app_versions.app_id) >= 0 - AND (SELECT retention FROM public.apps WHERE apps.app_id = app_versions.app_id) < 63113904 - AND app_versions.created_at < ( - SELECT NOW() - make_interval(secs => apps.retention) - FROM public.apps - WHERE apps.app_id = app_versions.app_id - ) - AND NOT EXISTS ( - SELECT 1 - FROM public.channels - WHERE channels.app_id = app_versions.app_id - AND channels.version = app_versions.id - ); -END; -$$; - --- Step 7: Update hard-delete function to use deleted_at instead of updated_at --- Also exclude builtin/unknown versions which should NEVER be hard-deleted -CREATE OR REPLACE FUNCTION "public"."delete_old_deleted_versions" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - deleted_count bigint; -BEGIN - -- Delete versions that are: - -- 1. Have deleted_at set (soft deleted) - -- 2. Soft-deleted more than 1 year ago - -- 3. NOT builtin or unknown (these are special placeholder versions) - -- 4. NOT currently linked to any channel (safety check) - DELETE FROM "public"."app_versions" - WHERE deleted_at IS NOT NULL - AND deleted_at < NOW() - INTERVAL '3 months' - AND name NOT IN ('builtin', 'unknown') - AND NOT EXISTS ( - SELECT 1 FROM "public"."channels" - WHERE channels.version = app_versions.id - ); - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - IF deleted_count > 0 THEN - RAISE NOTICE 'delete_old_deleted_versions: permanently deleted % app versions', deleted_count; - END IF; -END; -$$; diff --git a/supabase/migrations/20260115025158_add_daily_fail_ratio_email.sql b/supabase/migrations/20260115025158_add_daily_fail_ratio_email.sql deleted file mode 100644 index e2a4d0d4c0..0000000000 --- a/supabase/migrations/20260115025158_add_daily_fail_ratio_email.sql +++ /dev/null @@ -1,147 +0,0 @@ --- Migration: Add daily fail ratio email notifications --- Purpose: Send daily emails to app owners when their install fail rate exceeds a threshold --- This replaces the old per-device "weak signal" notification system that only sent one email per week - --- Function to calculate daily fail ratio and queue emails for apps with high failure rates -CREATE OR REPLACE FUNCTION public.process_daily_fail_ratio_email() RETURNS void LANGUAGE plpgsql -SET -search_path = '' AS $$ -DECLARE - record RECORD; - fail_threshold numeric := 0.30; -- 30% fail rate threshold - min_installs integer := 10; -- Minimum installs to avoid false positives -BEGIN - -- Get apps with high fail ratios from yesterday's data - -- We use yesterday to ensure we have complete data for the day - FOR record IN - WITH daily_stats AS ( - SELECT - dv.app_id, - SUM(COALESCE(dv.install, 0)) AS total_installs, - SUM(COALESCE(dv.fail, 0)) AS total_fails - FROM public.daily_version dv - WHERE dv.date = CURRENT_DATE - INTERVAL '1 day' - GROUP BY dv.app_id - HAVING SUM(COALESCE(dv.install, 0)) >= min_installs - ), - high_fail_apps AS ( - SELECT - ds.app_id, - ds.total_installs, - ds.total_fails, - -- Cap fail_percentage at 100 to handle edge cases where fails > installs - CASE - WHEN ds.total_installs > 0 THEN LEAST(ROUND((ds.total_fails::numeric / ds.total_installs::numeric) * 100, 2), 100) - ELSE 0 - END AS fail_percentage, - a.owner_org - FROM daily_stats ds - JOIN public.apps a ON a.app_id = ds.app_id - WHERE ds.total_installs > 0 - AND (ds.total_fails::numeric / ds.total_installs::numeric) >= fail_threshold - ), - with_org_email AS ( - SELECT - hfa.*, - o.management_email, - a.name AS app_name - FROM high_fail_apps hfa - JOIN public.orgs o ON o.id = hfa.owner_org - JOIN public.apps a ON a.app_id = hfa.app_id - WHERE o.management_email IS NOT NULL - AND o.management_email != '' - ) - SELECT * FROM with_org_email - LOOP - -- Queue email for each app with high fail ratio (with error handling) - BEGIN - PERFORM pgmq.send('cron_email', - jsonb_build_object( - 'function_name', 'cron_email', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'email', record.management_email, - 'appId', record.app_id, - 'orgId', record.owner_org, - 'type', 'daily_fail_ratio', - 'appName', record.app_name, - 'totalInstalls', record.total_installs, - 'totalFails', record.total_fails, - 'failPercentage', record.fail_percentage, - 'reportDate', (CURRENT_DATE - INTERVAL '1 day')::text - ) - ) - ); - EXCEPTION - WHEN OTHERS THEN - RAISE WARNING 'process_daily_fail_ratio_email: failed to queue email for app_id %, org_id %, email %: % (%)', - record.app_id, - record.owner_org, - record.management_email, - SQLERRM, - SQLSTATE; - END; - END LOOP; -END; -$$; - -ALTER FUNCTION public.process_daily_fail_ratio_email() OWNER TO postgres; - --- Security: internal function only -REVOKE EXECUTE ON FUNCTION public.process_daily_fail_ratio_email() FROM public; -GRANT EXECUTE ON FUNCTION public.process_daily_fail_ratio_email() TO service_role; - --- Register cron task to run daily at 08:00:00 UTC --- Note: The cron_tasks table is the canonical way to register tasks in this codebase. --- The process_all_cron_tasks function reads from this table to execute scheduled tasks. -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - batch_size, - second_interval, - minute_interval, - hour_interval, - run_at_hour, - run_at_minute, - run_at_second, - run_on_dow, - run_on_day -) VALUES ( - 'daily_fail_ratio_email', - 'Send daily email alerts for apps with high install failure rates (>30%)', - 'function', - 'public.process_daily_fail_ratio_email()', - null, -- batch_size not needed for function type - null, -- second_interval - null, -- minute_interval - null, -- hour_interval - 8, -- run_at_hour (08:00 UTC) - 0, -- run_at_minute - 0, -- run_at_second - null, -- run_on_dow (any day) - null -- run_on_day (any day) -) -ON CONFLICT (name) DO UPDATE SET - description = excluded.description, - task_type = excluded.task_type, - target = excluded.target, - run_at_hour = excluded.run_at_hour, - run_at_minute = excluded.run_at_minute, - run_at_second = excluded.run_at_second, - updated_at = NOW(); - --- Backfill daily_fail_ratio preference for existing users who have email_preferences set -UPDATE public.users -SET email_preferences = email_preferences || '{"daily_fail_ratio": true}'::jsonb -WHERE - email_preferences IS NOT null - AND NOT (email_preferences ? 'daily_fail_ratio'); - --- Backfill daily_fail_ratio preference for existing orgs who have email_preferences set -UPDATE public.orgs -SET email_preferences = email_preferences || '{"daily_fail_ratio": true}'::jsonb -WHERE - email_preferences IS NOT null - AND NOT (email_preferences ? 'daily_fail_ratio'); diff --git a/supabase/migrations/20260115051444_sync_stripe_info_on_org_create.sql b/supabase/migrations/20260115051444_sync_stripe_info_on_org_create.sql deleted file mode 100644 index ab2b98d3c2..0000000000 --- a/supabase/migrations/20260115051444_sync_stripe_info_on_org_create.sql +++ /dev/null @@ -1,62 +0,0 @@ --- Fix race condition: create stripe_info synchronously on org creation --- Pending customer_id (pending_{org_id}) is replaced with real Stripe customer_id by async handler - -CREATE OR REPLACE FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() - RETURNS "trigger" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' -AS $$ -DECLARE - solo_plan_stripe_id VARCHAR; - pending_customer_id VARCHAR; - trial_at_date TIMESTAMPTZ; -BEGIN - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (NEW.created_by, NEW.id, 'super_admin'::"public"."user_min_right"); - - IF NEW.customer_id IS NOT NULL THEN - RETURN NEW; - END IF; - - SELECT stripe_id INTO solo_plan_stripe_id - FROM public.plans - WHERE name = 'Solo' - LIMIT 1; - - IF solo_plan_stripe_id IS NULL THEN - RAISE WARNING 'Solo plan not found, skipping sync stripe_info creation for org %', NEW.id; - RETURN NEW; - END IF; - - pending_customer_id := 'pending_' || NEW.id::text; - trial_at_date := NOW() + INTERVAL '15 days'; - - INSERT INTO public.stripe_info ( - customer_id, - product_id, - trial_at, - status, - is_good_plan - ) VALUES ( - pending_customer_id, - solo_plan_stripe_id, - trial_at_date, - NULL, - true - ); - - UPDATE public.orgs - SET customer_id = pending_customer_id - WHERE id = NEW.id; - - RETURN NEW; -END $$; - -DROP TRIGGER IF EXISTS "generate_org_user_on_org_create" ON "public"."orgs"; - -CREATE TRIGGER "generate_org_user_stripe_info_on_org_create" - AFTER INSERT ON "public"."orgs" - FOR EACH ROW - EXECUTE FUNCTION "public"."generate_org_user_stripe_info_on_org_create"(); - -DROP FUNCTION IF EXISTS "public"."generate_org_user_on_org_create"(); diff --git a/supabase/migrations/20260118000000_add_build_stats_to_global_stats.sql b/supabase/migrations/20260118000000_add_build_stats_to_global_stats.sql deleted file mode 100644 index 18959321f6..0000000000 --- a/supabase/migrations/20260118000000_add_build_stats_to_global_stats.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Migration to add build statistics columns to global_stats table --- These columns will track total builds (all time) and last month builds - --- Add columns to global_stats table -ALTER TABLE public.global_stats -ADD COLUMN IF NOT EXISTS builds_total bigint DEFAULT 0, -ADD COLUMN IF NOT EXISTS builds_ios bigint DEFAULT 0, -ADD COLUMN IF NOT EXISTS builds_android bigint DEFAULT 0, -ADD COLUMN IF NOT EXISTS builds_last_month bigint DEFAULT 0, -ADD COLUMN IF NOT EXISTS builds_last_month_ios bigint DEFAULT 0, -ADD COLUMN IF NOT EXISTS builds_last_month_android bigint DEFAULT 0; - --- Add comment for documentation -COMMENT ON COLUMN public.global_stats.builds_total IS 'Total number of native builds recorded (all time)'; -COMMENT ON COLUMN public.global_stats.builds_ios IS 'Total number of iOS native builds recorded (all time)'; -COMMENT ON COLUMN public.global_stats.builds_android IS 'Total number of Android native builds recorded (all time)'; -COMMENT ON COLUMN public.global_stats.builds_last_month IS 'Number of native builds in the last 30 days'; -COMMENT ON COLUMN public.global_stats.builds_last_month_ios IS 'Number of iOS native builds in the last 30 days'; -COMMENT ON COLUMN public.global_stats.builds_last_month_android IS 'Number of Android native builds in the last 30 days'; diff --git a/supabase/migrations/20260118005052_version_usage_use_version_name.sql b/supabase/migrations/20260118005052_version_usage_use_version_name.sql deleted file mode 100644 index cc9e0086c8..0000000000 --- a/supabase/migrations/20260118005052_version_usage_use_version_name.sql +++ /dev/null @@ -1,107 +0,0 @@ --- Migration: Use version_name instead of version_id for version statistics --- This allows tracking version stats without database lookups - --- 1. Add version_name column to version_usage table (nullable for backwards compatibility with old data) -ALTER TABLE "public"."version_usage" ADD COLUMN IF NOT EXISTS "version_name" character varying(255); - --- 1b. Drop version_usage primary key (required before making version_id nullable) -ALTER TABLE "public"."version_usage" DROP CONSTRAINT IF EXISTS "version_usage_pkey"; - --- 1c. Make version_id nullable in version_usage (new data uses version_name instead) -ALTER TABLE "public"."version_usage" ALTER COLUMN "version_id" DROP NOT NULL; - --- 2. Add version_name column to daily_version table (nullable for backwards compatibility with old data) -ALTER TABLE "public"."daily_version" ADD COLUMN IF NOT EXISTS "version_name" character varying(255); - --- 3. Backfill version_name in daily_version from app_versions (for existing data) -UPDATE "public"."daily_version" dv -SET version_name = av.name -FROM "public"."app_versions" av -WHERE dv.version_id = av.id AND dv.version_name IS NULL; - --- 3b. Set 'unknown' for any rows that couldn't be backfilled (deleted versions) -UPDATE "public"."daily_version" -SET version_name = 'unknown' -WHERE version_name IS NULL; - --- 3c. Make version_name NOT NULL now that all rows have a value -ALTER TABLE "public"."daily_version" ALTER COLUMN "version_name" SET NOT NULL; - --- 3d. Drop old primary key FIRST (must be done before making version_id nullable) -ALTER TABLE "public"."daily_version" DROP CONSTRAINT IF EXISTS "daily_version_pkey"; - --- 3e. Make version_id nullable for new data (which only has version_name) -ALTER TABLE "public"."daily_version" ALTER COLUMN "version_id" DROP NOT NULL; - --- 4. Drop and recreate read_version_usage function with new return type (version_name instead of version_id) --- PostgreSQL doesn't allow changing return type with CREATE OR REPLACE, so we must drop first -DROP FUNCTION IF EXISTS "public"."read_version_usage"(character varying, timestamp without time zone, timestamp without time zone); - --- Recreate function with version_name in return type --- It now handles both old data (with version_id) and new data (with version_name) -CREATE FUNCTION "public"."read_version_usage"( - "p_app_id" character varying, - "p_period_start" timestamp without time zone, - "p_period_end" timestamp without time zone -) RETURNS TABLE( - "app_id" character varying, - "version_name" character varying, - "date" timestamp without time zone, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -) -LANGUAGE "plpgsql" -SET "search_path" TO '' -AS $$ -BEGIN - RETURN QUERY - SELECT - vu.app_id, - -- Use version_name if available (new data), otherwise look up from app_versions (old data) - COALESCE(vu.version_name, av.name)::character varying as version_name, - DATE_TRUNC('day', vu.timestamp) AS date, - SUM(CASE WHEN vu.action = 'get' THEN 1 ELSE 0 END) AS get, - SUM(CASE WHEN vu.action = 'fail' THEN 1 ELSE 0 END) AS fail, - SUM(CASE WHEN vu.action = 'install' THEN 1 ELSE 0 END) AS install, - SUM(CASE WHEN vu.action = 'uninstall' THEN 1 ELSE 0 END) AS uninstall - FROM public.version_usage vu - LEFT JOIN public.app_versions av ON vu.version_id = av.id AND vu.version_name IS NULL - WHERE - vu.app_id = p_app_id - AND vu.timestamp >= p_period_start - AND vu.timestamp < p_period_end - GROUP BY date, vu.app_id, COALESCE(vu.version_name, av.name) - ORDER BY date; -END; -$$; - --- 5. Create index on version_name for better query performance -CREATE INDEX IF NOT EXISTS "idx_version_usage_version_name" ON "public"."version_usage" ("version_name"); -CREATE INDEX IF NOT EXISTS "idx_daily_version_version_name" ON "public"."daily_version" ("version_name"); - --- 5b. Deduplicate daily_version rows that would violate the new unique constraint --- We aggregate metrics across duplicates so no data is lost when collapsing rows -WITH dedup AS ( - SELECT - app_id, - date, - version_name, - MIN(version_id) AS version_id, - SUM(get) AS get, - SUM(fail) AS fail, - SUM(install) AS install, - SUM(uninstall) AS uninstall - FROM public.daily_version - GROUP BY app_id, date, version_name -), deleted AS ( - DELETE FROM public.daily_version RETURNING 1 -) -INSERT INTO public.daily_version (app_id, date, version_name, version_id, get, fail, install, uninstall) -SELECT app_id, date, version_name, version_id, get, fail, install, uninstall -FROM dedup; - --- 6. Add unique constraint on (app_id, date, version_name) for upsert operations --- This constraint replaces the old primary key and will be used for ON CONFLICT -ALTER TABLE "public"."daily_version" ADD CONSTRAINT "daily_version_app_date_version_name_key" UNIQUE ("app_id", "date", "version_name"); diff --git a/supabase/migrations/20260119182934_add_use_new_rbac_to_get_orgs_v7.sql b/supabase/migrations/20260119182934_add_use_new_rbac_to_get_orgs_v7.sql deleted file mode 100644 index be2e532d8e..0000000000 --- a/supabase/migrations/20260119182934_add_use_new_rbac_to_get_orgs_v7.sql +++ /dev/null @@ -1,283 +0,0 @@ --- Add use_new_rbac to get_orgs_v7 function return type --- This field is needed by the frontend to conditionally show RBAC-related UI (e.g., app access tab) - --- Drop both overloads of get_orgs_v7 (with and without parameters) -DROP FUNCTION IF EXISTS public.get_orgs_v7(); -DROP FUNCTION IF EXISTS public.get_orgs_v7(uuid); - -CREATE FUNCTION public.get_orgs_v7(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - -- Calculate current billing cycle for each org - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - -- 2fa_has_access: true if enforcing_2fa is false OR (enforcing_2fa is true AND user has 2FA) - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - -- should_redact: true if org enforces 2FA and user doesn't have 2FA - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ), - -- Calculate password policy access status for user/org combinations - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - -- password_has_access: true if no policy OR (has policy AND user meets it) - public.user_meets_password_policy(userid, o.id) AS password_has_access, - -- should_redact: true if org has policy and user doesn't meet it - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - ou.user_right::varchar AS role, - -- Redact sensitive fields if user doesn't have 2FA or password policy access - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.available_credits, 0) - END AS credit_available, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.total_credits, 0) - END AS credit_total, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE ucb.next_expiration - END AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key, - o.use_new_rbac - FROM public.orgs o - JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM public; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM authenticated; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO postgres; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO service_role; - --- Update the get_orgs_v7() wrapper function with updated return type -CREATE OR REPLACE FUNCTION public.get_orgs_v7() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - -- Check if API key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v7() TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO service_role; diff --git a/supabase/migrations/20260120165047_rbac_invites.sql b/supabase/migrations/20260120165047_rbac_invites.sql deleted file mode 100644 index d56f2d65e2..0000000000 --- a/supabase/migrations/20260120165047_rbac_invites.sql +++ /dev/null @@ -1,1196 +0,0 @@ --- RBAC-native invite support - -ALTER TABLE public.tmp_users -ADD COLUMN IF NOT EXISTS rbac_role_name text; - -ALTER TABLE public.org_users -ADD COLUMN IF NOT EXISTS rbac_role_name text; - --- Map RBAC org roles to legacy user_min_right for compatibility paths -CREATE OR REPLACE FUNCTION public.rbac_legacy_right_for_org_role( - p_role_name text -) -RETURNS public.user_min_right -LANGUAGE plpgsql -SET search_path = '' -IMMUTABLE AS $$ -BEGIN - CASE p_role_name - WHEN public.rbac_role_org_super_admin() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_role_org_admin() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_role_org_billing_admin() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_role_org_member() THEN RETURN public.rbac_right_read(); - ELSE RETURN public.rbac_right_read(); - END CASE; -END; -$$; - -COMMENT ON FUNCTION public.rbac_legacy_right_for_org_role(text) IS -$$ -Maps RBAC org role names to legacy user_min_right values for compatibility with -legacy tables and RLS. -$$; - -ALTER FUNCTION public.rbac_legacy_right_for_org_role(text) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.rbac_legacy_right_for_org_role( - text -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_legacy_right_for_org_role( - text -) TO service_role; - --- RBAC-aware invite lookup (returns RBAC role name when available) -DROP FUNCTION IF EXISTS public.get_invite_by_magic_lookup(text); - -CREATE OR REPLACE FUNCTION public.get_invite_by_magic_lookup(lookup text) -RETURNS TABLE ( - org_name text, - org_logo text, - role text -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - o.name AS org_name, - o.logo AS org_logo, - COALESCE(tmp.rbac_role_name, tmp.role::text) AS role - FROM public.tmp_users tmp - JOIN public.orgs o ON tmp.org_id = o.id - WHERE tmp.invite_magic_string = get_invite_by_magic_lookup.lookup - AND tmp.cancelled_at IS NULL - AND tmp.created_at > (CURRENT_TIMESTAMP - INTERVAL '7 days'); -END; -$$; - -ALTER FUNCTION public.get_invite_by_magic_lookup(text) OWNER TO postgres; -GRANT ALL ON FUNCTION public.get_invite_by_magic_lookup(text) TO service_role; -GRANT EXECUTE ON FUNCTION public.get_invite_by_magic_lookup( - text -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_invite_by_magic_lookup(text) TO anon; - --- RBAC-native invite for existing users (keeps legacy invite flow) -CREATE OR REPLACE FUNCTION public.invite_user_to_org_rbac( - email varchar, - org_id uuid, - role_name text -) RETURNS varchar -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - org record; - invited_user record; - current_record record; - current_tmp_user record; - role_id uuid; - legacy_right public.user_min_right; - invite_right public.user_min_right; - api_key_text text; -BEGIN - SELECT * INTO org FROM public.orgs WHERE public.orgs.id = invite_user_to_org_rbac.org_id; - IF org IS NULL THEN - RETURN 'NO_ORG'; - END IF; - - IF NOT public.rbac_is_enabled_for_org(invite_user_to_org_rbac.org_id) THEN - RETURN 'RBAC_NOT_ENABLED'; - END IF; - - SELECT id INTO role_id - FROM public.roles r - WHERE r.name = invite_user_to_org_rbac.role_name - AND r.scope_type = public.rbac_scope_org() - AND r.is_assignable = true - LIMIT 1; - - IF role_id IS NULL THEN - RETURN 'ROLE_NOT_FOUND'; - END IF; - - SELECT public.get_apikey_header() INTO api_key_text; - - IF invite_user_to_org_rbac.role_name = public.rbac_role_org_super_admin() THEN - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN - RETURN 'NO_RIGHTS'; - END IF; - ELSE - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_invite_user(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN - RETURN 'NO_RIGHTS'; - END IF; - END IF; - - legacy_right := public.rbac_legacy_right_for_org_role(invite_user_to_org_rbac.role_name); - invite_right := public.transform_role_to_invite(legacy_right); - - SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email = invite_user_to_org_rbac.email; - - IF invited_user IS NOT NULL THEN - SELECT public.org_users.id INTO current_record - FROM public.org_users - WHERE public.org_users.user_id = invited_user.id - AND public.org_users.org_id = invite_user_to_org_rbac.org_id; - - IF current_record IS NOT NULL THEN - RETURN 'ALREADY_INVITED'; - ELSE - INSERT INTO public.org_users (user_id, org_id, user_right, rbac_role_name) - VALUES (invited_user.id, invite_user_to_org_rbac.org_id, invite_right, invite_user_to_org_rbac.role_name); - RETURN 'OK'; - END IF; - ELSE - SELECT * INTO current_tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email = invite_user_to_org_rbac.email - AND public.tmp_users.org_id = invite_user_to_org_rbac.org_id; - - IF current_tmp_user IS NOT NULL THEN - IF current_tmp_user.cancelled_at IS NOT NULL THEN - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - RETURN 'NO_EMAIL'; - END IF; - END IF; -END; -$$; - -COMMENT ON FUNCTION public.invite_user_to_org_rbac(varchar, uuid, text) IS -$$ -Invite a user to an organization using RBAC roles while preserving legacy invite -flow. -$$; - -ALTER FUNCTION public.invite_user_to_org_rbac( - varchar, uuid, text -) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.invite_user_to_org_rbac( - varchar, uuid, text -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.invite_user_to_org_rbac( - varchar, uuid, text -) TO service_role; - --- Update invite role for existing-user invitations (RBAC) -CREATE OR REPLACE FUNCTION public.update_org_invite_role_rbac( - p_org_id uuid, - p_user_id uuid, - p_new_role_name text -) RETURNS text -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - role_id uuid; - legacy_right public.user_min_right; - invite_right public.user_min_right; - api_key_text text; -BEGIN - IF NOT public.rbac_is_enabled_for_org(p_org_id) THEN - RAISE EXCEPTION 'RBAC_NOT_ENABLED'; - END IF; - - SELECT id INTO role_id - FROM public.roles r - WHERE r.name = p_new_role_name - AND r.scope_type = public.rbac_scope_org() - AND r.is_assignable = true - LIMIT 1; - - IF role_id IS NULL THEN - RAISE EXCEPTION 'ROLE_NOT_FOUND'; - END IF; - - SELECT public.get_apikey_header() INTO api_key_text; - - IF p_new_role_name = public.rbac_role_org_super_admin() THEN - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; - END IF; - ELSE - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_invite_user(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; - END IF; - END IF; - - legacy_right := public.rbac_legacy_right_for_org_role(p_new_role_name); - invite_right := public.transform_role_to_invite(legacy_right); - - UPDATE public.org_users - SET user_right = invite_right, - rbac_role_name = p_new_role_name, - updated_at = now() - WHERE org_id = p_org_id - AND user_id = p_user_id - AND user_right::text LIKE 'invite_%'; - - IF NOT FOUND THEN - RAISE EXCEPTION 'NO_INVITATION'; - END IF; - - RETURN 'OK'; -END; -$$; - -ALTER FUNCTION public.update_org_invite_role_rbac( - uuid, uuid, text -) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.update_org_invite_role_rbac( - uuid, uuid, text -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.update_org_invite_role_rbac( - uuid, uuid, text -) TO service_role; - --- Update invite role for new-user invitations (RBAC) -CREATE OR REPLACE FUNCTION public.update_tmp_invite_role_rbac( - p_org_id uuid, - p_email text, - p_new_role_name text -) RETURNS text -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - role_id uuid; - legacy_right public.user_min_right; - api_key_text text; -BEGIN - IF NOT public.rbac_is_enabled_for_org(p_org_id) THEN - RAISE EXCEPTION 'RBAC_NOT_ENABLED'; - END IF; - - SELECT id INTO role_id - FROM public.roles r - WHERE r.name = p_new_role_name - AND r.scope_type = public.rbac_scope_org() - AND r.is_assignable = true - LIMIT 1; - - IF role_id IS NULL THEN - RAISE EXCEPTION 'ROLE_NOT_FOUND'; - END IF; - - SELECT public.get_apikey_header() INTO api_key_text; - - IF p_new_role_name = public.rbac_role_org_super_admin() THEN - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; - END IF; - ELSE - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_invite_user(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; - END IF; - END IF; - - legacy_right := public.rbac_legacy_right_for_org_role(p_new_role_name); - - UPDATE public.tmp_users - SET role = legacy_right, - rbac_role_name = p_new_role_name, - updated_at = now() - WHERE org_id = p_org_id - AND email = p_email - AND cancelled_at IS NULL; - - IF NOT FOUND THEN - RAISE EXCEPTION 'NO_INVITATION'; - END IF; - - RETURN 'OK'; -END; -$$; - -ALTER FUNCTION public.update_tmp_invite_role_rbac( - uuid, text, text -) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.update_tmp_invite_role_rbac( - uuid, text, text -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.update_tmp_invite_role_rbac( - uuid, text, text -) TO service_role; - --- RBAC-aware accept invitation for existing users -CREATE OR REPLACE FUNCTION public.accept_invitation_to_org(org_id uuid) -RETURNS varchar -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - invite record; - use_rbac boolean; - legacy_right public.user_min_right; - role_id uuid; -BEGIN - SELECT org_users.* FROM public.org_users - INTO invite - WHERE org_users.org_id = accept_invitation_to_org.org_id - AND (SELECT auth.uid()) = org_users.user_id; - - IF invite IS NULL THEN - RETURN 'NO_INVITE'; - END IF; - - IF NOT (invite.user_right::varchar ILIKE 'invite_' || '%') THEN - RETURN 'INVALID_ROLE'; - END IF; - - use_rbac := public.rbac_is_enabled_for_org(invite.org_id); - - IF use_rbac AND invite.rbac_role_name IS NOT NULL THEN - legacy_right := public.rbac_legacy_right_for_org_role(invite.rbac_role_name); - - UPDATE public.org_users - SET user_right = legacy_right, - updated_at = CURRENT_TIMESTAMP - WHERE org_users.id = invite.id; - - SELECT id INTO role_id FROM public.roles - WHERE name = invite.rbac_role_name - AND scope_type = public.rbac_scope_org() - LIMIT 1; - - IF role_id IS NULL THEN - RETURN 'ROLE_NOT_FOUND'; - END IF; - - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = invite.user_id - AND scope_type = public.rbac_scope_org() - AND role_bindings.org_id = invite.org_id; - - INSERT INTO public.role_bindings ( - principal_type, - principal_id, - role_id, - scope_type, - org_id, - app_id, - channel_id, - granted_by, - granted_at, - reason, - is_direct - ) VALUES ( - public.rbac_principal_user(), - invite.user_id, - role_id, - public.rbac_scope_org(), - invite.org_id, - NULL, - NULL, - auth.uid(), - now(), - 'Accepted invitation', - true - ) ON CONFLICT DO NOTHING; - - RETURN 'OK'; - END IF; - - UPDATE public.org_users - SET user_right = REPLACE(invite.user_right::varchar, 'invite_', '')::public.user_min_right - WHERE org_users.id = invite.id; - - RETURN 'OK'; -END; -$$; - -ALTER FUNCTION public.accept_invitation_to_org(uuid) OWNER TO postgres; - --- Sync org_users inserts to role_bindings, skipping RBAC-managed rows -CREATE OR REPLACE FUNCTION public.sync_org_user_to_role_binding() -RETURNS trigger -LANGUAGE plpgsql SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - role_name_to_bind text; - role_id_to_bind uuid; - org_member_role_id uuid; - app_role_name text; - app_role_id uuid; - v_app RECORD; - v_app_uuid uuid; - v_channel_uuid uuid; - v_granted_by uuid; - v_sync_reason text := 'Synced from org_users'; - v_use_rbac boolean; -BEGIN - SELECT use_new_rbac INTO v_use_rbac FROM public.orgs WHERE id = NEW.org_id; - IF v_use_rbac AND NEW.rbac_role_name IS NOT NULL THEN - RETURN NEW; - END IF; - - v_granted_by := COALESCE(auth.uid(), NEW.user_id); - - -- Handle org-level rights (no app_id, no channel_id) - IF NEW.app_id IS NULL AND NEW.channel_id IS NULL THEN - -- For super_admin and admin: create org-level binding directly - IF NEW.user_right IN (public.rbac_right_super_admin(), public.rbac_right_admin()) THEN - CASE NEW.user_right - WHEN public.rbac_right_super_admin() THEN role_name_to_bind := public.rbac_role_org_super_admin(); - WHEN public.rbac_right_admin() THEN role_name_to_bind := public.rbac_role_org_admin(); - END CASE; - - SELECT id INTO role_id_to_bind FROM public.roles WHERE name = role_name_to_bind LIMIT 1; - - IF role_id_to_bind IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, role_id_to_bind, public.rbac_scope_org(), NEW.org_id, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - - -- For read/upload/write at org level: create org_member + app-level roles for each app - ELSIF NEW.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) THEN - -- 1) Create org_member binding at org level - SELECT id INTO org_member_role_id FROM public.roles WHERE name = public.rbac_role_org_member() LIMIT 1; - IF org_member_role_id IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, org_member_role_id, public.rbac_scope_org(), NEW.org_id, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - - -- 2) Determine app-level role based on user_right - CASE NEW.user_right - WHEN public.rbac_right_read() THEN app_role_name := public.rbac_role_app_reader(); - WHEN public.rbac_right_upload() THEN app_role_name := public.rbac_role_app_uploader(); - WHEN public.rbac_right_write() THEN app_role_name := public.rbac_role_app_developer(); - END CASE; - - SELECT id INTO app_role_id FROM public.roles WHERE name = app_role_name LIMIT 1; - - -- 3) Create app-level binding for EACH app in the org - IF app_role_id IS NOT NULL THEN - FOR v_app IN SELECT id FROM public.apps WHERE owner_org = NEW.org_id - LOOP - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, app_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, app_role_id, public.rbac_scope_app(), NEW.org_id, v_app.id, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END LOOP; - END IF; - END IF; - - -- Handle app-level rights (has app_id, no channel_id) - ELSIF NEW.app_id IS NOT NULL AND NEW.channel_id IS NULL THEN - CASE NEW.user_right - WHEN public.rbac_right_super_admin() THEN role_name_to_bind := public.rbac_role_app_admin(); - WHEN public.rbac_right_admin() THEN role_name_to_bind := public.rbac_role_app_admin(); - WHEN public.rbac_right_write() THEN role_name_to_bind := public.rbac_role_app_developer(); - WHEN public.rbac_right_upload() THEN role_name_to_bind := public.rbac_role_app_uploader(); - WHEN public.rbac_right_read() THEN role_name_to_bind := public.rbac_role_app_reader(); - ELSE role_name_to_bind := public.rbac_role_app_reader(); - END CASE; - - SELECT id INTO role_id_to_bind FROM public.roles WHERE name = role_name_to_bind LIMIT 1; - SELECT id INTO v_app_uuid FROM public.apps WHERE app_id = NEW.app_id LIMIT 1; - - IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, app_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, role_id_to_bind, public.rbac_scope_app(), NEW.org_id, v_app_uuid, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - - -- Handle channel-level rights (has app_id and channel_id) - ELSIF NEW.app_id IS NOT NULL AND NEW.channel_id IS NOT NULL THEN - CASE NEW.user_right - WHEN public.rbac_right_super_admin() THEN role_name_to_bind := public.rbac_role_channel_admin(); - WHEN public.rbac_right_admin() THEN role_name_to_bind := public.rbac_role_channel_admin(); - WHEN public.rbac_right_write() THEN role_name_to_bind := 'channel_developer'; - WHEN public.rbac_right_upload() THEN role_name_to_bind := 'channel_uploader'; - WHEN public.rbac_right_read() THEN role_name_to_bind := public.rbac_role_channel_reader(); - ELSE role_name_to_bind := public.rbac_role_channel_reader(); - END CASE; - - SELECT id INTO role_id_to_bind FROM public.roles WHERE name = role_name_to_bind LIMIT 1; - SELECT id INTO v_app_uuid FROM public.apps WHERE app_id = NEW.app_id LIMIT 1; - SELECT rbac_id INTO v_channel_uuid FROM public.channels WHERE id = NEW.channel_id LIMIT 1; - - IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL AND v_channel_uuid IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, app_id, channel_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, role_id_to_bind, public.rbac_scope_channel(), NEW.org_id, v_app_uuid, v_channel_uuid, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.sync_org_user_to_role_binding() OWNER TO postgres; - --- Sync org_users updates to role_bindings, skipping RBAC-managed rows -CREATE OR REPLACE FUNCTION public.sync_org_user_role_binding_on_update() -RETURNS trigger -LANGUAGE plpgsql SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - old_org_role_name text; - new_org_role_name text; - old_org_role_id uuid; - new_org_role_id uuid; - old_app_role_name text; - new_app_role_name text; - old_app_role_id uuid; - new_app_role_id uuid; - org_member_role_id uuid; - v_app RECORD; - v_granted_by uuid; - v_update_reason text := 'Updated from org_users'; - v_use_rbac boolean; -BEGIN - SELECT use_new_rbac INTO v_use_rbac FROM public.orgs WHERE id = NEW.org_id; - IF v_use_rbac AND (NEW.rbac_role_name IS NOT NULL OR OLD.rbac_role_name IS NOT NULL) THEN - RETURN NEW; - END IF; - - -- Only process if user_right actually changed - IF OLD.user_right = NEW.user_right THEN - RETURN NEW; - END IF; - - -- Only handle org-level rights (no app_id, no channel_id) - IF NEW.app_id IS NOT NULL OR NEW.channel_id IS NOT NULL THEN - RETURN NEW; - END IF; - - v_granted_by := COALESCE(auth.uid(), NEW.user_id); - - -- Map old user_right to role names - CASE OLD.user_right - WHEN public.rbac_right_super_admin() THEN - old_org_role_name := public.rbac_role_org_super_admin(); - old_app_role_name := NULL; - WHEN public.rbac_right_admin() THEN - old_org_role_name := public.rbac_role_org_admin(); - old_app_role_name := NULL; - WHEN public.rbac_right_write() THEN - old_org_role_name := public.rbac_role_org_member(); - old_app_role_name := public.rbac_role_app_developer(); - WHEN public.rbac_right_upload() THEN - old_org_role_name := public.rbac_role_org_member(); - old_app_role_name := public.rbac_role_app_uploader(); - WHEN public.rbac_right_read() THEN - old_org_role_name := public.rbac_role_org_member(); - old_app_role_name := public.rbac_role_app_reader(); - WHEN 'invite_super_admin'::public.user_min_right THEN - old_org_role_name := NULL; - old_app_role_name := NULL; - WHEN 'invite_admin'::public.user_min_right THEN - old_org_role_name := NULL; - old_app_role_name := NULL; - WHEN 'invite_write'::public.user_min_right THEN - old_org_role_name := NULL; - old_app_role_name := NULL; - WHEN 'invite_upload'::public.user_min_right THEN - old_org_role_name := NULL; - old_app_role_name := NULL; - WHEN 'invite_read'::public.user_min_right THEN - old_org_role_name := NULL; - old_app_role_name := NULL; - ELSE - RAISE WARNING 'Unexpected OLD.user_right value: %, skipping role binding sync', OLD.user_right; - RETURN NEW; - END CASE; - - -- Map new user_right to role names - CASE NEW.user_right - WHEN public.rbac_right_super_admin() THEN - new_org_role_name := public.rbac_role_org_super_admin(); - new_app_role_name := NULL; - WHEN public.rbac_right_admin() THEN - new_org_role_name := public.rbac_role_org_admin(); - new_app_role_name := NULL; - WHEN public.rbac_right_write() THEN - new_org_role_name := public.rbac_role_org_member(); - new_app_role_name := public.rbac_role_app_developer(); - WHEN public.rbac_right_upload() THEN - new_org_role_name := public.rbac_role_org_member(); - new_app_role_name := public.rbac_role_app_uploader(); - WHEN public.rbac_right_read() THEN - new_org_role_name := public.rbac_role_org_member(); - new_app_role_name := public.rbac_role_app_reader(); - WHEN 'invite_super_admin'::public.user_min_right THEN - new_org_role_name := NULL; - new_app_role_name := NULL; - WHEN 'invite_admin'::public.user_min_right THEN - new_org_role_name := NULL; - new_app_role_name := NULL; - WHEN 'invite_write'::public.user_min_right THEN - new_org_role_name := NULL; - new_app_role_name := NULL; - WHEN 'invite_upload'::public.user_min_right THEN - new_org_role_name := NULL; - new_app_role_name := NULL; - WHEN 'invite_read'::public.user_min_right THEN - new_org_role_name := NULL; - new_app_role_name := NULL; - ELSE - RAISE WARNING 'Unexpected NEW.user_right value: %, skipping role binding sync', NEW.user_right; - RETURN NEW; - END CASE; - - -- Get role IDs - IF old_org_role_name IS NOT NULL THEN - SELECT id INTO old_org_role_id FROM public.roles WHERE name = old_org_role_name LIMIT 1; - END IF; - - IF new_org_role_name IS NOT NULL THEN - SELECT id INTO new_org_role_id FROM public.roles WHERE name = new_org_role_name LIMIT 1; - END IF; - SELECT id INTO org_member_role_id FROM public.roles WHERE name = public.rbac_role_org_member() LIMIT 1; - - IF old_app_role_name IS NOT NULL THEN - SELECT id INTO old_app_role_id FROM public.roles WHERE name = old_app_role_name LIMIT 1; - END IF; - - IF new_app_role_name IS NOT NULL THEN - SELECT id INTO new_app_role_id FROM public.roles WHERE name = new_app_role_name LIMIT 1; - END IF; - - -- Delete old org-level binding (only if there was a role) - IF old_org_role_id IS NOT NULL THEN - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = NEW.user_id - AND scope_type = public.rbac_scope_org() - AND org_id = NEW.org_id - AND role_id = old_org_role_id; - END IF; - - -- Delete old app-level bindings (for read/upload/write users) - IF old_app_role_id IS NOT NULL THEN - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = NEW.user_id - AND scope_type = public.rbac_scope_app() - AND org_id = NEW.org_id - AND role_id = old_app_role_id; - END IF; - - -- Create new org-level binding - IF new_org_role_id IS NOT NULL THEN - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, new_org_role_id, public.rbac_scope_org(), NEW.org_id, - v_granted_by, now(), v_update_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - - -- Create new app-level bindings for each app (for read/upload/write users) - IF new_app_role_id IS NOT NULL THEN - FOR v_app IN SELECT id FROM public.apps WHERE owner_org = NEW.org_id - LOOP - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, app_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), NEW.user_id, new_app_role_id, public.rbac_scope_app(), NEW.org_id, v_app.id, - v_granted_by, now(), v_update_reason, true - ) ON CONFLICT DO NOTHING; - END LOOP; - END IF; - - -- Handle transition from admin/super_admin to read/upload/write: - IF OLD.user_right IN (public.rbac_right_super_admin(), public.rbac_right_admin()) - AND NEW.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) THEN - NULL; - END IF; - - -- Handle transition from read/upload/write to admin/super_admin: - IF OLD.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) - AND NEW.user_right IN (public.rbac_right_super_admin(), public.rbac_right_admin()) THEN - IF org_member_role_id IS NOT NULL THEN - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = NEW.user_id - AND scope_type = public.rbac_scope_org() - AND org_id = NEW.org_id - AND role_id = org_member_role_id; - END IF; - - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_user() - AND principal_id = NEW.user_id - AND scope_type = public.rbac_scope_app() - AND org_id = NEW.org_id; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.sync_org_user_role_binding_on_update() OWNER TO postgres; - --- RBAC-aware org members list (includes pending invites) -DROP FUNCTION IF EXISTS public.get_org_members_rbac(uuid); - -CREATE OR REPLACE FUNCTION public.get_org_members_rbac(p_org_id uuid) -RETURNS TABLE ( - user_id uuid, - email character varying, - image_url character varying, - role_name text, - role_id uuid, - binding_id uuid, - granted_at timestamptz, - is_invite boolean, - is_tmp boolean, - org_user_id bigint -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_read(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_MEMBERS'; - END IF; - - RETURN QUERY - WITH rbac_members AS ( - SELECT - u.id AS user_id, - u.email, - u.image_url, - r.name AS role_name, - rb.role_id, - rb.id AS binding_id, - rb.granted_at, - false AS is_invite, - false AS is_tmp, - NULL::bigint AS org_user_id - FROM public.users u - INNER JOIN public.role_bindings rb ON rb.principal_id = u.id - AND rb.principal_type = public.rbac_principal_user() - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE r.scope_type = public.rbac_scope_org() - AND r.name LIKE 'org_%' - ), - legacy_invites AS ( - SELECT - u.id AS user_id, - u.email, - u.image_url, - COALESCE( - ou.rbac_role_name, - CASE public.transform_role_to_non_invite(ou.user_right) - WHEN public.rbac_right_super_admin() THEN public.rbac_role_org_super_admin() - WHEN public.rbac_right_admin() THEN public.rbac_role_org_admin() - ELSE public.rbac_role_org_member() - END - ) AS role_name, - NULL::uuid AS role_id, - NULL::uuid AS binding_id, - ou.created_at AS granted_at, - true AS is_invite, - false AS is_tmp, - ou.id AS org_user_id - FROM public.org_users ou - INNER JOIN public.users u ON u.id = ou.user_id - WHERE ou.org_id = p_org_id - AND ou.user_right::text LIKE 'invite_%' - ), - tmp_invites AS ( - SELECT - tmp.future_uuid AS user_id, - tmp.email, - ''::character varying AS image_url, - COALESCE( - tmp.rbac_role_name, - CASE tmp.role - WHEN public.rbac_right_super_admin() THEN public.rbac_role_org_super_admin() - WHEN public.rbac_right_admin() THEN public.rbac_role_org_admin() - ELSE public.rbac_role_org_member() - END - ) AS role_name, - NULL::uuid AS role_id, - NULL::uuid AS binding_id, - tmp.created_at AS granted_at, - true AS is_invite, - true AS is_tmp, - NULL::bigint AS org_user_id - FROM public.tmp_users tmp - WHERE tmp.org_id = p_org_id - AND tmp.cancelled_at IS NULL - AND tmp.created_at > (CURRENT_TIMESTAMP - INTERVAL '7 days') - ) - SELECT * - FROM ( - SELECT * FROM rbac_members - UNION ALL - SELECT * FROM legacy_invites - UNION ALL - SELECT * FROM tmp_invites - ) AS combined - ORDER BY - combined.is_invite, - CASE combined.role_name - WHEN public.rbac_role_org_super_admin() THEN 1 - WHEN public.rbac_role_org_admin() THEN 2 - WHEN public.rbac_role_org_billing_admin() THEN 3 - WHEN public.rbac_role_org_member() THEN 4 - ELSE 5 - END, - combined.email; -END; -$$; - -ALTER FUNCTION public.get_org_members_rbac(uuid) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.get_org_members_rbac(uuid) TO authenticated; - -COMMENT ON FUNCTION public.get_org_members_rbac(uuid) IS -$$ -Returns organization members and pending invites with their RBAC roles. Requires -org.read permission. -$$; - --- RBAC-aware org list with RBAC roles when enabled -DROP FUNCTION IF EXISTS public.get_orgs_v7(uuid); - -CREATE FUNCTION public.get_orgs_v7(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - rbac_roles AS ( - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION ALL - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - rbac_org_roles AS ( - SELECT org_id, (ARRAY_AGG(rbac_roles.name ORDER BY rbac_roles.priority_rank DESC))[1] AS role_name - FROM rbac_roles - GROUP BY org_id - ), - user_orgs AS ( - SELECT ou.org_id - FROM public.org_users ou - WHERE ou.user_id = userid - UNION - SELECT rbac_org_roles.org_id - FROM rbac_org_roles - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - -- Calculate current billing cycle for each org - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ), - -- Calculate password policy access status for user/org combinations - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - public.user_meets_password_policy(userid, o.id) AS password_has_access, - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - CASE - WHEN o.use_new_rbac AND ou.user_right::text LIKE 'invite_%' THEN ou.user_right::varchar - WHEN o.use_new_rbac THEN COALESCE(ror.role_name, ou.rbac_role_name, ou.user_right::varchar) - ELSE COALESCE(ou.user_right::varchar, ror.role_name) - END AS role, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.available_credits, 0) - END AS credit_available, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.total_credits, 0) - END AS credit_total, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE ucb.next_expiration - END AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key, - o.use_new_rbac - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - LEFT JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id - LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(uuid) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM public; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO postgres; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO service_role; - --- Update wrapper to match updated return type -DROP FUNCTION IF EXISTS public.get_orgs_v7(); - -CREATE OR REPLACE FUNCTION public.get_orgs_v7() -RETURNS TABLE ( - gid uuid, - created_by uuid, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO postgres; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO service_role; diff --git a/supabase/migrations/20260121000000_add_demo_app_support.sql b/supabase/migrations/20260121000000_add_demo_app_support.sql deleted file mode 100644 index ecc7aae50b..0000000000 --- a/supabase/migrations/20260121000000_add_demo_app_support.sql +++ /dev/null @@ -1,65 +0,0 @@ --- Auto-delete demo apps after 14 days --- Demo apps are identified by app_id starting with 'com.capdemo.' --- Uses existing created_at column to determine age --- All related data is cleaned up via CASCADE foreign keys + on_app_delete trigger - --- Simple function that deletes expired demo apps --- CASCADE handles related data cleanup, on_app_delete trigger handles S3/storage cleanup -CREATE OR REPLACE FUNCTION public.cleanup_expired_demo_apps() RETURNS void -LANGUAGE plpgsql -SET search_path TO '' -AS $$ -DECLARE - deleted_count bigint; -BEGIN - DELETE FROM public.apps - WHERE app_id LIKE 'com.capdemo.%' - AND created_at < NOW() - INTERVAL '14 days'; - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - RAISE NOTICE 'cleanup_expired_demo_apps: Deleted % expired demo apps', deleted_count; -END; -$$; - --- Security: internal function only -REVOKE EXECUTE ON FUNCTION public.cleanup_expired_demo_apps() FROM public; -GRANT EXECUTE ON FUNCTION public.cleanup_expired_demo_apps() TO service_role; - --- Register cron task to run cleanup daily at 03:00:00 UTC -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - batch_size, - second_interval, - minute_interval, - hour_interval, - run_at_hour, - run_at_minute, - run_at_second, - run_on_dow, - run_on_day -) VALUES ( - 'cleanup_expired_demo_apps', - 'Delete demo apps (app_id starts with com.capdemo.) older than 14 days', - 'function', - 'public.cleanup_expired_demo_apps()', - null, - null, - null, - null, - 3, -- run_at_hour (03:00 UTC) - 0, -- run_at_minute - 0, -- run_at_second - null, - null -) -ON CONFLICT (name) DO UPDATE SET - description = excluded.description, - task_type = excluded.task_type, - target = excluded.target, - run_at_hour = excluded.run_at_hour, - run_at_minute = excluded.run_at_minute, - run_at_second = excluded.run_at_second, - updated_at = NOW(); diff --git a/supabase/migrations/20260123140712_fix_rbac_perf_security.sql b/supabase/migrations/20260123140712_fix_rbac_perf_security.sql deleted file mode 100644 index abf9df66a0..0000000000 --- a/supabase/migrations/20260123140712_fix_rbac_perf_security.sql +++ /dev/null @@ -1,659 +0,0 @@ --- ============================================================================= --- Fix RBAC Performance and Security Issues --- ============================================================================= --- This migration addresses: --- 1. Security: Add search_path to is_user_app_admin and is_user_org_admin --- 2. Performance: Use (SELECT auth.uid()) pattern in RLS policies to avoid --- multiple auth.uid() evaluations per row --- 3. Multiple Permissive Policies: Remove duplicate SELECT policies on role_bindings --- 4. Security: Restrict function access - only authenticated users should access --- RBAC helper functions, not anon/public --- ============================================================================= - --- ============================================================================= --- 1. FIX SECURITY: Add search_path to functions --- ============================================================================= - --- Fix is_user_org_admin - add search_path for security -CREATE OR REPLACE FUNCTION public.is_user_org_admin( - p_user_id uuid, p_org_id uuid -) -RETURNS boolean -LANGUAGE sql -SECURITY DEFINER -STABLE -SET search_path = '' -AS $$ - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - AND rb.org_id = p_org_id - AND rb.scope_type = public.rbac_scope_org() - AND r.name IN (public.rbac_role_platform_super_admin(), public.rbac_role_org_super_admin(), public.rbac_role_org_admin()) - ); -$$; - -COMMENT ON FUNCTION public.is_user_org_admin(uuid, uuid) IS -'Checks whether a user has an admin role in an organization (bypasses RLS to avoid recursion).'; - --- Fix is_user_app_admin - add search_path for security and fix org-level role inheritance -CREATE OR REPLACE FUNCTION public.is_user_app_admin( - p_user_id uuid, p_app_id uuid -) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -STABLE -SET search_path = '' -AS $$ -DECLARE - v_org_id uuid; -BEGIN - -- Get the org that owns the app - SELECT owner_org INTO v_org_id - FROM public.apps - WHERE id = p_app_id - LIMIT 1; - - IF v_org_id IS NULL THEN - RETURN false; - END IF; - - -- Check for app-scoped admin roles OR org-scoped admin roles (inheritance) - RETURN EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - AND ( - -- App-scoped bindings - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = p_app_id) - OR - -- Org-scoped bindings (inherit org admin to app) - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = v_org_id) - ) - AND r.name IN (public.rbac_role_app_admin(), public.rbac_role_org_super_admin(), public.rbac_role_org_admin(), public.rbac_role_platform_super_admin()) - ); -END; -$$; - -COMMENT ON FUNCTION public.is_user_app_admin(uuid, uuid) IS -'Checks whether a user has an admin role for an app, including inherited org-level admin roles (bypasses RLS to avoid recursion).'; - --- ============================================================================= --- 2. RESTRICT FUNCTION ACCESS: Only authenticated users, not anon/public --- ============================================================================= - --- Restrict is_user_org_admin -REVOKE ALL ON FUNCTION public.is_user_org_admin(uuid, uuid) FROM public; -REVOKE ALL ON FUNCTION public.is_user_org_admin(uuid, uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.is_user_org_admin(uuid, uuid) TO authenticated; -GRANT EXECUTE ON FUNCTION public.is_user_org_admin(uuid, uuid) TO service_role; - --- Restrict is_user_app_admin -REVOKE ALL ON FUNCTION public.is_user_app_admin(uuid, uuid) FROM public; -REVOKE ALL ON FUNCTION public.is_user_app_admin(uuid, uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.is_user_app_admin(uuid, uuid) TO authenticated; -GRANT EXECUTE ON FUNCTION public.is_user_app_admin(uuid, uuid) TO service_role; - --- ============================================================================= --- 3. FIX MULTIPLE PERMISSIVE POLICIES: Remove duplicate SELECT on role_bindings --- ============================================================================= - --- Drop the older, less optimized policy -DROP POLICY IF EXISTS role_bindings_read_scope_member ON public.role_bindings; - --- The "Allow viewing role bindings with permission" policy already covers this with better logic --- We'll recreate it with optimized auth.uid() pattern - -DROP POLICY IF EXISTS "Allow viewing role bindings with permission" ON public.role_bindings; - -CREATE POLICY "Allow viewing role bindings with permission" -ON public.role_bindings -FOR SELECT -TO authenticated -USING ( - -- Use (SELECT auth.uid()) to evaluate once per query, not per row - -- Org admins can see all bindings in their org - public.is_user_org_admin((SELECT auth.uid()), org_id) - OR - -- App admins can see app-scoped bindings - ( - scope_type = public.rbac_scope_app() - AND public.is_user_app_admin((SELECT auth.uid()), app_id) - ) - OR - -- Users with a role in the app can see app-scoped bindings - ( - scope_type = public.rbac_scope_app() - AND app_id IS NOT NULL - AND public.user_has_role_in_app((SELECT auth.uid()), app_id) - ) - OR - -- Channel-scope bindings: visible to app admins of the parent app - ( - scope_type = public.rbac_scope_channel() - AND channel_id IS NOT NULL - AND EXISTS ( - SELECT 1 FROM public.channels AS c - INNER JOIN public.apps AS a ON c.app_id = a.app_id - WHERE - c.rbac_id = role_bindings.channel_id - AND public.is_user_app_admin((SELECT auth.uid()), a.id) - ) - ) -); - -COMMENT ON POLICY "Allow viewing role bindings with permission" ON public.role_bindings IS -'Allows viewing role bindings if the user is org admin, app admin, or has a role in the app. Includes channel-scope visibility for app admins. Optimized with (SELECT auth.uid()) pattern.'; - --- ============================================================================= --- 4. FIX PERFORMANCE: Optimize RLS policies with (SELECT auth.uid()) pattern --- ============================================================================= - --- Fix rbac_settings policies -DROP POLICY IF EXISTS rbac_settings_read_authenticated ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_admin_all ON public.rbac_settings; - -CREATE POLICY rbac_settings_read_authenticated ON public.rbac_settings -FOR SELECT -TO authenticated -USING (TRUE); - -CREATE POLICY rbac_settings_admin_all ON public.rbac_settings -FOR ALL -TO authenticated -USING (public.is_admin((SELECT auth.uid()))) -WITH CHECK (public.is_admin((SELECT auth.uid()))); - --- Fix roles policies -DROP POLICY IF EXISTS roles_admin_write ON public.roles; - -CREATE POLICY roles_admin_write ON public.roles -FOR ALL -TO authenticated -USING (public.is_admin((SELECT auth.uid()))) -WITH CHECK (public.is_admin((SELECT auth.uid()))); - --- Fix permissions policies -DROP POLICY IF EXISTS permissions_admin_write ON public.permissions; - -CREATE POLICY permissions_admin_write ON public.permissions -FOR ALL -TO authenticated -USING (public.is_admin((SELECT auth.uid()))) -WITH CHECK (public.is_admin((SELECT auth.uid()))); - --- Fix role_permissions policies -DROP POLICY IF EXISTS role_permissions_admin_write ON public.role_permissions; - -CREATE POLICY role_permissions_admin_write ON public.role_permissions -FOR ALL -TO authenticated -USING (public.is_admin((SELECT auth.uid()))) -WITH CHECK (public.is_admin((SELECT auth.uid()))); - --- Fix role_hierarchy policies -DROP POLICY IF EXISTS role_hierarchy_admin_write ON public.role_hierarchy; - -CREATE POLICY role_hierarchy_admin_write ON public.role_hierarchy -FOR ALL -TO authenticated -USING (public.is_admin((SELECT auth.uid()))) -WITH CHECK (public.is_admin((SELECT auth.uid()))); - --- Fix groups policies -DROP POLICY IF EXISTS groups_read_org_member ON public.groups; -DROP POLICY IF EXISTS groups_write_org_admin ON public.groups; - -CREATE POLICY groups_read_org_member ON public.groups -FOR SELECT -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM public.org_users - WHERE - org_users.org_id = groups.org_id - AND org_users.user_id = (SELECT auth.uid()) - ) - OR - public.is_admin((SELECT auth.uid())) -); - -CREATE POLICY groups_write_org_admin ON public.groups -FOR ALL -TO authenticated -USING ( - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - org_id, - NULL::varchar, - NULL::bigint - ) - OR - public.is_admin((SELECT auth.uid())) -) -WITH CHECK ( - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - org_id, - NULL::varchar, - NULL::bigint - ) - OR - public.is_admin((SELECT auth.uid())) -); - --- Fix group_members policies -DROP POLICY IF EXISTS group_members_read_org_member ON public.group_members; -DROP POLICY IF EXISTS group_members_write_org_admin ON public.group_members; - -CREATE POLICY group_members_read_org_member ON public.group_members -FOR SELECT -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM public.groups - INNER JOIN public.org_users ON groups.org_id = org_users.org_id - WHERE - groups.id = group_members.group_id - AND org_users.user_id = (SELECT auth.uid()) - ) - OR - public.is_admin((SELECT auth.uid())) -); - -CREATE POLICY group_members_write_org_admin ON public.group_members -FOR ALL -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM public.groups - WHERE - groups.id = group_members.group_id - AND ( - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - groups.org_id, - NULL::varchar, - NULL::bigint - ) - OR public.is_admin((SELECT auth.uid())) - ) - ) -) -WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.groups - WHERE - groups.id = group_members.group_id - AND ( - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - groups.org_id, - NULL::varchar, - NULL::bigint - ) - OR public.is_admin((SELECT auth.uid())) - ) - ) -); - --- Fix role_bindings write policy -DROP POLICY IF EXISTS role_bindings_write_scope_admin ON public.role_bindings; - -CREATE POLICY role_bindings_write_scope_admin ON public.role_bindings -FOR ALL -TO authenticated -USING ( - ( - scope_type = public.rbac_scope_platform() - AND public.is_admin((SELECT auth.uid())) - ) - OR - ( - scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - org_id, - NULL::varchar, - NULL::bigint - ) - ) - OR - (scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - apps.owner_org, - apps.app_id, - NULL::bigint - ) - )) - OR - (scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - apps.owner_org, - channels.app_id, - channels.id - ) - )) - OR - public.is_admin((SELECT auth.uid())) -) -WITH CHECK ( - ( - scope_type = public.rbac_scope_platform() - AND public.is_admin((SELECT auth.uid())) - ) - OR - ( - scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - org_id, - NULL::varchar, - NULL::bigint - ) - ) - OR - (scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - apps.owner_org, - apps.app_id, - NULL::bigint - ) - )) - OR - (scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - (SELECT auth.uid()), - apps.owner_org, - channels.app_id, - channels.id - ) - )) - OR - public.is_admin((SELECT auth.uid())) -); - --- Fix role_bindings delete policy -DROP POLICY IF EXISTS "Allow admins to delete manageable role bindings" ON public.role_bindings; - -CREATE POLICY "Allow admins to delete manageable role bindings" -ON public.role_bindings -FOR DELETE -TO authenticated -USING ( - ( - scope_type = public.rbac_scope_app() - AND public.user_has_app_update_user_roles((SELECT auth.uid()), app_id) - ) - OR - ( - scope_type = public.rbac_scope_app() - AND principal_type = public.rbac_principal_user() - AND principal_id = (SELECT auth.uid()) - ) -); - -COMMENT ON POLICY "Allow admins to delete manageable role bindings" ON public.role_bindings IS -'Allows users with app.update_user_roles permission and the user themselves to delete role bindings. Optimized with (SELECT auth.uid()) pattern.'; - --- ============================================================================= --- 5. FIX user_has_role_in_app: Use (SELECT auth.uid()) pattern --- ============================================================================= - -CREATE OR REPLACE FUNCTION public.user_has_role_in_app( - p_user_id uuid, p_app_id uuid -) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -STABLE -SET search_path = '' -AS $$ -DECLARE - v_caller_id uuid; - v_org_id uuid; -BEGIN - -- Use SELECT to evaluate auth.uid() once - SELECT auth.uid() INTO v_caller_id; - - IF v_caller_id IS NULL THEN - RETURN false; - END IF; - - IF v_caller_id <> p_user_id THEN - SELECT owner_org INTO v_org_id - FROM public.apps - WHERE id = p_app_id - LIMIT 1; - - IF v_org_id IS NULL THEN - RETURN false; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_caller_id - AND (rb.org_id = v_org_id OR rb.app_id = p_app_id) - ) THEN - RETURN false; - END IF; - END IF; - - RETURN EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - AND rb.app_id = p_app_id - AND rb.scope_type = public.rbac_scope_app() - ); -END; -$$; - -COMMENT ON FUNCTION public.user_has_role_in_app(uuid, uuid) IS -'Checks whether a user has a role in an app (bypasses RLS to avoid recursion). Optimized with SELECT auth.uid() pattern.'; - --- Restrict user_has_role_in_app -REVOKE ALL ON FUNCTION public.user_has_role_in_app(uuid, uuid) FROM public; -REVOKE ALL ON FUNCTION public.user_has_role_in_app(uuid, uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.user_has_role_in_app( - uuid, uuid -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.user_has_role_in_app( - uuid, uuid -) TO service_role; - --- ============================================================================= --- 6. FIX user_has_app_update_user_roles: Use (SELECT auth.uid()) pattern --- ============================================================================= - -CREATE OR REPLACE FUNCTION public.user_has_app_update_user_roles( - p_user_id uuid, p_app_id uuid -) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -STABLE -SET search_path = '' -AS $$ -DECLARE - v_app_id_varchar text; - v_org_id uuid; - v_caller_id uuid; -BEGIN - -- Use SELECT to evaluate auth.uid() once - SELECT auth.uid() INTO v_caller_id; - - IF v_caller_id IS NULL THEN - RETURN false; - END IF; - - -- Fetch app_id varchar and org_id from apps table - SELECT app_id, owner_org INTO v_app_id_varchar, v_org_id - FROM public.apps - WHERE id = p_app_id - LIMIT 1; - - IF v_app_id_varchar IS NULL OR v_org_id IS NULL THEN - RETURN false; - END IF; - - IF v_caller_id <> p_user_id THEN - IF NOT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_caller_id - AND (rb.org_id = v_org_id OR rb.app_id = p_app_id) - ) THEN - RETURN false; - END IF; - END IF; - - -- Use rbac_has_permission to check the permission - RETURN public.rbac_has_permission( - public.rbac_principal_user(), - p_user_id, - public.rbac_perm_app_update_user_roles(), - v_org_id, - v_app_id_varchar, - NULL - ); -END; -$$; - -COMMENT ON FUNCTION public.user_has_app_update_user_roles(uuid, uuid) IS -'Checks whether a user has app.update_user_roles permission (bypasses RLS to avoid recursion). Optimized with SELECT auth.uid() pattern.'; - --- Restrict user_has_app_update_user_roles -REVOKE ALL ON FUNCTION public.user_has_app_update_user_roles( - uuid, uuid -) FROM public; -REVOKE ALL ON FUNCTION public.user_has_app_update_user_roles( - uuid, uuid -) FROM anon; -GRANT EXECUTE ON FUNCTION public.user_has_app_update_user_roles( - uuid, uuid -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.user_has_app_update_user_roles( - uuid, uuid -) TO service_role; - --- ============================================================================= --- 7. RESTRICT ADMIN-ONLY RBAC FUNCTIONS: Prevent access from anon/public --- ============================================================================= --- These functions are used for RBAC migration and administration. --- They should ONLY be callable by service_role. By default, functions --- are public, so we explicitly restrict their execution to service_role here. - --- Restrict rbac_migrate_org_users_to_bindings - admin migration function -REVOKE ALL ON FUNCTION public.rbac_migrate_org_users_to_bindings( - uuid, uuid -) FROM public; -REVOKE ALL ON FUNCTION public.rbac_migrate_org_users_to_bindings( - uuid, uuid -) FROM anon; -GRANT EXECUTE ON FUNCTION public.rbac_migrate_org_users_to_bindings( - uuid, uuid -) TO service_role; - --- Restrict rbac_enable_for_org - admin migration function -REVOKE ALL ON FUNCTION public.rbac_enable_for_org(uuid, uuid) FROM public; -REVOKE ALL ON FUNCTION public.rbac_enable_for_org(uuid, uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.rbac_enable_for_org( - uuid, uuid -) TO service_role; - --- Restrict rbac_preview_migration - admin preview function -REVOKE ALL ON FUNCTION public.rbac_preview_migration(uuid) FROM public; -REVOKE ALL ON FUNCTION public.rbac_preview_migration(uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.rbac_preview_migration(uuid) TO service_role; - --- Restrict rbac_rollback_org - admin rollback function -REVOKE ALL ON FUNCTION public.rbac_rollback_org(uuid) FROM public; -REVOKE ALL ON FUNCTION public.rbac_rollback_org(uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.rbac_rollback_org(uuid) TO service_role; - --- Restrict rbac_has_permission - should only be used by authenticated users --- and service_role (not anon/apikey access without auth) -REVOKE ALL ON FUNCTION public.rbac_has_permission( - text, uuid, text, uuid, character varying, bigint -) FROM public; -REVOKE ALL ON FUNCTION public.rbac_has_permission( - text, uuid, text, uuid, character varying, bigint -) FROM anon; -GRANT EXECUTE ON FUNCTION public.rbac_has_permission( - text, uuid, text, uuid, character varying, bigint -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_has_permission( - text, uuid, text, uuid, character varying, bigint -) TO service_role; - --- Restrict rbac_is_enabled_for_org - helper function -REVOKE ALL ON FUNCTION public.rbac_is_enabled_for_org(uuid) FROM public; -REVOKE ALL ON FUNCTION public.rbac_is_enabled_for_org(uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.rbac_is_enabled_for_org(uuid) TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_is_enabled_for_org(uuid) TO service_role; - --- Restrict rbac_permission_for_legacy - internal helper -REVOKE ALL ON FUNCTION public.rbac_permission_for_legacy( - public.user_min_right, text -) FROM public; -REVOKE ALL ON FUNCTION public.rbac_permission_for_legacy( - public.user_min_right, text -) FROM anon; -GRANT EXECUTE ON FUNCTION public.rbac_permission_for_legacy( - public.user_min_right, text -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_permission_for_legacy( - public.user_min_right, text -) TO service_role; - --- Restrict rbac_legacy_role_hint - internal helper -REVOKE ALL ON FUNCTION public.rbac_legacy_role_hint( - public.user_min_right, character varying, bigint -) FROM public; -REVOKE ALL ON FUNCTION public.rbac_legacy_role_hint( - public.user_min_right, character varying, bigint -) FROM anon; -GRANT EXECUTE ON FUNCTION public.rbac_legacy_role_hint( - public.user_min_right, character varying, bigint -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_legacy_role_hint( - public.user_min_right, character varying, bigint -) TO service_role; diff --git a/supabase/migrations/20260124231940_fix_multiple_permissive_policies.sql b/supabase/migrations/20260124231940_fix_multiple_permissive_policies.sql deleted file mode 100644 index fd48849369..0000000000 --- a/supabase/migrations/20260124231940_fix_multiple_permissive_policies.sql +++ /dev/null @@ -1,722 +0,0 @@ --- ============================================================================= --- Fix Multiple Permissive Policies --- ============================================================================= --- This migration fixes the Supabase linter warning about multiple permissive --- policies for the same role and action on several tables. --- --- The issue: Using FOR ALL creates policies that cover SELECT, INSERT, UPDATE, --- DELETE. When combined with a separate FOR SELECT policy, this creates --- duplicate SELECT policies which is suboptimal for performance. --- --- The fix: Replace FOR ALL with separate FOR INSERT, FOR UPDATE, FOR DELETE --- policies, keeping only one FOR SELECT policy per table that combines all --- read conditions. --- --- Performance: All policies call auth.uid() only once using a subquery pattern --- as per AGENTS.md guidelines. UPDATE policies omit WITH CHECK when identical --- to USING (defaults to USING). --- ============================================================================= - --- ============================================================================= --- 1. FIX rbac_settings: Combine SELECT, split write policies --- ============================================================================= - --- Drop existing policies -DROP POLICY IF EXISTS rbac_settings_read_authenticated ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_admin_all ON public.rbac_settings; - --- Single SELECT policy (admins and authenticated users can read) -CREATE POLICY rbac_settings_select ON public.rbac_settings -FOR SELECT -TO authenticated -USING (true); - --- Separate write policies for admin only (single auth.uid() call each) -CREATE POLICY rbac_settings_insert ON public.rbac_settings -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY rbac_settings_update ON public.rbac_settings -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY rbac_settings_delete ON public.rbac_settings -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - --- ============================================================================= --- 2. FIX roles: Combine SELECT, split write policies --- ============================================================================= - --- Drop existing policies -DROP POLICY IF EXISTS roles_read_all ON public.roles; -DROP POLICY IF EXISTS roles_admin_write ON public.roles; - --- Single SELECT policy -CREATE POLICY roles_select ON public.roles -FOR SELECT -TO authenticated -USING (true); - --- Separate write policies for admin only -CREATE POLICY roles_insert ON public.roles -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY roles_update ON public.roles -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY roles_delete ON public.roles -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - --- ============================================================================= --- 3. FIX permissions: Combine SELECT, split write policies --- ============================================================================= - --- Drop existing policies -DROP POLICY IF EXISTS permissions_read_all ON public.permissions; -DROP POLICY IF EXISTS permissions_admin_write ON public.permissions; - --- Single SELECT policy -CREATE POLICY permissions_select ON public.permissions -FOR SELECT -TO authenticated -USING (true); - --- Separate write policies for admin only -CREATE POLICY permissions_insert ON public.permissions -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY permissions_update ON public.permissions -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY permissions_delete ON public.permissions -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - --- ============================================================================= --- 4. FIX role_permissions: Combine SELECT, split write policies --- ============================================================================= - --- Drop existing policies -DROP POLICY IF EXISTS role_permissions_read_all ON public.role_permissions; -DROP POLICY IF EXISTS role_permissions_admin_write ON public.role_permissions; - --- Single SELECT policy -CREATE POLICY role_permissions_select ON public.role_permissions -FOR SELECT -TO authenticated -USING (true); - --- Separate write policies for admin only -CREATE POLICY role_permissions_insert ON public.role_permissions -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY role_permissions_update ON public.role_permissions -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY role_permissions_delete ON public.role_permissions -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - --- ============================================================================= --- 5. FIX role_hierarchy: Combine SELECT, split write policies --- ============================================================================= - --- Drop existing policies -DROP POLICY IF EXISTS role_hierarchy_read_all ON public.role_hierarchy; -DROP POLICY IF EXISTS role_hierarchy_admin_write ON public.role_hierarchy; - --- Single SELECT policy -CREATE POLICY role_hierarchy_select ON public.role_hierarchy -FOR SELECT -TO authenticated -USING (true); - --- Separate write policies for admin only -CREATE POLICY role_hierarchy_insert ON public.role_hierarchy -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY role_hierarchy_update ON public.role_hierarchy -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY role_hierarchy_delete ON public.role_hierarchy -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE public.is_admin(auth_user.uid) - ) -); - --- ============================================================================= --- 6. FIX groups: Combine SELECT, split write policies --- ============================================================================= - --- Drop existing policies -DROP POLICY IF EXISTS groups_read_org_member ON public.groups; -DROP POLICY IF EXISTS groups_write_org_admin ON public.groups; - --- Single SELECT policy (org members OR admins can read) - single auth.uid() call -CREATE POLICY groups_select ON public.groups -FOR SELECT -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE EXISTS ( - SELECT 1 FROM public.org_users - WHERE - org_users.org_id = groups.org_id - AND org_users.user_id = auth_user.uid - ) - OR public.is_admin(auth_user.uid) - ) -); - --- Separate write policies for org admin - single auth.uid() call each -CREATE POLICY groups_insert ON public.groups -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - groups.org_id, - null::varchar, - null::bigint - ) - OR public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY groups_update ON public.groups -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - groups.org_id, - null::varchar, - null::bigint - ) - OR public.is_admin(auth_user.uid) - ) -); - -CREATE POLICY groups_delete ON public.groups -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - groups.org_id, - null::varchar, - null::bigint - ) - OR public.is_admin(auth_user.uid) - ) -); - --- ============================================================================= --- 7. FIX group_members: Combine SELECT, split write policies --- ============================================================================= - --- Drop existing policies -DROP POLICY IF EXISTS group_members_read_org_member ON public.group_members; -DROP POLICY IF EXISTS group_members_write_org_admin ON public.group_members; - --- Single SELECT policy (org members OR admins can read) - single auth.uid() call -CREATE POLICY group_members_select ON public.group_members -FOR SELECT -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE EXISTS ( - SELECT 1 FROM public.groups - INNER JOIN public.org_users ON groups.org_id = org_users.org_id - WHERE - groups.id = group_members.group_id - AND org_users.user_id = auth_user.uid - ) - OR public.is_admin(auth_user.uid) - ) -); - --- Separate write policies for org admin - single auth.uid() call each -CREATE POLICY group_members_insert ON public.group_members -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE EXISTS ( - SELECT 1 FROM public.groups - WHERE - groups.id = group_members.group_id - AND ( - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - groups.org_id, - null::varchar, - null::bigint - ) - OR public.is_admin(auth_user.uid) - ) - ) - ) -); - -CREATE POLICY group_members_update ON public.group_members -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE EXISTS ( - SELECT 1 FROM public.groups - WHERE - groups.id = group_members.group_id - AND ( - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - groups.org_id, - null::varchar, - null::bigint - ) - OR public.is_admin(auth_user.uid) - ) - ) - ) -); - -CREATE POLICY group_members_delete ON public.group_members -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE EXISTS ( - SELECT 1 FROM public.groups - WHERE - groups.id = group_members.group_id - AND ( - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - groups.org_id, - null::varchar, - null::bigint - ) - OR public.is_admin(auth_user.uid) - ) - ) - ) -); - --- ============================================================================= --- 8. FIX role_bindings: Consolidate SELECT and DELETE policies --- ============================================================================= - --- Drop existing policies -DROP POLICY IF EXISTS "Allow viewing role bindings with permission" ON public.role_bindings; -DROP POLICY IF EXISTS role_bindings_write_scope_admin ON public.role_bindings; -DROP POLICY IF EXISTS "Allow admins to delete manageable role bindings" ON public.role_bindings; - --- Single SELECT policy combining all read conditions - single auth.uid() call -CREATE POLICY role_bindings_select ON public.role_bindings -FOR SELECT -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - -- Platform admin sees all - public.is_admin(auth_user.uid) - OR - -- Org admins can see all bindings in their org - public.is_user_org_admin(auth_user.uid, role_bindings.org_id) - OR - -- App admins can see app-scoped bindings - ( - role_bindings.scope_type = public.rbac_scope_app() - AND public.is_user_app_admin( - auth_user.uid, role_bindings.app_id - ) - ) - OR - -- Users with a role in the app can see app-scoped bindings - ( - role_bindings.scope_type = public.rbac_scope_app() - AND role_bindings.app_id IS NOT null - AND public.user_has_role_in_app( - auth_user.uid, role_bindings.app_id - ) - ) - OR - -- Channel-scope bindings: visible to app admins of the parent app - ( - role_bindings.scope_type = public.rbac_scope_channel() - AND role_bindings.channel_id IS NOT null - AND EXISTS ( - SELECT 1 FROM public.channels AS c - INNER JOIN public.apps AS a ON c.app_id = a.app_id - WHERE - c.rbac_id = role_bindings.channel_id - AND public.is_user_app_admin(auth_user.uid, a.id) - ) - ) - ) -); - --- INSERT policy - single auth.uid() call -CREATE POLICY role_bindings_insert ON public.role_bindings -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - public.is_admin(auth_user.uid) - OR - ( - role_bindings.scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - role_bindings.org_id, - null::varchar, - null::bigint - ) - ) - OR - (role_bindings.scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - apps.owner_org, - apps.app_id, - null::bigint - ) - )) - OR - (role_bindings.scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - apps.owner_org, - channels.app_id, - channels.id - ) - )) - ) -); - --- UPDATE policy - single auth.uid() call -CREATE POLICY role_bindings_update ON public.role_bindings -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - public.is_admin(auth_user.uid) - OR - ( - role_bindings.scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - role_bindings.org_id, - null::varchar, - null::bigint - ) - ) - OR - (role_bindings.scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - apps.owner_org, - apps.app_id, - null::bigint - ) - )) - OR - (role_bindings.scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - apps.owner_org, - channels.app_id, - channels.id - ) - )) - ) -); - --- Single DELETE policy combining all delete conditions - single auth.uid() call -CREATE POLICY role_bindings_delete ON public.role_bindings -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - -- Platform admin - public.is_admin(auth_user.uid) - OR - -- Org admin for org-scoped bindings - ( - role_bindings.scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - role_bindings.org_id, - null::varchar, - null::bigint - ) - ) - OR - -- App admin for app-scoped bindings - (role_bindings.scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - apps.owner_org, - apps.app_id, - null::bigint - ) - )) - OR - -- Channel admin for channel-scoped bindings - (role_bindings.scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - apps.owner_org, - channels.app_id, - channels.id - ) - )) - OR - -- Users with app.update_user_roles permission can delete app-scoped bindings - ( - role_bindings.scope_type = public.rbac_scope_app() - AND public.user_has_app_update_user_roles( - auth_user.uid, role_bindings.app_id - ) - ) - OR - -- Users can delete their own app-scoped bindings - ( - role_bindings.scope_type = public.rbac_scope_app() - AND role_bindings.principal_type = public.rbac_principal_user() - AND role_bindings.principal_id = auth_user.uid - ) - ) -); - --- ============================================================================= --- Add comments for documentation --- ============================================================================= - -COMMENT ON POLICY rbac_settings_select ON public.rbac_settings IS -'All authenticated users can read RBAC settings. Single SELECT policy to avoid multiple permissive policies.'; -COMMENT ON POLICY rbac_settings_insert ON public.rbac_settings IS -'Only platform admins can insert RBAC settings.'; -COMMENT ON POLICY rbac_settings_update ON public.rbac_settings IS -'Only platform admins can update RBAC settings.'; -COMMENT ON POLICY rbac_settings_delete ON public.rbac_settings IS -'Only platform admins can delete RBAC settings.'; - -COMMENT ON POLICY roles_select ON public.roles IS -'All authenticated users can read roles. Single SELECT policy to avoid multiple permissive policies.'; -COMMENT ON POLICY roles_insert ON public.roles IS -'Only platform admins can insert roles.'; -COMMENT ON POLICY roles_update ON public.roles IS -'Only platform admins can update roles.'; -COMMENT ON POLICY roles_delete ON public.roles IS -'Only platform admins can delete roles.'; - -COMMENT ON POLICY permissions_select ON public.permissions IS -'All authenticated users can read permissions. Single SELECT policy to avoid multiple permissive policies.'; -COMMENT ON POLICY permissions_insert ON public.permissions IS -'Only platform admins can insert permissions.'; -COMMENT ON POLICY permissions_update ON public.permissions IS -'Only platform admins can update permissions.'; -COMMENT ON POLICY permissions_delete ON public.permissions IS -'Only platform admins can delete permissions.'; - -COMMENT ON POLICY role_permissions_select ON public.role_permissions IS -'All authenticated users can read role_permissions. Single SELECT policy to avoid multiple permissive policies.'; -COMMENT ON POLICY role_permissions_insert ON public.role_permissions IS -'Only platform admins can insert role_permissions.'; -COMMENT ON POLICY role_permissions_update ON public.role_permissions IS -'Only platform admins can update role_permissions.'; -COMMENT ON POLICY role_permissions_delete ON public.role_permissions IS -'Only platform admins can delete role_permissions.'; - -COMMENT ON POLICY role_hierarchy_select ON public.role_hierarchy IS -'All authenticated users can read role_hierarchy. Single SELECT policy to avoid multiple permissive policies.'; -COMMENT ON POLICY role_hierarchy_insert ON public.role_hierarchy IS -'Only platform admins can insert role_hierarchy.'; -COMMENT ON POLICY role_hierarchy_update ON public.role_hierarchy IS -'Only platform admins can update role_hierarchy.'; -COMMENT ON POLICY role_hierarchy_delete ON public.role_hierarchy IS -'Only platform admins can delete role_hierarchy.'; - -COMMENT ON POLICY groups_select ON public.groups IS -'Org members and platform admins can read groups. Single SELECT policy with single auth.uid() call.'; -COMMENT ON POLICY groups_insert ON public.groups IS -'Org admins and platform admins can insert groups.'; -COMMENT ON POLICY groups_update ON public.groups IS -'Org admins and platform admins can update groups.'; -COMMENT ON POLICY groups_delete ON public.groups IS -'Org admins and platform admins can delete groups.'; - -COMMENT ON POLICY group_members_select ON public.group_members IS -'Org members and platform admins can read group_members. Single SELECT policy with single auth.uid() call.'; -COMMENT ON POLICY group_members_insert ON public.group_members IS -'Org admins and platform admins can insert group_members.'; -COMMENT ON POLICY group_members_update ON public.group_members IS -'Org admins and platform admins can update group_members.'; -COMMENT ON POLICY group_members_delete ON public.group_members IS -'Org admins and platform admins can delete group_members.'; - -COMMENT ON POLICY role_bindings_select ON public.role_bindings IS -'Consolidated SELECT policy for role_bindings. Visible to platform admins, org admins, app admins, and users with roles. Single auth.uid() call for performance.'; -COMMENT ON POLICY role_bindings_insert ON public.role_bindings IS -'Scope admins can insert role_bindings within their scope.'; -COMMENT ON POLICY role_bindings_update ON public.role_bindings IS -'Scope admins can update role_bindings within their scope.'; -COMMENT ON POLICY role_bindings_delete ON public.role_bindings IS -'Consolidated DELETE policy for role_bindings. Scope admins, users with update_user_roles permission, and users deleting their own bindings. Single auth.uid() call for performance.'; diff --git a/supabase/migrations/20260125151000_mau_first_seen_device_usage.sql b/supabase/migrations/20260125151000_mau_first_seen_device_usage.sql deleted file mode 100644 index 1632f73aac..0000000000 --- a/supabase/migrations/20260125151000_mau_first_seen_device_usage.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Update read_device_usage to count unique devices once per period (first seen in range) --- This aligns MAU with "unique over period" semantics rather than per-day DAU. -CREATE OR REPLACE FUNCTION "public"."read_device_usage" ( - "p_app_id" pg_catalog.varchar, - "p_period_start" pg_catalog.timestamp, - "p_period_end" pg_catalog.timestamp -) RETURNS TABLE ( - "date" pg_catalog.date, - "mau" pg_catalog.int8, - "app_id" pg_catalog.varchar -) LANGUAGE "plpgsql" -SET - search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - first_seen.date AS date, - COUNT(*)::bigint AS mau, - p_app_id AS app_id - FROM ( - SELECT - MIN(DATE_TRUNC('day', device_usage.timestamp)::date) AS date, - device_usage.device_id - FROM public.device_usage - WHERE - device_usage.app_id = p_app_id - AND device_usage.timestamp >= p_period_start - AND device_usage.timestamp < p_period_end - GROUP BY device_usage.device_id - ) AS first_seen - GROUP BY first_seen.date - ORDER BY first_seen.date; -END; -$$; diff --git a/supabase/migrations/20260127120000_enforce_2fa_in_permission_checks.sql b/supabase/migrations/20260127120000_enforce_2fa_in_permission_checks.sql deleted file mode 100644 index 9641d3a987..0000000000 --- a/supabase/migrations/20260127120000_enforce_2fa_in_permission_checks.sql +++ /dev/null @@ -1,308 +0,0 @@ --- Enforce org 2FA requirements across permission checks (RBAC + legacy) - -CREATE OR REPLACE FUNCTION public.check_min_rights( - min_right public.user_min_right, - user_id uuid, - org_id uuid, - app_id character varying, - channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - v_allowed boolean := false; - v_perm text; - v_scope text; - v_apikey text; - v_apikey_principal uuid; - v_use_rbac boolean; - v_effective_org_id uuid := org_id; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; -BEGIN - -- Derive org from app/channel when not provided to honor org-level flag and scoping. - IF v_effective_org_id IS NULL AND app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id FROM public.apps WHERE public.apps.app_id = check_min_rights.app_id LIMIT 1; - END IF; - IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id FROM public.channels WHERE public.channels.id = channel_id LIMIT 1; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id; - IF v_org_enforcing_2fa = true AND (user_id IS NULL OR NOT public.has_2fa_enabled(user_id)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - IF NOT v_use_rbac THEN - RETURN public.check_min_rights_legacy(min_right, user_id, COALESCE(org_id, v_effective_org_id), app_id, channel_id); - END IF; - - IF channel_id IS NOT NULL THEN - v_scope := public.rbac_scope_channel(); - ELSIF app_id IS NOT NULL THEN - v_scope := public.rbac_scope_app(); - ELSE - v_scope := public.rbac_scope_org(); - END IF; - - v_perm := public.rbac_permission_for_legacy(min_right, v_scope); - - IF user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), user_id, v_perm, v_effective_org_id, app_id, channel_id); - END IF; - - -- Also consider apikey principal when RBAC is enabled (API keys can hold roles directly). - IF NOT v_allowed THEN - SELECT public.get_apikey_header() INTO v_apikey; - IF v_apikey IS NOT NULL THEN - SELECT rbac_id INTO v_apikey_principal FROM public.apikeys WHERE key = v_apikey LIMIT 1; - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, v_perm, v_effective_org_id, app_id, channel_id); - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_RBAC', jsonb_build_object('org_id', COALESCE(org_id, v_effective_org_id), 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id, 'scope', v_scope, 'perm', v_perm)); - END IF; - - RETURN v_allowed; -END; -$$; - -CREATE OR REPLACE FUNCTION public.check_min_rights_legacy( - min_right public.user_min_right, - user_id uuid, - org_id uuid, - app_id character varying, - channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - user_right_record RECORD; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; -BEGIN - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_NO_UID', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text)); - RETURN false; - END IF; - - -- Enforce 2FA if the org requires it. - IF org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa FROM public.orgs WHERE id = org_id; - IF v_org_enforcing_2fa = true AND NOT public.has_2fa_enabled(user_id) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( - 'org_id', org_id, - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(user_id, org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'org_id', org_id, - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - FOR user_right_record IN - SELECT org_users.user_right, org_users.app_id, org_users.channel_id - FROM public.org_users - WHERE org_users.org_id = check_min_rights_legacy.org_id AND org_users.user_id = check_min_rights_legacy.user_id - LOOP - IF (user_right_record.user_right >= min_right AND user_right_record.app_id IS NULL AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy.app_id AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy.app_id AND user_right_record.channel_id = check_min_rights_legacy.channel_id) - THEN - RETURN true; - END IF; - END LOOP; - - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); - RETURN false; -END; -$$; - -CREATE OR REPLACE FUNCTION public.rbac_check_permission_direct( - p_permission_key text, - p_user_id uuid, - p_org_id uuid, - p_app_id character varying, - p_channel_id bigint, - p_apikey text DEFAULT NULL -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; - v_org_enforcing_2fa boolean; - v_effective_user_id uuid := p_user_id; - v_password_policy_ok boolean; -BEGIN - -- Validate permission key - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - -- Derive org from app/channel when not provided - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF v_effective_org_id IS NULL AND p_channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - END IF; - - -- Resolve user from API key when needed (handles hashed keys too). - IF v_effective_user_id IS NULL AND p_apikey IS NOT NULL THEN - SELECT user_id INTO v_effective_user_id - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(v_effective_user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Check if RBAC is enabled for this org - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - -- RBAC path: Check user permission directly - IF v_effective_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), v_effective_user_id, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - END IF; - - -- If user doesn't have permission, check apikey permission - IF NOT v_allowed AND p_apikey IS NOT NULL THEN - SELECT rbac_id INTO v_apikey_principal - FROM public.apikeys - WHERE key = p_apikey - LIMIT 1; - - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - ELSE - -- Legacy path: Map permission to min_right and use legacy check - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - -- Unknown permission in legacy mode, deny by default - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id - )); - RETURN false; - END IF; - - -- Use appropriate legacy check based on context - IF p_apikey IS NOT NULL AND p_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, v_effective_user_id, p_apikey); - ELSIF p_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(p_app_id, v_legacy_right, v_effective_user_id); - ELSE - RETURN public.check_min_rights_legacy(v_legacy_right, v_effective_user_id, v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; -END; -$$; diff --git a/supabase/migrations/20260127121000_allow_credits_without_plan.sql b/supabase/migrations/20260127121000_allow_credits_without_plan.sql deleted file mode 100644 index f843ae4bfc..0000000000 --- a/supabase/migrations/20260127121000_allow_credits_without_plan.sql +++ /dev/null @@ -1,297 +0,0 @@ --- Allow usage when credits are available without an active subscription - -CREATE OR REPLACE FUNCTION "public"."get_orgs_v7"() RETURNS TABLE("gid" "uuid", "created_by" "uuid", "logo" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "stats_updated_at" timestamp without time zone, "next_stats_update_at" timestamp with time zone, "credit_available" numeric, "credit_total" numeric, "credit_next_expiration" timestamp with time zone, "enforcing_2fa" boolean, "2fa_has_access" boolean, "enforce_hashed_api_keys" boolean, "password_policy_config" "jsonb", "password_has_access" boolean, "require_apikey_expiration" boolean, "max_apikey_expiration_days" integer, "enforce_encrypted_bundles" boolean, "required_encryption_key" character varying, "use_new_rbac" boolean) - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v7"() OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_orgs_v7"("userid" "uuid") RETURNS TABLE("gid" "uuid", "created_by" "uuid", "logo" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "stats_updated_at" timestamp without time zone, "next_stats_update_at" timestamp with time zone, "credit_available" numeric, "credit_total" numeric, "credit_next_expiration" timestamp with time zone, "enforcing_2fa" boolean, "2fa_has_access" boolean, "enforce_hashed_api_keys" boolean, "password_policy_config" "jsonb", "password_has_access" boolean, "require_apikey_expiration" boolean, "max_apikey_expiration_days" integer, "enforce_encrypted_bundles" boolean, "required_encryption_key" character varying, "use_new_rbac" boolean) - LANGUAGE "plpgsql" STABLE SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - rbac_roles AS ( - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION ALL - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - rbac_org_roles AS ( - SELECT org_id, (ARRAY_AGG(rbac_roles.name ORDER BY rbac_roles.priority_rank DESC))[1] AS role_name - FROM rbac_roles - GROUP BY org_id - ), - user_orgs AS ( - SELECT ou.org_id - FROM public.org_users ou - WHERE ou.user_id = userid - UNION - SELECT rbac_org_roles.org_id - FROM rbac_org_roles - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - -- Calculate current billing cycle for each org - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ), - -- Calculate password policy access status for user/org combinations - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - public.user_meets_password_policy(userid, o.id) AS password_has_access, - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ) - SELECT - o.id AS gid, - o.created_by, - o.logo, - o.name, - CASE - WHEN o.use_new_rbac AND ou.user_right::text LIKE 'invite_%' THEN ou.user_right::varchar - WHEN o.use_new_rbac THEN COALESCE(ror.role_name, ou.rbac_role_name, ou.user_right::varchar) - ELSE COALESCE(ou.user_right::varchar, ror.role_name) - END AS role, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'succeeded') - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) - OR (si.trial_at::date - NOW()::date > 0) - OR COALESCE(ucb.available_credits, 0) > 0) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'canceled') - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.available_credits, 0) - END AS credit_available, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.total_credits, 0) - END AS credit_total, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE ucb.next_expiration - END AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key, - o.use_new_rbac - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - LEFT JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id - LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v7"("userid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - RETURN ( - SELECT - EXISTS ( - SELECT 1 - FROM public.usage_credit_balances ucb - WHERE ucb.org_id = orgid - AND COALESCE(ucb.available_credits, 0) > 0 - ) - OR EXISTS ( - SELECT 1 - FROM public.stripe_info - WHERE customer_id=(SELECT customer_id FROM public.orgs WHERE id=orgid) - AND ( - (status = 'succeeded' AND is_good_plan = true) - OR (trial_at::date - (now())::date > 0) - ) - ) - ); -END; -$$; - -ALTER FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE org_customer_id text; result boolean; has_credits boolean; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM public.usage_credit_balances ucb - WHERE ucb.org_id = orgid - AND COALESCE(ucb.available_credits, 0) > 0 - ) INTO has_credits; - - IF has_credits THEN - RETURN true; - END IF; - - SELECT o.customer_id INTO org_customer_id FROM public.orgs o WHERE o.id = orgid; - SELECT (si.trial_at > now()) OR (si.status = 'succeeded' AND NOT ( - (si.mau_exceeded AND 'mau' = ANY(actions)) OR (si.storage_exceeded AND 'storage' = ANY(actions)) OR - (si.bandwidth_exceeded AND 'bandwidth' = ANY(actions)) OR (si.build_time_exceeded AND 'build_time' = ANY(actions)))) - INTO result FROM public.stripe_info si WHERE si.customer_id = org_customer_id LIMIT 1; - RETURN COALESCE(result, false); -END; -$$; - -ALTER FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) OWNER TO "postgres"; diff --git a/supabase/migrations/20260127153000_require_recent_reauth_for_delete_user.sql b/supabase/migrations/20260127153000_require_recent_reauth_for_delete_user.sql deleted file mode 100644 index 81d816205e..0000000000 --- a/supabase/migrations/20260127153000_require_recent_reauth_for_delete_user.sql +++ /dev/null @@ -1,71 +0,0 @@ --- Require a recent password reauthentication before allowing account deletion -CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - user_id_fn uuid; - user_email text; - old_record_json jsonb; - last_sign_in_at_ts timestamptz; -BEGIN - -- Get the current user ID and email - SELECT "auth"."uid"() INTO user_id_fn; - IF user_id_fn IS NULL THEN - RAISE EXCEPTION 'not_authenticated' USING ERRCODE = '42501'; - END IF; - - SELECT "email", "last_sign_in_at" INTO user_email, last_sign_in_at_ts - FROM "auth"."users" - WHERE "id" = user_id_fn; - - -- Require a fresh reauthentication (password confirmation) - IF last_sign_in_at_ts IS NULL OR last_sign_in_at_ts < NOW() - INTERVAL '5 minutes' THEN - RAISE EXCEPTION 'reauth_required' USING ERRCODE = 'P0001'; - END IF; - - -- Fetch the old_record using the specified query format - SELECT row_to_json(u)::jsonb INTO old_record_json - FROM ( - SELECT * - FROM "public"."users" - WHERE id = user_id_fn - ) AS u; - - IF old_record_json IS NULL THEN - RAISE EXCEPTION 'user_not_found' USING ERRCODE = 'P0002'; - END IF; - - -- Trigger the queue-based deletion process - -- This cancels the subscriptions of the user's organizations - PERFORM "pgmq"."send"( - 'on_user_delete'::text, - "jsonb_build_object"( - 'payload', "jsonb_build_object"( - 'old_record', old_record_json, - 'table', 'users', - 'type', 'DELETE' - ), - 'function_name', 'on_user_delete' - ) - ); - - -- Mark the user for deletion - INSERT INTO "public"."to_delete_accounts" ( - "account_id", - "removal_date", - "removed_data" - ) VALUES - ( - user_id_fn, - NOW() + INTERVAL '30 days', - "jsonb_build_object"('email', user_email, 'apikeys', COALESCE((SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn), '[]'::jsonb)) - ); - - -- Delete the API keys - DELETE FROM "public"."apikeys" WHERE "public"."apikeys"."user_id" = user_id_fn; -END; -$$; - -GRANT EXECUTE ON FUNCTION "public"."delete_user"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."delete_user"() TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."delete_user"() TO "service_role"; diff --git a/supabase/migrations/20260127232000_sanitize_text_fields.sql b/supabase/migrations/20260127232000_sanitize_text_fields.sql deleted file mode 100644 index 7864404159..0000000000 --- a/supabase/migrations/20260127232000_sanitize_text_fields.sql +++ /dev/null @@ -1,106 +0,0 @@ --- Enforce HTML tag stripping at the database layer for org/app/user fields. - -CREATE OR REPLACE FUNCTION public.strip_html(input text) -RETURNS text -LANGUAGE sql -IMMUTABLE -SECURITY DEFINER -SET search_path = '' -AS $$ - SELECT CASE - WHEN input IS NULL THEN NULL - ELSE btrim(regexp_replace(input, '<[^>]*>', '', 'g')) - END; -$$; - -CREATE OR REPLACE FUNCTION public.sanitize_orgs_text_fields() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - NEW."name" := public.strip_html(NEW."name"); - NEW."management_email" := public.strip_html(NEW."management_email"); - NEW."logo" := public.strip_html(NEW."logo"); - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.sanitize_apps_text_fields() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - NEW."name" := public.strip_html(NEW."name"); - NEW."icon_url" := public.strip_html(NEW."icon_url"); - IF (TG_OP = 'UPDATE') THEN - NEW."updated_at" := now(); - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.sanitize_users_text_fields() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - NEW."email" := public.strip_html(NEW."email"); - NEW."first_name" := public.strip_html(NEW."first_name"); - NEW."last_name" := public.strip_html(NEW."last_name"); - NEW."country" := public.strip_html(NEW."country"); - IF (TG_OP = 'UPDATE') THEN - NEW."updated_at" := now(); - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.sanitize_tmp_users_text_fields() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - NEW."email" := public.strip_html(NEW."email"); - NEW."first_name" := public.strip_html(NEW."first_name"); - NEW."last_name" := public.strip_html(NEW."last_name"); - IF (TG_OP = 'UPDATE') THEN - NEW."updated_at" := now(); - END IF; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS sanitize_orgs_text_fields ON public.orgs; -CREATE TRIGGER sanitize_orgs_text_fields -BEFORE INSERT OR UPDATE ON public.orgs -FOR EACH ROW -EXECUTE FUNCTION public.sanitize_orgs_text_fields(); - -DROP TRIGGER IF EXISTS sanitize_apps_text_fields ON public.apps; -DROP TRIGGER IF EXISTS handle_updated_at ON public.apps; -CREATE TRIGGER handle_updated_at -BEFORE INSERT OR UPDATE ON public.apps -FOR EACH ROW -EXECUTE FUNCTION public.sanitize_apps_text_fields(); - -DROP TRIGGER IF EXISTS sanitize_users_text_fields ON public.users; -DROP TRIGGER IF EXISTS handle_updated_at ON public.users; -CREATE TRIGGER handle_updated_at -BEFORE INSERT OR UPDATE ON public.users -FOR EACH ROW -EXECUTE FUNCTION public.sanitize_users_text_fields(); - -DROP TRIGGER IF EXISTS sanitize_tmp_users_text_fields ON public.tmp_users; -DROP TRIGGER IF EXISTS handle_updated_at ON public.tmp_users; -CREATE TRIGGER handle_updated_at -BEFORE INSERT OR UPDATE ON public.tmp_users -FOR EACH ROW -EXECUTE FUNCTION public.sanitize_tmp_users_text_fields(); diff --git a/supabase/migrations/20260129120000_fix_reject_access_due_to_2fa_for_app.sql b/supabase/migrations/20260129120000_fix_reject_access_due_to_2fa_for_app.sql deleted file mode 100644 index 5cdb1abf23..0000000000 --- a/supabase/migrations/20260129120000_fix_reject_access_due_to_2fa_for_app.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ========================================================================== --- Fix reject_access_due_to_2fa_for_app to avoid false 2FA rejections --- ========================================================================== --- Behavior changes: --- 1) Non-existent apps no longer return "reject" (align with org function). --- 2) Use get_identity_org_appid to respect app/org scoped API keys. --- ========================================================================== - -CREATE OR REPLACE FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) - RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_owner_org uuid; - v_user_id uuid; - v_org_enforcing_2fa boolean; -BEGIN - -- Get the owner organization for this app - SELECT owner_org INTO v_owner_org - FROM public.apps - WHERE public.apps.app_id = reject_access_due_to_2fa_for_app.app_id; - - -- If app not found or no owner_org, allow (no 2FA enforcement can apply) - IF v_owner_org IS NULL THEN - RETURN false; - END IF; - - -- Get the current user identity (works for both JWT auth and API key) - -- Use get_identity_org_appid to ensure org/app scoping is respected - v_user_id := public.get_identity_org_appid('{read,upload,write,all}'::public.key_mode[], v_owner_org, reject_access_due_to_2fa_for_app.app_id); - - -- If no user identity found, allow (auth failure should be handled elsewhere) - IF v_user_id IS NULL THEN - RETURN false; - END IF; - - -- Check if org has 2FA enforcement enabled - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE public.orgs.id = v_owner_org; - - -- If org not found, allow (no 2FA enforcement can apply) - IF v_org_enforcing_2fa IS NULL THEN - RETURN false; - END IF; - - -- If org does not enforce 2FA, allow access - IF v_org_enforcing_2fa = false THEN - RETURN false; - END IF; - - -- If org enforces 2FA and user doesn't have 2FA enabled, reject access - IF v_org_enforcing_2fa = true AND NOT public.has_2fa_enabled(v_user_id) THEN - RETURN true; - END IF; - - -- Otherwise, allow access - RETURN false; -END; -$$; - -ALTER FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) OWNER TO "postgres"; - --- Grant permissions - accessible to authenticated, anon (for API key usage), and service_role -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) TO "service_role"; diff --git a/supabase/migrations/20260129123000_fix_is_bundle_encrypted_empty.sql b/supabase/migrations/20260129123000_fix_is_bundle_encrypted_empty.sql deleted file mode 100644 index 9fa762ff19..0000000000 --- a/supabase/migrations/20260129123000_fix_is_bundle_encrypted_empty.sql +++ /dev/null @@ -1,21 +0,0 @@ --- ============================================================================ --- Fix is_bundle_encrypted to treat empty/whitespace session_key as not encrypted --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."is_bundle_encrypted"( - "session_key" text -) RETURNS boolean -LANGUAGE "plpgsql" IMMUTABLE -SET "search_path" TO '' -AS $$ -BEGIN - -- A bundle is considered encrypted if session_key is non-null and non-empty - RETURN session_key IS NOT NULL AND length(btrim(session_key)) > 0; -END; -$$; - -ALTER FUNCTION "public"."is_bundle_encrypted"(text) OWNER TO "postgres"; - --- Grant permissions -GRANT EXECUTE ON FUNCTION "public"."is_bundle_encrypted"(text) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_bundle_encrypted"(text) TO "service_role"; diff --git a/supabase/migrations/20260130032543_allow_org_logo_images.sql b/supabase/migrations/20260130032543_allow_org_logo_images.sql deleted file mode 100644 index c4984fd3b0..0000000000 --- a/supabase/migrations/20260130032543_allow_org_logo_images.sql +++ /dev/null @@ -1,261 +0,0 @@ --- Allow org logo access in images bucket policies --- Org logos live at: images/org/{org_id}/logo/{file} - --- SELECT -DROP POLICY IF EXISTS "Allow user or apikey to read they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to read they own folder in images" -ON storage.objects -FOR SELECT -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - -- Org logos: org/{org_id}/logo/... - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - THEN - public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR ( - -- User avatars stored under user_id/* (allow same org members) - (storage.foldername(name))[1] <> 'org' - AND EXISTS ( - SELECT 1 - FROM public.org_users AS ou - WHERE - ou.user_id::text - = (storage.foldername(storage.objects.name))[1] - AND public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - ou.org_id - ), - ou.org_id, - NULL::character varying, - NULL::bigint - ) - ) - ) - ) -); - --- INSERT -DROP POLICY IF EXISTS "Allow user or apikey to insert they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to insert they own folder in images" -ON storage.objects -FOR INSERT -TO anon, authenticated -WITH CHECK ( - bucket_id = 'images' - AND ( - -- Org logos: org/{org_id}/logo/... - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR EXISTS ( - -- User avatars: only the owner can write their folder - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT NULL - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -); - --- UPDATE -DROP POLICY IF EXISTS "Allow user or apikey to update they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to update they own folder in images" -ON storage.objects -FOR UPDATE -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT NULL - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -) -WITH CHECK ( - bucket_id = 'images' - AND ( - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT NULL - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -); - --- DELETE -DROP POLICY IF EXISTS "Allow user or apikey to delete they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to delete they own folder in images" -ON storage.objects -FOR DELETE -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT NULL - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -); diff --git a/supabase/migrations/20260130033703_private_images_bucket.sql b/supabase/migrations/20260130033703_private_images_bucket.sql deleted file mode 100644 index 43dcd451a3..0000000000 --- a/supabase/migrations/20260130033703_private_images_bucket.sql +++ /dev/null @@ -1,230 +0,0 @@ --- Make images bucket private -UPDATE storage.buckets -SET public = false -WHERE id = 'images'; - --- Normalize existing public image URLs to storage paths (backward compatible) -UPDATE public.users -SET - image_url - = regexp_replace( - split_part(image_url, '?', 1), - '^.*/storage/v1/object/(public/|sign/)?images/', - '' - ) -WHERE - image_url IS NOT null - AND image_url ~ '/storage/v1/object/(public/|sign/)?images/'; - -UPDATE public.orgs -SET - logo - = regexp_replace( - split_part(logo, '?', 1), - '^.*/storage/v1/object/(public/|sign/)?images/', - '' - ) -WHERE - logo IS NOT null - AND logo ~ '/storage/v1/object/(public/|sign/)?images/'; - -UPDATE public.apps -SET - icon_url - = regexp_replace( - split_part(icon_url, '?', 1), - '^.*/storage/v1/object/(public/|sign/)?images/', - '' - ) -WHERE - icon_url IS NOT null - AND icon_url ~ '/storage/v1/object/(public/|sign/)?images/'; - --- Remove overly permissive policy -DROP POLICY IF EXISTS "All all users to act" ON storage.objects; - --- Replace images bucket policies to support private access + org membership -DROP POLICY IF EXISTS "Allow user or apikey to read they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to read they own folder in images" -ON storage.objects -FOR SELECT -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - -- App icons: org/{org_id}/{app_id}/... - CASE - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - null::bigint - ) - ELSE false - END - OR ( - -- User avatars stored under user_id/* (allow same org members) - (storage.foldername(name))[1] <> 'org' - AND EXISTS ( - SELECT 1 - FROM public.org_users AS ou - WHERE - ou.user_id::text - = (storage.foldername(storage.objects.name))[1] - AND public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - ou.org_id - ), - ou.org_id, - null::character varying, - null::bigint - ) - ) - ) - ) -); - -DROP POLICY IF EXISTS "Allow user or apikey to insert they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to insert they own folder in images" -ON storage.objects -FOR INSERT -TO anon, authenticated -WITH CHECK ( - bucket_id = 'images' - AND ( - -- App icons: org/{org_id}/{app_id}/... - CASE - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - null::bigint - ) - ELSE false - END - OR EXISTS ( - -- User avatars: only the owner can write their folder - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT null - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -); - -DROP POLICY IF EXISTS "Allow user or apikey to update they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to update they own folder in images" -ON storage.objects -FOR UPDATE -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - CASE - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - null::bigint - ) - ELSE false - END - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT null - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -) -WITH CHECK ( - bucket_id = 'images' - AND ( - CASE - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - null::bigint - ) - ELSE false - END - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT null - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -); - -DROP POLICY IF EXISTS "Allow user or apikey to delete they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to delete they own folder in images" -ON storage.objects -FOR DELETE -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - CASE - WHEN (storage.foldername(name))[1] = 'org' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - null::bigint - ) - ELSE false - END - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT null - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -); diff --git a/supabase/migrations/20260130040811_allow_org_logo_upload.sql b/supabase/migrations/20260130040811_allow_org_logo_upload.sql deleted file mode 100644 index bd8c187c01..0000000000 --- a/supabase/migrations/20260130040811_allow_org_logo_upload.sql +++ /dev/null @@ -1,277 +0,0 @@ --- Allow org logo uploads/reads in private images bucket - --- Recreate images bucket policies with org logo support -DROP POLICY IF EXISTS "Allow user or apikey to read they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to read they own folder in images" -ON storage.objects -FOR SELECT -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - -- App icons: org/{org_id}/{app_id}/... - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] IS NOT NULL - AND (storage.foldername(name))[3] <> 'logo' - THEN - public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR ( - -- Org logos: org/{org_id}/logo/... - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - AND public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - ) - OR ( - -- User avatars stored under user_id/* (allow same org members) - (storage.foldername(name))[1] <> 'org' - AND EXISTS ( - SELECT 1 - FROM public.org_users AS ou - WHERE - ou.user_id::text - = (storage.foldername(storage.objects.name))[1] - AND public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - ou.org_id - ), - ou.org_id, - NULL::character varying, - NULL::bigint - ) - ) - ) - ) -); - -DROP POLICY IF EXISTS "Allow user or apikey to insert they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to insert they own folder in images" -ON storage.objects -FOR INSERT -TO anon, authenticated -WITH CHECK ( - bucket_id = 'images' - AND ( - -- App icons: org/{org_id}/{app_id}/... - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] IS NOT NULL - AND (storage.foldername(name))[3] <> 'logo' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR ( - -- Org logos: org/{org_id}/logo/... - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - AND public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - ) - OR EXISTS ( - -- User avatars: only the owner can write their folder - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT NULL - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -); - -DROP POLICY IF EXISTS "Allow user or apikey to update they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to update they own folder in images" -ON storage.objects -FOR UPDATE -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] IS NOT NULL - AND (storage.foldername(name))[3] <> 'logo' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR ( - -- Org logos: org/{org_id}/logo/... - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - AND public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - ) - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT NULL - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -) -WITH CHECK ( - bucket_id = 'images' - AND ( - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] IS NOT NULL - AND (storage.foldername(name))[3] <> 'logo' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR ( - -- Org logos: org/{org_id}/logo/... - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - AND public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - ) - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT NULL - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -); - -DROP POLICY IF EXISTS "Allow user or apikey to delete they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to delete they own folder in images" -ON storage.objects -FOR DELETE -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] IS NOT NULL - AND (storage.foldername(name))[3] <> 'logo' - THEN - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - OR ( - -- Org logos: org/{org_id}/logo/... - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - AND public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - ) - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - auth_user.uid IS NOT NULL - AND auth_user.uid::text - = (storage.foldername(storage.objects.name))[1] - ) - ) -); diff --git a/supabase/migrations/20260130190800_update_invite_expiry_on_resend.sql b/supabase/migrations/20260130190800_update_invite_expiry_on_resend.sql deleted file mode 100644 index 5d09205f4c..0000000000 --- a/supabase/migrations/20260130190800_update_invite_expiry_on_resend.sql +++ /dev/null @@ -1,177 +0,0 @@ --- Refresh invite validity based on updated_at to support resends without mutating created_at. - -CREATE OR REPLACE FUNCTION public.get_invite_by_magic_lookup(lookup text) -RETURNS TABLE ( - org_name text, - org_logo text, - role text -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - SELECT - o.name AS org_name, - o.logo AS org_logo, - COALESCE(tmp.rbac_role_name, tmp.role::text) AS role - FROM public.tmp_users tmp - JOIN public.orgs o ON tmp.org_id = o.id - WHERE tmp.invite_magic_string = get_invite_by_magic_lookup.lookup - AND tmp.cancelled_at IS NULL - AND GREATEST(tmp.updated_at, tmp.created_at) > (CURRENT_TIMESTAMP - INTERVAL '7 days'); -END; -$$; - -CREATE OR REPLACE FUNCTION public.get_org_members( - "user_id" uuid, "guild_id" uuid -) RETURNS TABLE ( - aid bigint, - uid uuid, - email varchar, - image_url varchar, - role public.user_min_right, - is_tmp boolean -) LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -BEGIN - PERFORM user_id; - RETURN QUERY - -- Get existing org members - SELECT o.id AS aid, users.id AS uid, users.email, users.image_url, o.user_right AS role, false AS is_tmp - FROM public.org_users o - JOIN public.users ON users.id = o.user_id - WHERE o.org_id = get_org_members.guild_id - AND public.is_member_of_org(users.id, o.org_id) - UNION - -- Get pending invitations from tmp_users - SELECT - ((SELECT COALESCE(MAX(id), 0) FROM public.org_users) + tmp.id)::bigint AS aid, - tmp.future_uuid AS uid, - tmp.email::varchar, - ''::varchar AS image_url, - public.transform_role_to_invite(tmp.role) AS role, - true AS is_tmp - FROM public.tmp_users tmp - WHERE tmp.org_id = get_org_members.guild_id - AND tmp.cancelled_at IS NULL - AND GREATEST(tmp.updated_at, tmp.created_at) > (CURRENT_TIMESTAMP - INTERVAL '7 days'); -END; -$$; - -CREATE OR REPLACE FUNCTION public.get_org_members_rbac(p_org_id uuid) -RETURNS TABLE ( - user_id uuid, - email character varying, - image_url character varying, - role_name text, - role_id uuid, - binding_id uuid, - granted_at timestamp with time zone, - is_invite boolean, - is_tmp boolean, - org_user_id bigint -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - api_key_text text; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_read(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_MEMBERS'; - END IF; - - RETURN QUERY - WITH rbac_members AS ( - SELECT - u.id AS user_id, - u.email, - u.image_url, - r.name AS role_name, - rb.role_id, - rb.id AS binding_id, - rb.granted_at, - false AS is_invite, - false AS is_tmp, - NULL::bigint AS org_user_id - FROM public.users u - INNER JOIN public.role_bindings rb ON rb.principal_id = u.id - AND rb.principal_type = public.rbac_principal_user() - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE r.scope_type = public.rbac_scope_org() - AND r.name LIKE 'org_%' - ), - legacy_invites AS ( - SELECT - u.id AS user_id, - u.email, - u.image_url, - COALESCE( - ou.rbac_role_name, - CASE public.transform_role_to_non_invite(ou.user_right) - WHEN public.rbac_right_super_admin() THEN public.rbac_role_org_super_admin() - WHEN public.rbac_right_admin() THEN public.rbac_role_org_admin() - ELSE public.rbac_role_org_member() - END - ) AS role_name, - NULL::uuid AS role_id, - NULL::uuid AS binding_id, - ou.created_at AS granted_at, - true AS is_invite, - false AS is_tmp, - ou.id AS org_user_id - FROM public.org_users ou - INNER JOIN public.users u ON u.id = ou.user_id - WHERE ou.org_id = p_org_id - AND ou.user_right::text LIKE 'invite_%' - ), - tmp_invites AS ( - SELECT - tmp.future_uuid AS user_id, - tmp.email, - ''::character varying AS image_url, - COALESCE( - tmp.rbac_role_name, - CASE tmp.role - WHEN public.rbac_right_super_admin() THEN public.rbac_role_org_super_admin() - WHEN public.rbac_right_admin() THEN public.rbac_role_org_admin() - ELSE public.rbac_role_org_member() - END - ) AS role_name, - NULL::uuid AS role_id, - NULL::uuid AS binding_id, - GREATEST(tmp.updated_at, tmp.created_at) AS granted_at, - true AS is_invite, - true AS is_tmp, - NULL::bigint AS org_user_id - FROM public.tmp_users tmp - WHERE tmp.org_id = p_org_id - AND tmp.cancelled_at IS NULL - AND GREATEST(tmp.updated_at, tmp.created_at) > (CURRENT_TIMESTAMP - INTERVAL '7 days') - ) - SELECT * - FROM ( - SELECT * FROM rbac_members - UNION ALL - SELECT * FROM legacy_invites - UNION ALL - SELECT * FROM tmp_invites - ) AS combined - ORDER BY - combined.is_invite, - CASE combined.role_name - WHEN public.rbac_role_org_super_admin() THEN 1 - WHEN public.rbac_role_org_admin() THEN 2 - WHEN public.rbac_role_org_billing_admin() THEN 3 - WHEN public.rbac_role_org_member() THEN 4 - ELSE 5 - END, - combined.email; -END; -$$; diff --git a/supabase/migrations/20260201015640_add_upgrade_org_stats.sql b/supabase/migrations/20260201015640_add_upgrade_org_stats.sql deleted file mode 100644 index a5e23b5fdd..0000000000 --- a/supabase/migrations/20260201015640_add_upgrade_org_stats.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Add upgrade tracking fields for revenue analytics -ALTER TABLE public.stripe_info -ADD COLUMN IF NOT EXISTS upgraded_at timestamp with time zone; - -COMMENT ON COLUMN public.stripe_info.upgraded_at IS 'Timestamp of last paid plan upgrade for the org'; - -ALTER TABLE public.global_stats -ADD COLUMN IF NOT EXISTS upgraded_orgs integer DEFAULT 0 NOT NULL; - -COMMENT ON COLUMN public.global_stats.upgraded_orgs IS 'Number of organizations that upgraded plans in the last 24 hours'; diff --git a/supabase/migrations/20260201042609_fix_password_policy_org_read_gate.sql b/supabase/migrations/20260201042609_fix_password_policy_org_read_gate.sql deleted file mode 100644 index 3128de49ec..0000000000 --- a/supabase/migrations/20260201042609_fix_password_policy_org_read_gate.sql +++ /dev/null @@ -1,248 +0,0 @@ --- Allow org.read checks without enforcing password policy for password verification flow - -CREATE OR REPLACE FUNCTION public.check_min_rights_legacy_no_password_policy( - min_right public.user_min_right, - user_id uuid, - org_id uuid, - app_id character varying, - channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - user_right_record RECORD; - v_org_enforcing_2fa boolean; -BEGIN - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_LEGACY_NO_UID', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text)); - RETURN false; - END IF; - - -- Enforce 2FA if the org requires it. - IF org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa FROM public.orgs WHERE id = org_id; - IF v_org_enforcing_2fa = true AND NOT public.has_2fa_enabled(user_id) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_LEGACY_NO_PW_2FA_ENFORCEMENT', jsonb_build_object( - 'org_id', org_id, - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - FOR user_right_record IN - SELECT org_users.user_right, org_users.app_id, org_users.channel_id - FROM public.org_users - WHERE org_users.org_id = check_min_rights_legacy_no_password_policy.org_id - AND org_users.user_id = check_min_rights_legacy_no_password_policy.user_id - LOOP - IF (user_right_record.user_right >= min_right AND user_right_record.app_id IS NULL AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy_no_password_policy.app_id AND user_right_record.channel_id IS NULL) OR - (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy_no_password_policy.app_id AND user_right_record.channel_id = check_min_rights_legacy_no_password_policy.channel_id) - THEN - RETURN true; - END IF; - END LOOP; - - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_LEGACY_NO_PW', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); - RETURN false; -END; -$$; - -ALTER FUNCTION public.check_min_rights_legacy_no_password_policy( - public.user_min_right, uuid, uuid, character varying, bigint -) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy( - public.user_min_right, uuid, uuid, character varying, bigint -) FROM public; -REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy( - public.user_min_right, uuid, uuid, character varying, bigint -) FROM anon; -REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy( - public.user_min_right, uuid, uuid, character varying, bigint -) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.check_min_rights_legacy_no_password_policy( - public.user_min_right, uuid, uuid, character varying, bigint -) TO service_role; - -CREATE OR REPLACE FUNCTION public.rbac_check_permission_direct_no_password_policy( - p_permission_key text, - p_user_id uuid, - p_org_id uuid, - p_app_id character varying, - p_channel_id bigint, - p_apikey text DEFAULT NULL -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; - v_org_enforcing_2fa boolean; - v_effective_user_id uuid := p_user_id; -BEGIN - -- Validate permission key - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - -- Derive org from app/channel when not provided - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF v_effective_org_id IS NULL AND p_channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - END IF; - - -- Resolve user from API key when needed (handles hashed keys too). - IF v_effective_user_id IS NULL AND p_apikey IS NOT NULL THEN - SELECT user_id INTO v_effective_user_id - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Check if RBAC is enabled for this org - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - -- RBAC path: Check user permission directly - IF v_effective_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), v_effective_user_id, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - END IF; - - -- If user doesn't have permission, check apikey permission - IF NOT v_allowed AND p_apikey IS NOT NULL THEN - SELECT rbac_id INTO v_apikey_principal - FROM public.apikeys - WHERE key = p_apikey - LIMIT 1; - - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - ELSE - -- Legacy path: Map permission to min_right and use legacy check - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - -- Unknown permission in legacy mode, deny by default - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id - )); - RETURN false; - END IF; - - -- Use appropriate legacy check based on context - IF p_apikey IS NOT NULL AND p_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, v_effective_user_id, p_apikey); - ELSIF p_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(p_app_id, v_legacy_right, v_effective_user_id); - ELSE - RETURN public.check_min_rights_legacy_no_password_policy(v_legacy_right, v_effective_user_id, v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - -ALTER FUNCTION public.rbac_check_permission_direct_no_password_policy( - text, uuid, uuid, character varying, bigint, text -) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.rbac_check_permission_direct_no_password_policy( - text, uuid, uuid, character varying, bigint, text -) FROM public; -REVOKE ALL ON FUNCTION public.rbac_check_permission_direct_no_password_policy( - text, uuid, uuid, character varying, bigint, text -) FROM anon; -REVOKE ALL ON FUNCTION public.rbac_check_permission_direct_no_password_policy( - text, uuid, uuid, character varying, bigint, text -) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_check_permission_direct_no_password_policy( - text, uuid, uuid, character varying, bigint, text -) TO service_role; - -CREATE OR REPLACE FUNCTION public.rbac_check_permission_no_password_policy( - p_permission_key text, - p_org_id uuid DEFAULT NULL, - p_app_id character varying DEFAULT NULL, - p_channel_id bigint DEFAULT NULL -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - IF auth.uid() IS NULL THEN - RETURN false; - END IF; - - RETURN public.rbac_check_permission_direct_no_password_policy( - p_permission_key, - auth.uid(), - p_org_id, - p_app_id, - p_channel_id, - NULL - ); -END; -$$; - -COMMENT ON FUNCTION public.rbac_check_permission_no_password_policy( - text, uuid, character varying, bigint -) IS -'RBAC permission check without password policy enforcement. Uses auth.uid() and delegates to rbac_check_permission_direct_no_password_policy.'; - -ALTER FUNCTION public.rbac_check_permission_no_password_policy( - text, uuid, character varying, bigint -) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.rbac_check_permission_no_password_policy( - text, uuid, character varying, bigint -) TO authenticated; diff --git a/supabase/migrations/20260202090000_add_cli_realtime_feed_pref.sql b/supabase/migrations/20260202090000_add_cli_realtime_feed_pref.sql deleted file mode 100644 index 7a446d9d13..0000000000 --- a/supabase/migrations/20260202090000_add_cli_realtime_feed_pref.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Add cli_realtime_feed preference for users and set default to true - --- Backfill cli_realtime_feed preference for existing users who have email_preferences set -UPDATE public.users -SET - email_preferences - = email_preferences || '{"cli_realtime_feed": true}'::jsonb -WHERE - email_preferences IS NOT NULL - AND NOT (email_preferences ? 'cli_realtime_feed'); - --- Update the default value for email_preferences on users table -ALTER TABLE public.users -ALTER COLUMN email_preferences SET DEFAULT '{ - "usage_limit": true, - "credit_usage": true, - "onboarding": true, - "weekly_stats": true, - "monthly_stats": true, - "billing_period_stats": true, - "deploy_stats_24h": true, - "bundle_created": true, - "bundle_deployed": true, - "device_error": true, - "channel_self_rejected": true, - "cli_realtime_feed": true -}'::jsonb; - --- Update column comments -COMMENT ON COLUMN public.users.email_preferences IS 'Per-user email notification preferences. Keys: usage_limit, credit_usage, onboarding, weekly_stats, monthly_stats, billing_period_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected, cli_realtime_feed. Values are booleans.'; diff --git a/supabase/migrations/20260203010025_add_build_success_stats.sql b/supabase/migrations/20260203010025_add_build_success_stats.sql deleted file mode 100644 index 32d4f0fffa..0000000000 --- a/supabase/migrations/20260203010025_add_build_success_stats.sql +++ /dev/null @@ -1,8 +0,0 @@ -ALTER TABLE public.global_stats -ADD COLUMN builds_success_total bigint DEFAULT 0, -ADD COLUMN builds_success_ios bigint DEFAULT 0, -ADD COLUMN builds_success_android bigint DEFAULT 0; - -COMMENT ON COLUMN public.global_stats.builds_success_total IS 'Total number of successful native builds recorded (all time)'; -COMMENT ON COLUMN public.global_stats.builds_success_ios IS 'Total number of successful iOS native builds recorded (all time)'; -COMMENT ON COLUMN public.global_stats.builds_success_android IS 'Total number of successful Android native builds recorded (all time)'; diff --git a/supabase/migrations/20260203120000_optimize_org_metrics_cache.sql b/supabase/migrations/20260203120000_optimize_org_metrics_cache.sql deleted file mode 100644 index e6789cece1..0000000000 --- a/supabase/migrations/20260203120000_optimize_org_metrics_cache.sql +++ /dev/null @@ -1,556 +0,0 @@ --- Add org-level metrics cache and combine plan usage + fit calculation - -CREATE TABLE IF NOT EXISTS public.org_metrics_cache ( - org_id uuid PRIMARY KEY REFERENCES public.orgs (id), - start_date date NOT NULL, - end_date date NOT NULL, - mau bigint NOT NULL, - storage bigint NOT NULL, - bandwidth bigint NOT NULL, - build_time_unit bigint NOT NULL, - get bigint NOT NULL, - fail bigint NOT NULL, - install bigint NOT NULL, - uninstall bigint NOT NULL, - cached_at timestamp with time zone NOT NULL DEFAULT NOW() -); - -ALTER TABLE public.org_metrics_cache ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Deny all" ON public.org_metrics_cache FOR ALL USING (false) -WITH -CHECK (false); - -CREATE FUNCTION public.calculate_org_metrics_cache_entry( - p_org_id uuid, - p_start_date date, - p_end_date date -) RETURNS public.org_metrics_cache LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_mau bigint; - v_storage bigint; - v_bandwidth bigint; - v_build_time bigint; - v_get bigint; - v_fail bigint; - v_install bigint; - v_uninstall bigint; - cache_record public.org_metrics_cache%ROWTYPE; -BEGIN - WITH app_ids AS ( - SELECT apps.app_id - FROM public.apps - WHERE apps.owner_org = p_org_id - UNION - SELECT deleted_apps.app_id - FROM public.deleted_apps - WHERE deleted_apps.owner_org = p_org_id - ), - mau AS ( - SELECT COALESCE(SUM(dm.mau), 0)::bigint AS value - FROM public.daily_mau dm - JOIN app_ids a ON a.app_id = dm.app_id - WHERE dm.date BETWEEN p_start_date AND p_end_date - ), - bandwidth AS ( - SELECT COALESCE(SUM(db.bandwidth), 0)::bigint AS value - FROM public.daily_bandwidth db - JOIN app_ids a ON a.app_id = db.app_id - WHERE db.date BETWEEN p_start_date AND p_end_date - ), - build_time AS ( - SELECT COALESCE(SUM(dbt.build_time_unit), 0)::bigint AS value - FROM public.daily_build_time dbt - JOIN app_ids a ON a.app_id = dbt.app_id - WHERE dbt.date BETWEEN p_start_date AND p_end_date - ), - version_stats AS ( - SELECT - COALESCE(SUM(dv.get), 0)::bigint AS get, - COALESCE(SUM(dv.fail), 0)::bigint AS fail, - COALESCE(SUM(dv.install), 0)::bigint AS install, - COALESCE(SUM(dv.uninstall), 0)::bigint AS uninstall - FROM public.daily_version dv - JOIN app_ids a ON a.app_id = dv.app_id - WHERE dv.date BETWEEN p_start_date AND p_end_date - ), - storage AS ( - SELECT COALESCE(SUM(avm.size), 0)::bigint AS value - FROM public.app_versions av - INNER JOIN public.app_versions_meta avm ON av.id = avm.id - WHERE av.owner_org = p_org_id AND av.deleted = false - ) - SELECT - mau.value, - storage.value, - bandwidth.value, - build_time.value, - version_stats.get, - version_stats.fail, - version_stats.install, - version_stats.uninstall - INTO v_mau, v_storage, v_bandwidth, v_build_time, v_get, v_fail, v_install, v_uninstall - FROM mau, storage, bandwidth, build_time, version_stats; - - cache_record.org_id := p_org_id; - cache_record.start_date := p_start_date; - cache_record.end_date := p_end_date; - cache_record.mau := v_mau; - cache_record.storage := v_storage; - cache_record.bandwidth := v_bandwidth; - cache_record.build_time_unit := v_build_time; - cache_record.get := v_get; - cache_record.fail := v_fail; - cache_record.install := v_install; - cache_record.uninstall := v_uninstall; - cache_record.cached_at := clock_timestamp(); - - RETURN cache_record; -END; -$function$; - -ALTER FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) FROM anon; -REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) FROM authenticated; -REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) FROM service_role; -COMMENT ON FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) IS - 'Compute the aggregated org metrics (MAU, storage, bandwidth, build time unit, get/fail/install/uninstall) for the supplied date range without persisting changes. Read-only paths use this helper so they can return cached metrics without touching org_metrics_cache directly.'; - -CREATE OR REPLACE FUNCTION public.seed_org_metrics_cache( - p_org_id uuid, - p_start_date date, - p_end_date date -) RETURNS public.org_metrics_cache LANGUAGE plpgsql SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - cache_record public.org_metrics_cache%ROWTYPE; -BEGIN - INSERT INTO public.org_metrics_cache ( - org_id, - start_date, - end_date, - mau, - storage, - bandwidth, - build_time_unit, - get, - fail, - install, - uninstall, - cached_at - ) - SELECT - org_id, - start_date, - end_date, - mau, - storage, - bandwidth, - build_time_unit, - get, - fail, - install, - uninstall, - cached_at - FROM public.calculate_org_metrics_cache_entry(p_org_id, p_start_date, p_end_date) - ON CONFLICT (org_id) DO UPDATE - SET start_date = EXCLUDED.start_date, - end_date = EXCLUDED.end_date, - mau = EXCLUDED.mau, - storage = EXCLUDED.storage, - bandwidth = EXCLUDED.bandwidth, - build_time_unit = EXCLUDED.build_time_unit, - get = EXCLUDED.get, - fail = EXCLUDED.fail, - install = EXCLUDED.install, - uninstall = EXCLUDED.uninstall, - cached_at = EXCLUDED.cached_at - RETURNING * INTO cache_record; - - RETURN cache_record; -END; -$function$; - -ALTER FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) FROM public; -REVOKE ALL ON FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) FROM anon; -REVOKE ALL ON FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) FROM authenticated; -REVOKE ALL ON FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) FROM service_role; - --- Cached get_total_metrics implementation -DROP FUNCTION IF EXISTS public.get_total_metrics(uuid, date, date); - -CREATE FUNCTION public.get_total_metrics( - org_id uuid, - start_date date, - end_date date -) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - cache_entry public.org_metrics_cache%ROWTYPE; - cache_ttl interval := '5 minutes'::interval; - tx_read_only boolean := current_setting('transaction_read_only') = 'on'; -BEGIN - IF start_date IS NULL OR end_date IS NULL THEN - RETURN; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE orgs.id = get_total_metrics.org_id - ) THEN - RETURN; - END IF; - - IF EXISTS ( - SELECT 1 - FROM pg_catalog.pg_stat_xact_user_tables - WHERE relname IN ( - 'apps', - 'deleted_apps', - 'daily_mau', - 'daily_bandwidth', - 'daily_build_time', - 'daily_version', - 'app_versions', - 'app_versions_meta' - ) - AND (n_tup_ins > 0 OR n_tup_upd > 0 OR n_tup_del > 0) - ) THEN - IF tx_read_only THEN - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.calculate_org_metrics_cache_entry(org_id, start_date, end_date) AS metrics; - RETURN; - END IF; - - cache_entry := public.seed_org_metrics_cache(org_id, start_date, end_date); - - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; - RETURN; - END IF; - - SELECT * INTO cache_entry - FROM public.org_metrics_cache - WHERE org_metrics_cache.org_id = get_total_metrics.org_id; - - IF FOUND - AND cache_entry.start_date = start_date - AND cache_entry.end_date = end_date - AND cache_entry.cached_at > clock_timestamp() - cache_ttl - THEN - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; - RETURN; - END IF; - - IF tx_read_only THEN - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.calculate_org_metrics_cache_entry(org_id, start_date, end_date) AS metrics; - RETURN; - END IF; - - cache_entry := public.seed_org_metrics_cache(org_id, start_date, end_date); - - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; -END; -$function$; - -ALTER FUNCTION public.get_total_metrics(uuid, date, date) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_total_metrics( - uuid, date, date -) TO service_role; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid, date, date) FROM anon; -REVOKE ALL ON FUNCTION public.get_total_metrics( - uuid, date, date -) FROM authenticated; - --- Keep 1-arg get_total_metrics in sync with new column list -DROP FUNCTION IF EXISTS public.get_total_metrics(uuid); - -CREATE FUNCTION public.get_total_metrics(org_id uuid) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_start_date date; - v_end_date date; - v_anchor_day interval; -BEGIN - SELECT - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - INTO v_anchor_day - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE o.id = org_id; - - IF NOT FOUND THEN - RETURN; - END IF; - - IF v_anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN - v_start_date := (date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + v_anchor_day)::date; - ELSE - v_start_date := (date_trunc('MONTH', NOW()) + v_anchor_day)::date; - END IF; - v_end_date := (v_start_date + INTERVAL '1 MONTH')::date; - - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.get_total_metrics(org_id, v_start_date, v_end_date) AS metrics; -END; -$function$; - -ALTER FUNCTION public.get_total_metrics(uuid) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_total_metrics(uuid) TO service_role; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM authenticated; - --- Combined usage + plan fit (single get_total_metrics call) -CREATE FUNCTION public.get_plan_usage_and_fit(orgid uuid) -RETURNS TABLE ( - is_good_plan boolean, - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_start_date date; - v_end_date date; - v_plan_mau bigint; - v_plan_bandwidth bigint; - v_plan_storage bigint; - v_plan_build_time bigint; - v_anchor_day interval; - v_plan_name text; - total_stats RECORD; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; - percent_build_time double precision; - v_is_good_plan boolean; -BEGIN - SELECT - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL), - p.mau, - p.bandwidth, - p.storage, - p.build_time_unit, - p.name - INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time, v_plan_name - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; - - IF v_anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN - v_start_date := (date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + v_anchor_day)::date; - ELSE - v_start_date := (date_trunc('MONTH', NOW()) + v_anchor_day)::date; - END IF; - v_end_date := (v_start_date + INTERVAL '1 MONTH')::date; - - SELECT * INTO total_stats - FROM public.get_total_metrics(orgid, v_start_date, v_end_date); - - percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); - - IF v_plan_name = 'Enterprise' THEN - v_is_good_plan := TRUE; - ELSIF v_plan_name IS NULL THEN - v_is_good_plan := FALSE; - ELSE - v_is_good_plan := v_plan_mau >= total_stats.mau - AND v_plan_bandwidth >= total_stats.bandwidth - AND v_plan_storage >= total_stats.storage - AND v_plan_build_time >= COALESCE(total_stats.build_time_unit, 0); - END IF; - - RETURN QUERY SELECT - v_is_good_plan, - GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, - percent_bandwidth, - percent_storage, - percent_build_time; -END; -$function$; - -ALTER FUNCTION public.get_plan_usage_and_fit(uuid) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) TO service_role; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM authenticated; - --- Uncached usage + plan fit (refreshes cache for accurate cron evaluations) -CREATE FUNCTION public.get_plan_usage_and_fit_uncached(orgid uuid) -RETURNS TABLE ( - is_good_plan boolean, - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_start_date date; - v_end_date date; - v_plan_mau bigint; - v_plan_bandwidth bigint; - v_plan_storage bigint; - v_plan_build_time bigint; - v_anchor_day interval; - v_plan_name text; - total_stats RECORD; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; - percent_build_time double precision; - v_is_good_plan boolean; -BEGIN - SELECT - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL), - p.mau, - p.bandwidth, - p.storage, - p.build_time_unit, - p.name - INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time, v_plan_name - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; - - IF v_anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN - v_start_date := (date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + v_anchor_day)::date; - ELSE - v_start_date := (date_trunc('MONTH', NOW()) + v_anchor_day)::date; - END IF; - v_end_date := (v_start_date + INTERVAL '1 MONTH')::date; - - SELECT * INTO total_stats - FROM public.seed_org_metrics_cache(orgid, v_start_date, v_end_date); - - percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); - - IF v_plan_name = 'Enterprise' THEN - v_is_good_plan := TRUE; - ELSIF v_plan_name IS NULL THEN - v_is_good_plan := FALSE; - ELSE - v_is_good_plan := v_plan_mau >= total_stats.mau - AND v_plan_bandwidth >= total_stats.bandwidth - AND v_plan_storage >= total_stats.storage - AND v_plan_build_time >= COALESCE(total_stats.build_time_unit, 0); - END IF; - - RETURN QUERY SELECT - v_is_good_plan, - GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, - percent_bandwidth, - percent_storage, - percent_build_time; -END; -$function$; - -ALTER FUNCTION public.get_plan_usage_and_fit_uncached(uuid) OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_plan_usage_and_fit_uncached( - uuid -) TO service_role; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached( - uuid -) FROM authenticated; diff --git a/supabase/migrations/20260203140000_security_hardening.sql b/supabase/migrations/20260203140000_security_hardening.sql deleted file mode 100644 index 7d3f81be4b..0000000000 --- a/supabase/migrations/20260203140000_security_hardening.sql +++ /dev/null @@ -1,281 +0,0 @@ --- ============================================================================ --- Security hardening: RPC exposure, auth checks, and logging redaction --- ============================================================================ - --- --------------------------------------------------------------------------- --- 1) Restrict find_apikey_by_value EXECUTE to service_role --- --------------------------------------------------------------------------- -REVOKE EXECUTE -ON FUNCTION public.find_apikey_by_value(text) -FROM anon; - -REVOKE EXECUTE -ON FUNCTION public.find_apikey_by_value(text) -FROM authenticated; - -GRANT EXECUTE -ON FUNCTION public.find_apikey_by_value(text) -TO service_role; - --- --------------------------------------------------------------------------- --- 2) Harden get_account_removal_date (self-only or service_role) --- --------------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION public.get_account_removal_date(user_id uuid) -RETURNS timestamptz -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - removal_date TIMESTAMPTZ; - auth_uid uuid; - auth_role text; -BEGIN - SELECT auth.uid() INTO auth_uid; - SELECT auth.role() INTO auth_role; - - IF auth_uid IS NULL THEN - IF auth_role IS DISTINCT FROM 'service_role' THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - ELSE - IF auth_uid <> user_id THEN - RAISE EXCEPTION 'Permission denied'; - END IF; - END IF; - - -- Get the removal_date for the user_id - SELECT to_delete_accounts.removal_date - INTO removal_date - FROM public.to_delete_accounts - WHERE account_id = user_id; - - -- Throw exception if account is not in the table - IF removal_date IS NULL THEN - RAISE EXCEPTION - 'Account with ID % is not marked for deletion', - user_id; - END IF; - - RETURN removal_date; -END; -$$; - -REVOKE EXECUTE -ON FUNCTION public.get_account_removal_date(uuid) -FROM anon; - -GRANT EXECUTE -ON FUNCTION public.get_account_removal_date(uuid) -TO authenticated; - -GRANT EXECUTE -ON FUNCTION public.get_account_removal_date(uuid) -TO service_role; - --- --------------------------------------------------------------------------- --- 3) Prevent org-id enumeration via get_user_main_org_id_by_app_id --- --------------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION public.get_user_main_org_id_by_app_id(app_id text) -RETURNS uuid -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - org_id uuid; - auth_uid uuid; - auth_role text; - api_user_id uuid; -BEGIN - SELECT apps.owner_org - INTO org_id - FROM public.apps - WHERE apps.app_id::text = get_user_main_org_id_by_app_id.app_id::text - LIMIT 1; - - IF org_id IS NULL THEN - RETURN NULL; - END IF; - - SELECT auth.uid() INTO auth_uid; - IF auth_uid IS NOT NULL THEN - IF public.check_min_rights( - 'read'::public.user_min_right, - auth_uid, - org_id, - get_user_main_org_id_by_app_id.app_id, - NULL::bigint - ) THEN - RETURN org_id; - END IF; - - RETURN NULL; - END IF; - - SELECT auth.role() INTO auth_role; - IF auth_role = 'service_role' THEN - RETURN org_id; - END IF; - - SELECT public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode[], - org_id, - get_user_main_org_id_by_app_id.app_id - ) - INTO api_user_id; - - IF api_user_id IS NULL THEN - RETURN NULL; - END IF; - - IF public.check_min_rights( - 'read'::public.user_min_right, - api_user_id, - org_id, - get_user_main_org_id_by_app_id.app_id, - NULL::bigint - ) THEN - RETURN org_id; - END IF; - - RETURN NULL; -END; -$$; - --- --------------------------------------------------------------------------- --- 4) Redact PII from invite_user_to_org logging --- --------------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION public.invite_user_to_org( - email varchar, - org_id uuid, - invite_type public.user_min_right -) -RETURNS varchar -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - org record; - invited_user record; - current_record record; - current_tmp_user record; - calling_user_id uuid; -BEGIN - -- Get the calling user's ID - SELECT public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - invite_user_to_org.org_id - ) - INTO calling_user_id; - - -- Check if org exists - SELECT * - INTO org - FROM public.orgs - WHERE public.orgs.id = invite_user_to_org.org_id; - - IF org IS NULL THEN - RETURN 'NO_ORG'; - END IF; - - -- Check if user has at least public.rbac_right_admin() rights - IF NOT public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - calling_user_id, - invite_user_to_org.org_id, - NULL::varchar, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: NO_RIGHTS_ADMIN', - jsonb_build_object( - 'org_id', invite_user_to_org.org_id, - 'invite_type', invite_user_to_org.invite_type - ) - ); - RETURN 'NO_RIGHTS'; - END IF; - - -- If inviting as super_admin, caller must be super_admin - IF invite_type = public.rbac_right_super_admin()::public.user_min_right - OR invite_type - = public.rbac_right_invite_super_admin()::public.user_min_right THEN - IF NOT public.check_min_rights( - public.rbac_right_super_admin()::public.user_min_right, - calling_user_id, - invite_user_to_org.org_id, - NULL::varchar, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: NO_RIGHTS_SUPER_ADMIN', - jsonb_build_object( - 'org_id', invite_user_to_org.org_id, - 'invite_type', invite_user_to_org.invite_type - ) - ); - RETURN 'NO_RIGHTS'; - END IF; - END IF; - - -- Check if user already exists - SELECT public.users.id - INTO invited_user - FROM public.users - WHERE public.users.email = invite_user_to_org.email; - - IF invited_user IS NOT NULL THEN - -- User exists, check if already in org - SELECT public.org_users.id - INTO current_record - FROM public.org_users - WHERE public.org_users.user_id = invited_user.id - AND public.org_users.org_id = invite_user_to_org.org_id; - - IF current_record IS NOT NULL THEN - RETURN 'ALREADY_INVITED'; - ELSE - -- Add user to org - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - RETURN 'OK'; - END IF; - ELSE - -- User doesn't exist, check tmp_users for pending invitations - SELECT * - INTO current_tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email = invite_user_to_org.email - AND public.tmp_users.org_id = invite_user_to_org.org_id; - - IF current_tmp_user IS NOT NULL THEN - -- Invitation already exists - IF current_tmp_user.cancelled_at IS NOT NULL THEN - -- Invitation was cancelled, check if recent - IF current_tmp_user.cancelled_at - > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - -- No invitation exists, need to create one (handled elsewhere) - RETURN 'NO_EMAIL'; - END IF; - END IF; -END; -$$; - --- --------------------------------------------------------------------------- --- 5) Remove default EXECUTE grants for functions to anon/authenticated --- --------------------------------------------------------------------------- -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public -REVOKE ALL ON FUNCTIONS FROM anon; - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public -REVOKE ALL ON FUNCTIONS FROM authenticated; diff --git a/supabase/migrations/20260203150000_fix_get_user_main_org_id_by_app_id_seed.sql b/supabase/migrations/20260203150000_fix_get_user_main_org_id_by_app_id_seed.sql deleted file mode 100644 index 9c1b6a1bdf..0000000000 --- a/supabase/migrations/20260203150000_fix_get_user_main_org_id_by_app_id_seed.sql +++ /dev/null @@ -1,53 +0,0 @@ --- ============================================================================ --- Allow trusted DB roles to resolve org_id during seed/migrations --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") RETURNS "uuid" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - org_id uuid; - auth_uid uuid; - auth_role text; - api_user_id uuid; -BEGIN - SELECT apps.owner_org INTO org_id - FROM public.apps - WHERE ((apps.app_id)::text = (get_user_main_org_id_by_app_id.app_id)::text) - LIMIT 1; - - IF org_id IS NULL THEN - RETURN NULL; - END IF; - - -- Allow trusted DB roles (seed/migrations) without JWT context - IF session_user IN ('postgres', 'supabase_admin') THEN - RETURN org_id; - END IF; - - SELECT auth.uid() INTO auth_uid; - IF auth_uid IS NOT NULL THEN - IF public.check_min_rights('read'::public.user_min_right, auth_uid, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN - RETURN org_id; - END IF; - RETURN NULL; - END IF; - - SELECT auth.role() INTO auth_role; - IF auth_role = 'service_role' THEN - RETURN org_id; - END IF; - - SELECT public.get_identity_org_appid('{read,upload,write,all}'::public.key_mode[], org_id, get_user_main_org_id_by_app_id.app_id) INTO api_user_id; - IF api_user_id IS NULL THEN - RETURN NULL; - END IF; - - IF public.check_min_rights('read'::public.user_min_right, api_user_id, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN - RETURN org_id; - END IF; - - RETURN NULL; -END; -$$; diff --git a/supabase/migrations/20260203160000_optimize_audit_logs_rls.sql b/supabase/migrations/20260203160000_optimize_audit_logs_rls.sql deleted file mode 100644 index 46c4cbe61d..0000000000 --- a/supabase/migrations/20260203160000_optimize_audit_logs_rls.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Restrict audit_logs access to authenticated users only and fail fast for anon --- to avoid expensive RLS evaluation on unauthenticated requests. - -REVOKE ALL ON TABLE public.audit_logs FROM anon; -REVOKE ALL ON SEQUENCE public.audit_logs_id_seq FROM anon; - -DROP POLICY IF EXISTS "Allow select for auth, api keys (super_admin+)" ON public.audit_logs; - -CREATE POLICY "Allow select for auth (super_admin+)" ON public.audit_logs -FOR SELECT TO authenticated -USING ( - (SELECT - public.check_min_rights( - 'super_admin'::public.user_min_right, - auth_check.uid, - audit_logs.org_id, - NULL::character varying, - NULL::bigint - ) - FROM (SELECT auth.uid() AS uid) AS auth_check) -); diff --git a/supabase/migrations/20260203173000_get_account_removal_date_auth.sql b/supabase/migrations/20260203173000_get_account_removal_date_auth.sql deleted file mode 100644 index d75f8db488..0000000000 --- a/supabase/migrations/20260203173000_get_account_removal_date_auth.sql +++ /dev/null @@ -1,46 +0,0 @@ --- ========================================================================== --- Use auth context for account removal date lookups --- ========================================================================== - -DROP FUNCTION IF EXISTS public.get_account_removal_date(user_id uuid); - -CREATE OR REPLACE FUNCTION public.get_account_removal_date() -RETURNS timestamptz -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - removal_date TIMESTAMPTZ; - auth_uid uuid; -BEGIN - SELECT auth.uid() INTO auth_uid; - IF auth_uid IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT to_delete_accounts.removal_date INTO removal_date - FROM public.to_delete_accounts - WHERE account_id = auth_uid; - - IF removal_date IS NULL THEN - RAISE EXCEPTION - 'Account with ID % is not marked for deletion', - auth_uid; - END IF; - - RETURN removal_date; -END; -$$; - -REVOKE EXECUTE -ON FUNCTION public.get_account_removal_date() -FROM anon; - -GRANT EXECUTE -ON FUNCTION public.get_account_removal_date() -TO authenticated; - -GRANT EXECUTE -ON FUNCTION public.get_account_removal_date() -TO service_role; diff --git a/supabase/migrations/20260203190000_check_min_rights_apikey_scope.sql b/supabase/migrations/20260203190000_check_min_rights_apikey_scope.sql deleted file mode 100644 index 4c803c6296..0000000000 --- a/supabase/migrations/20260203190000_check_min_rights_apikey_scope.sql +++ /dev/null @@ -1,124 +0,0 @@ --- ============================================================================ --- Enforce API key org/app scoping in RBAC fallback + reaffirm grants --- ============================================================================ - -CREATE OR REPLACE FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_allowed boolean := false; - v_perm text; - v_scope text; - v_apikey text; - v_apikey_principal uuid; - v_use_rbac boolean; - v_effective_org_id uuid := org_id; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; - api_key record; -BEGIN - -- Derive org from app/channel when not provided to honor org-level flag and scoping. - IF v_effective_org_id IS NULL AND app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id FROM public.apps WHERE public.apps.app_id = check_min_rights.app_id LIMIT 1; - END IF; - IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id FROM public.channels WHERE public.channels.id = channel_id LIMIT 1; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id; - IF v_org_enforcing_2fa = true AND (user_id IS NULL OR NOT public.has_2fa_enabled(user_id)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - IF NOT v_use_rbac THEN - RETURN public.check_min_rights_legacy(min_right, user_id, COALESCE(org_id, v_effective_org_id), app_id, channel_id); - END IF; - - IF channel_id IS NOT NULL THEN - v_scope := public.rbac_scope_channel(); - ELSIF app_id IS NOT NULL THEN - v_scope := public.rbac_scope_app(); - ELSE - v_scope := public.rbac_scope_org(); - END IF; - - v_perm := public.rbac_permission_for_legacy(min_right, v_scope); - - IF user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), user_id, v_perm, v_effective_org_id, app_id, channel_id); - END IF; - - -- Also consider apikey principal when RBAC is enabled (API keys can hold roles directly). - IF NOT v_allowed THEN - SELECT public.get_apikey_header() INTO v_apikey; - IF v_apikey IS NOT NULL THEN - -- Enforce org/app scoping before using the apikey RBAC principal. - SELECT * FROM public.find_apikey_by_value(v_apikey) INTO api_key; - IF api_key.id IS NOT NULL THEN - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id, 'org_id', v_effective_org_id, 'app_id', app_id)); - ELSIF v_effective_org_id IS NULL THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_NO_ORG', jsonb_build_object('app_id', app_id)); - ELSIF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 AND NOT (v_effective_org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_ORG_RESTRICT', jsonb_build_object('org_id', v_effective_org_id, 'app_id', app_id)); - ELSIF app_id IS NOT NULL AND api_key.limited_to_apps IS DISTINCT FROM '{}' AND NOT (app_id = ANY(api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_APP_RESTRICT', jsonb_build_object('org_id', v_effective_org_id, 'app_id', app_id)); - ELSE - v_apikey_principal := api_key.rbac_id; - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, v_perm, v_effective_org_id, app_id, channel_id); - END IF; - END IF; - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_RBAC', jsonb_build_object('org_id', COALESCE(org_id, v_effective_org_id), 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id, 'scope', v_scope, 'perm', v_perm)); - END IF; - - RETURN v_allowed; -END; -$$; - --- Reaffirm execute grants for functions used by RLS and API key flows. -GRANT EXECUTE ON FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") TO "service_role"; - -GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") TO "service_role"; diff --git a/supabase/migrations/20260203201308_rbac_org_member_no_app_access.sql b/supabase/migrations/20260203201308_rbac_org_member_no_app_access.sql deleted file mode 100644 index 544b5dea1b..0000000000 --- a/supabase/migrations/20260203201308_rbac_org_member_no_app_access.sql +++ /dev/null @@ -1,320 +0,0 @@ --- Remove app/channel/bundle permissions from org_member role -DO $$ -DECLARE - v_role_id uuid; -BEGIN - SELECT id INTO v_role_id - FROM public.roles - WHERE name = public.rbac_role_org_member() - LIMIT 1; - - IF v_role_id IS NULL THEN - RAISE NOTICE 'org_member role not found, skipping permission cleanup'; - RETURN; - END IF; - - DELETE FROM public.role_permissions rp - USING public.permissions p - WHERE rp.role_id = v_role_id - AND rp.permission_id = p.id - AND p.scope_type IN ( - public.rbac_scope_app(), - public.rbac_scope_bundle(), - public.rbac_scope_channel() - ); - - UPDATE public.roles - SET description = 'Basic org member: org-only access' - WHERE name = public.rbac_role_org_member(); -END $$; - --- Prevent admin privilege escalation when RBAC is enabled -CREATE OR REPLACE FUNCTION public.check_org_user_privileges() RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' -AS $$ -DECLARE - v_is_super_admin boolean := false; - v_use_rbac boolean := false; - v_enforcing_2fa boolean := false; -BEGIN - -- Allow service_role / postgres to bypass - IF (((SELECT auth.jwt() ->> 'role') = 'service_role') OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres')) THEN - RETURN NEW; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(NEW.org_id); - - IF v_use_rbac THEN - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - JOIN public.roles r ON r.id = rb.role_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = auth.uid() - AND ( - (rb.scope_type = public.rbac_scope_org() - AND rb.org_id = NEW.org_id - AND r.name = public.rbac_role_org_super_admin()) - OR - (rb.scope_type = public.rbac_scope_platform() - AND r.name = public.rbac_role_platform_super_admin()) - ) - ) INTO v_is_super_admin; - - IF v_is_super_admin THEN - SELECT enforcing_2fa INTO v_enforcing_2fa - FROM public.orgs - WHERE id = NEW.org_id; - - IF v_enforcing_2fa AND NOT public.has_2fa_enabled(auth.uid()) THEN - PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); - v_is_super_admin := false; - END IF; - END IF; - ELSE - v_is_super_admin := public.check_min_rights( - 'super_admin'::public.user_min_right, - (SELECT auth.uid()), - NEW.org_id, - NULL::character varying, - NULL::bigint - ); - END IF; - - IF v_is_super_admin THEN - RETURN NEW; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'super_admin'::public.user_min_right THEN - PERFORM public.pg_log('deny: ELEVATE_SUPER_ADMIN', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'invite_super_admin'::public.user_min_right THEN - PERFORM public.pg_log('deny: ELEVATE_INVITE_SUPER_ADMIN', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - RETURN NEW; -END; -$$; - --- Support hashed keys and RBAC fallback for app access checks -CREATE OR REPLACE FUNCTION public.has_app_right_apikey( - "appid" character varying, - "right" public.user_min_right, - "userid" uuid, - "apikey" text -) RETURNS boolean -LANGUAGE plpgsql SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - org_id uuid; - api_key record; - allowed boolean := false; - use_rbac boolean; - perm_key text; - has_apikey_roles boolean := false; -BEGIN - org_id := public.get_user_main_org_id_by_app_id("appid"); - use_rbac := public.rbac_is_enabled_for_org(org_id); - - -- Support both plain and hashed keys - SELECT * FROM public.find_apikey_by_value("apikey") INTO api_key; - - IF api_key.id IS NULL THEN - PERFORM public.pg_log('deny: INVALID_APIKEY', jsonb_build_object('appid', "appid")); - RETURN false; - END IF; - - IF api_key.user_id IS DISTINCT FROM "userid" THEN - PERFORM public.pg_log('deny: USERID_MISMATCH', jsonb_build_object('appid', "appid", 'org_id', org_id, 'apikey_id', api_key.id, 'userid', "userid", 'apikey_user_id', api_key.user_id)); - RETURN false; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: APIKEY_EXPIRED', jsonb_build_object('appid', "appid", 'org_id', org_id, 'apikey_id', api_key.id)); - RETURN false; - END IF; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: APIKEY_ORG_RESTRICT', jsonb_build_object('org_id', org_id, 'appid', "appid")); - RETURN false; - END IF; - END IF; - - IF api_key.limited_to_apps IS DISTINCT FROM '{}' THEN - IF NOT ("appid" = ANY(api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: APIKEY_APP_RESTRICT', jsonb_build_object('appid', "appid")); - RETURN false; - END IF; - END IF; - - IF use_rbac THEN - perm_key := public.rbac_permission_for_legacy("right", public.rbac_scope_app()); - - IF api_key.rbac_id IS NOT NULL THEN - allowed := public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, perm_key, org_id, "appid", NULL::bigint); - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = api_key.rbac_id - ) INTO has_apikey_roles; - END IF; - - -- Compatibility: if no RBAC bindings exist for the key, fall back to legacy rights - IF NOT allowed AND NOT has_apikey_roles THEN - allowed := public.check_min_rights("right", "userid", org_id, "appid", NULL::bigint); - END IF; - ELSE - allowed := public.check_min_rights("right", "userid", org_id, "appid", NULL::bigint); - END IF; - - IF NOT allowed THEN - PERFORM public.pg_log('deny: HAS_APP_RIGHT_APIKEY', jsonb_build_object('appid', "appid", 'org_id', org_id, 'right', "right"::text, 'userid', "userid", 'rbac', use_rbac)); - END IF; - RETURN allowed; -END; -$$; - --- Ensure super_admin invites require super_admin role even in RBAC mode -CREATE OR REPLACE FUNCTION public.invite_user_to_org( - "email" character varying, - "org_id" uuid, - "invite_type" public.user_min_right -) RETURNS character varying -LANGUAGE plpgsql SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - org record; - invited_user record; - current_record record; - current_tmp_user record; - calling_user_id uuid; - v_is_super_admin boolean := false; - v_use_rbac boolean := false; -BEGIN - -- Get the calling user's ID - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id) - INTO calling_user_id; - - -- Check if org exists - SELECT * INTO org FROM public.orgs WHERE public.orgs.id=invite_user_to_org.org_id; - IF org IS NULL THEN - RETURN 'NO_ORG'; - END IF; - - -- Check if user has at least public.rbac_right_admin() rights - IF NOT public.check_min_rights(public.rbac_right_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN - PERFORM public.pg_log('deny: NO_RIGHTS_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type)); - RETURN 'NO_RIGHTS'; - END IF; - - -- If inviting as super_admin, caller must be super_admin - IF (invite_type = public.rbac_right_super_admin()::public.user_min_right OR invite_type = public.rbac_right_invite_super_admin()::public.user_min_right) THEN - v_use_rbac := public.rbac_is_enabled_for_org(invite_user_to_org.org_id); - - IF v_use_rbac THEN - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - JOIN public.roles r ON r.id = rb.role_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = calling_user_id - AND ( - (rb.scope_type = public.rbac_scope_org() - AND rb.org_id = invite_user_to_org.org_id - AND r.name = public.rbac_role_org_super_admin()) - OR - (rb.scope_type = public.rbac_scope_platform() - AND r.name = public.rbac_role_platform_super_admin()) - ) - ) INTO v_is_super_admin; - - IF NOT v_is_super_admin THEN - PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type)); - RETURN 'NO_RIGHTS'; - END IF; - - IF org.enforcing_2fa AND NOT public.has_2fa_enabled(auth.uid()) THEN - PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type, 'uid', auth.uid())); - RETURN 'NO_RIGHTS'; - END IF; - ELSE - IF NOT public.check_min_rights(public.rbac_right_super_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN - PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type)); - RETURN 'NO_RIGHTS'; - END IF; - END IF; - END IF; - - -- Check if user already exists - SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email=invite_user_to_org.email; - - IF invited_user IS NOT NULL THEN - -- User exists, check if already in org - SELECT public.org_users.id INTO current_record - FROM public.org_users - WHERE public.org_users.user_id=invited_user.id - AND public.org_users.org_id=invite_user_to_org.org_id; - - IF current_record IS NOT NULL THEN - RETURN 'ALREADY_INVITED'; - ELSE - -- Add user to org - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - RETURN 'OK'; - END IF; - ELSE - -- User doesn't exist, check tmp_users for pending invitations - SELECT * INTO current_tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email=invite_user_to_org.email - AND public.tmp_users.org_id=invite_user_to_org.org_id; - - IF current_tmp_user IS NOT NULL THEN - -- Invitation already exists - IF current_tmp_user.cancelled_at IS NOT NULL THEN - -- Invitation was cancelled, check if recent - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - -- No invitation exists, need to create one (handled elsewhere) - RETURN 'NO_EMAIL'; - END IF; - END IF; -END; -$$; - --- Fix apps table INSERT RLS policy to check org-level permissions --- Original bug: checked app-level permissions but app_id doesn't exist during INSERT --- Solution: Check org-level 'write' permission which admins/super_admins have - -DROP POLICY IF EXISTS "Allow insert for apikey (write,all) (admin+)" ON public.apps; - -CREATE POLICY "Allow insert for apikey (write,all) (admin+)" ON public.apps -FOR INSERT TO anon, authenticated -WITH CHECK ( - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - owner_org - ), - owner_org, - NULL::character varying, -- NULL for org-level check - NULL::bigint - ) -); diff --git a/supabase/migrations/20260204100000_restore_audit_logs_apikey.sql b/supabase/migrations/20260204100000_restore_audit_logs_apikey.sql deleted file mode 100644 index e4e02e2576..0000000000 --- a/supabase/migrations/20260204100000_restore_audit_logs_apikey.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Restore audit_logs read access for API key requests (anon role) --- Keep a single SELECT policy while allowing both authenticated users and API keys. - -GRANT SELECT ON TABLE public.audit_logs TO anon; - -DROP POLICY IF EXISTS "Allow select for auth (super_admin+)" ON public.audit_logs; -DROP POLICY IF EXISTS "Allow select for auth, api keys (super_admin+)" ON public.audit_logs; - -CREATE POLICY "Allow select for auth, api keys (super_admin+)" ON public.audit_logs -FOR SELECT TO anon, authenticated -USING ( - public.check_min_rights( - 'super_admin'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], org_id - ), - org_id, - NULL::character varying, - NULL::bigint - ) -); diff --git a/supabase/migrations/20260204103000_mfa_email_otp_guard.sql b/supabase/migrations/20260204103000_mfa_email_otp_guard.sql deleted file mode 100644 index b7f047661c..0000000000 --- a/supabase/migrations/20260204103000_mfa_email_otp_guard.sql +++ /dev/null @@ -1,184 +0,0 @@ --- ============================================================================ --- Email OTP verification guard for MFA enrollment (unsupported supabase hack) --- ============================================================================ - --- ============================================================================ --- Section 1: Security settings (compatibility cutoff) --- ============================================================================ - -CREATE TABLE IF NOT EXISTS public.security_settings ( - id boolean PRIMARY KEY DEFAULT true, - mfa_email_otp_enforced_at timestamptz NOT NULL DEFAULT NOW() -); - -COMMENT ON TABLE public.security_settings IS -'Singleton settings table for security feature cutovers'; - -INSERT INTO public.security_settings (id, mfa_email_otp_enforced_at) -VALUES (true, NOW()) -ON CONFLICT (id) DO NOTHING; - --- ============================================================================ --- Section 2: User security table for OTP verification tracking --- ============================================================================ - -CREATE TABLE IF NOT EXISTS public.user_security ( - user_id uuid PRIMARY KEY REFERENCES auth.users (id) ON DELETE CASCADE, - email_otp_verified_at timestamptz NULL, - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW() -); - -COMMENT ON TABLE public.user_security IS -'Tracks email OTP verification state used to gate MFA enrollment'; -COMMENT ON COLUMN public.user_security.email_otp_verified_at IS -'Last successful email OTP verification used for MFA enrollment'; - -ALTER TABLE public.user_security ENABLE ROW LEVEL SECURITY; - -CREATE POLICY users_can_read_own_security_status -ON public.user_security -FOR SELECT -TO authenticated -USING (user_id = (SELECT auth.uid())); - -GRANT SELECT ON public.user_security TO authenticated; -GRANT ALL ON public.user_security TO service_role; -GRANT ALL ON public.user_security TO postgres; - --- ============================================================================ --- Section 3: Record OTP verification (server-side timestamp) --- ============================================================================ - -CREATE OR REPLACE FUNCTION public.record_email_otp_verified() -RETURNS timestamptz -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; - v_now timestamptz; -BEGIN - SELECT auth.uid() INTO v_user_id; - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'authentication required'; - END IF; - - v_now := NOW(); - - INSERT INTO public.user_security ( - user_id, - email_otp_verified_at, - created_at, - updated_at - ) - VALUES (v_user_id, v_now, v_now, v_now) - ON CONFLICT (user_id) DO UPDATE - SET - email_otp_verified_at = EXCLUDED.email_otp_verified_at, - updated_at = EXCLUDED.updated_at; - - RETURN v_now; -END; -$$; - -ALTER FUNCTION public.record_email_otp_verified() OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.record_email_otp_verified() TO postgres; -GRANT EXECUTE ON FUNCTION public.record_email_otp_verified() TO service_role; -GRANT EXECUTE ON FUNCTION public.record_email_otp_verified() TO authenticated; - --- ============================================================================ --- Section 4: Helper function to check OTP verification freshness --- ============================================================================ - -CREATE OR REPLACE FUNCTION public.is_recent_email_otp_verified( - p_user_id uuid -) -RETURNS boolean -LANGUAGE plpgsql -STABLE -SET search_path = '' -AS $$ -DECLARE - verified_at timestamptz; -BEGIN - SELECT public.user_security.email_otp_verified_at - INTO verified_at - FROM public.user_security - WHERE public.user_security.user_id = p_user_id; - - RETURN verified_at IS NOT NULL - AND verified_at > (NOW() - INTERVAL '1 hour'); -END; -$$; - -ALTER FUNCTION public.is_recent_email_otp_verified(uuid) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.is_recent_email_otp_verified(uuid) TO postgres; -GRANT EXECUTE ON FUNCTION public.is_recent_email_otp_verified( - uuid -) TO service_role; - --- ============================================================================ --- Section 5: Trigger to block MFA enrollment without recent OTP verification --- ============================================================================ - -DO $$ -BEGIN - BEGIN - EXECUTE $authfn$ - CREATE OR REPLACE FUNCTION "auth"."enforce_email_otp_for_mfa"() RETURNS trigger - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $body$ - DECLARE - otp_ok boolean; - enforced_at timestamptz; - user_created_at timestamptz; - BEGIN - SELECT public.security_settings.mfa_email_otp_enforced_at - INTO enforced_at - FROM public.security_settings - WHERE public.security_settings.id = true; - - IF enforced_at IS NOT NULL THEN - SELECT auth.users.created_at - INTO user_created_at - FROM auth.users - WHERE auth.users.id = NEW.user_id; - - IF user_created_at IS NOT NULL AND user_created_at < enforced_at THEN - RETURN NEW; - END IF; - END IF; - - IF TG_OP = 'INSERT' THEN - otp_ok := public.is_recent_email_otp_verified(NEW.user_id); - IF NOT otp_ok THEN - RAISE EXCEPTION 'email otp verification required for mfa enrollment'; - END IF; - RETURN NEW; - END IF; - - IF TG_OP = 'UPDATE' - AND (NEW.status IS DISTINCT FROM OLD.status) - AND NEW.status = 'verified' THEN - otp_ok := public.is_recent_email_otp_verified(NEW.user_id); - IF NOT otp_ok THEN - RAISE EXCEPTION 'email otp verification required for mfa enrollment'; - END IF; - END IF; - - RETURN NEW; - END; - $body$; - $authfn$; - - EXECUTE 'ALTER FUNCTION "auth"."enforce_email_otp_for_mfa"() OWNER TO "postgres"'; - EXECUTE 'DROP TRIGGER IF EXISTS "trg_enforce_email_otp_for_mfa" ON auth.mfa_factors'; - EXECUTE 'CREATE TRIGGER "trg_enforce_email_otp_for_mfa" BEFORE INSERT OR UPDATE ON auth.mfa_factors FOR EACH ROW EXECUTE FUNCTION auth.enforce_email_otp_for_mfa()'; - EXCEPTION - WHEN insufficient_privilege THEN - RAISE NOTICE 'Skipping auth.mfa_factors trigger setup (insufficient privileges)'; - END; -END $$; diff --git a/supabase/migrations/20260204103001_enable_security_settings_rls.sql b/supabase/migrations/20260204103001_enable_security_settings_rls.sql deleted file mode 100644 index 03c308252a..0000000000 --- a/supabase/migrations/20260204103001_enable_security_settings_rls.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Enable RLS on singleton security settings table -ALTER TABLE IF EXISTS public.security_settings ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Deny access to security settings" -ON public.security_settings -FOR ALL -TO authenticated, anon -USING (false) -WITH CHECK (false); diff --git a/supabase/migrations/20260204181424_add_channel_permission_overrides.sql b/supabase/migrations/20260204181424_add_channel_permission_overrides.sql deleted file mode 100644 index 590940177e..0000000000 --- a/supabase/migrations/20260204181424_add_channel_permission_overrides.sql +++ /dev/null @@ -1,360 +0,0 @@ --- Channel permission overrides (delta-only) -CREATE TABLE IF NOT EXISTS public.channel_permission_overrides ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - principal_type text NOT NULL CHECK (principal_type IN ( - public.rbac_principal_user(), - public.rbac_principal_group(), - public.rbac_principal_apikey() - )), - principal_id uuid NOT NULL, - channel_id bigint NOT NULL REFERENCES public.channels ( - id - ) ON DELETE CASCADE, - permission_key text NOT NULL REFERENCES public.permissions ( - key - ) ON DELETE CASCADE, - is_allowed boolean NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() -); - -COMMENT ON TABLE public.channel_permission_overrides IS 'Delta-only overrides for channel-scoped permissions (user > group, deny > allow).'; -COMMENT ON COLUMN public.channel_permission_overrides.principal_type IS 'user | group | apikey.'; -COMMENT ON COLUMN public.channel_permission_overrides.principal_id IS 'users.id, groups.id, or apikeys.rbac_id depending on principal_type.'; -COMMENT ON COLUMN public.channel_permission_overrides.channel_id IS 'public.channels.id target for the override.'; -COMMENT ON COLUMN public.channel_permission_overrides.permission_key IS 'RBAC permission key (channel.*).'; - -CREATE UNIQUE INDEX IF NOT EXISTS channel_permission_overrides_unique -ON public.channel_permission_overrides ( - principal_type, principal_id, channel_id, permission_key -); - -CREATE INDEX IF NOT EXISTS channel_permission_overrides_channel_idx -ON public.channel_permission_overrides (channel_id); - -CREATE INDEX IF NOT EXISTS channel_permission_overrides_principal_idx -ON public.channel_permission_overrides (principal_type, principal_id); - -CREATE INDEX IF NOT EXISTS channel_permission_overrides_permission_idx -ON public.channel_permission_overrides (permission_key); - -ALTER TABLE public.channel_permission_overrides ENABLE ROW LEVEL SECURITY; - -CREATE POLICY channel_permission_overrides_admin_select ON public.channel_permission_overrides -FOR SELECT -TO authenticated -USING ( - EXISTS ( - SELECT 1 - FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.id = channel_permission_overrides.channel_id - AND public.rbac_check_permission( - public.rbac_perm_app_update_user_roles(), - apps.owner_org, - apps.app_id, - NULL::bigint - ) - ) -); - -CREATE POLICY channel_permission_overrides_admin_write ON public.channel_permission_overrides -FOR ALL -TO authenticated -USING ( - EXISTS ( - SELECT 1 - FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.id = channel_permission_overrides.channel_id - AND public.rbac_check_permission( - public.rbac_perm_app_update_user_roles(), - apps.owner_org, - apps.app_id, - NULL::bigint - ) - ) -) -WITH CHECK ( - EXISTS ( - SELECT 1 - FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.id = channel_permission_overrides.channel_id - AND public.rbac_check_permission( - public.rbac_perm_app_update_user_roles(), - apps.owner_org, - apps.app_id, - NULL::bigint - ) - ) -); - --- Extend app_uploader defaults to channel-level permissions -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT - r.id, - p.id -FROM public.roles AS r -INNER JOIN public.permissions AS p - ON p.key IN ( - public.rbac_perm_channel_read(), - public.rbac_perm_channel_read_history(), - public.rbac_perm_channel_promote_bundle() - ) -WHERE r.name = public.rbac_role_app_uploader() -ON CONFLICT DO NOTHING; - --- Apply channel overrides in RBAC permission checks -CREATE OR REPLACE FUNCTION public.rbac_check_permission_direct( - p_permission_key text, - p_user_id uuid, - p_org_id uuid, - p_app_id character varying, - p_channel_id bigint, - p_apikey text DEFAULT NULL -) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_effective_user_id uuid := p_user_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; - v_override boolean; - v_channel_scope boolean := false; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; - v_apikey_user_id uuid; -BEGIN - -- Validate permission key - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - IF p_channel_id IS NOT NULL AND p_permission_key LIKE 'channel.%' THEN - v_channel_scope := true; - END IF; - - -- Derive org from app/channel when not provided - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF v_effective_org_id IS NULL AND p_channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - END IF; - - -- Resolve API key once, caching both user_id and rbac_id for reuse below. - IF p_apikey IS NOT NULL THEN - SELECT user_id, rbac_id INTO v_apikey_user_id, v_apikey_principal - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - END IF; - - -- Resolve user from API key when not provided directly. - IF v_effective_user_id IS NULL AND v_apikey_user_id IS NOT NULL THEN - v_effective_user_id := v_apikey_user_id; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(v_effective_user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Check if RBAC is enabled for this org - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - -- RBAC path: Check user permission directly - IF p_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), p_user_id, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - - IF v_channel_scope THEN - -- Direct user override - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_user() - AND o.principal_id = p_user_id - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - ELSE - -- Group overrides (deny > allow) - IF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = p_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = false - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := false; - ELSIF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = p_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = true - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := true; - END IF; - END IF; - END IF; - END IF; - - -- If user doesn't have permission, check apikey permission - IF NOT v_allowed AND v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_apikey() - AND o.principal_id = v_apikey_principal - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', p_user_id, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - ELSE - -- Legacy path: Map permission to min_right and use legacy check - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', p_user_id - )); - RETURN false; - END IF; - - IF p_apikey IS NOT NULL AND p_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, p_user_id, p_apikey); - ELSIF p_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(p_app_id, v_legacy_right, p_user_id); - ELSE - RETURN public.check_min_rights_legacy(v_legacy_right, p_user_id, v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - -COMMENT ON FUNCTION public.rbac_check_permission_direct( - text, uuid, uuid, character varying, bigint, text -) IS -'Direct RBAC permission check with automatic legacy fallback based on org feature flag. Uses channel overrides when present.'; - --- Atomically delete a group and all its role_bindings in a single server-side call. -CREATE OR REPLACE FUNCTION public.delete_group_with_bindings(group_id uuid) -RETURNS void -LANGUAGE plpgsql -SET search_path = '' -SECURITY DEFINER AS $$ -DECLARE - v_org_id uuid; -BEGIN - -- Verify group exists and caller has org.update_user_roles permission. - SELECT org_id INTO v_org_id - FROM public.groups - WHERE id = group_id; - - IF v_org_id IS NULL THEN - RAISE EXCEPTION 'Group not found' USING ERRCODE = 'P0002'; - END IF; - - IF NOT public.rbac_check_permission_direct( - public.rbac_perm_org_update_user_roles(), - auth.uid(), - v_org_id, - NULL::varchar, - NULL::bigint - ) THEN - RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501'; - END IF; - - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_group() - AND principal_id = group_id; - - - -- Clean up channel permission overrides for this group - DELETE FROM public.channel_permission_overrides - WHERE principal_type = public.rbac_principal_group() - AND principal_id = group_id; - DELETE FROM public.groups - WHERE id = group_id; -END; -$$; - -COMMENT ON FUNCTION public.delete_group_with_bindings(uuid) IS -'Atomically deletes a group and all its role bindings. Requires org.update_user_roles permission.'; diff --git a/supabase/migrations/20260205031305_mfa_email_otp_hardening.sql b/supabase/migrations/20260205031305_mfa_email_otp_hardening.sql deleted file mode 100644 index 993b4c28d6..0000000000 --- a/supabase/migrations/20260205031305_mfa_email_otp_hardening.sql +++ /dev/null @@ -1,41 +0,0 @@ --- ========================================================================== --- Harden email OTP verification record to require OTP-authenticated session --- ========================================================================== - -CREATE OR REPLACE FUNCTION "public"."record_email_otp_verified"() RETURNS timestamptz -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_user_id uuid; - v_now timestamptz; -BEGIN - SELECT auth.uid() INTO v_user_id; - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'authentication required'; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM jsonb_array_elements(coalesce((SELECT auth.jwt())->'amr', '[]'::jsonb)) AS amr_elem - WHERE amr_elem->>'method' = 'otp' - ) THEN - RAISE EXCEPTION 'otp authentication required'; - END IF; - - v_now := NOW(); - - INSERT INTO public.user_security (user_id, email_otp_verified_at, created_at, updated_at) - VALUES (v_user_id, v_now, v_now, v_now) - ON CONFLICT (user_id) DO UPDATE - SET email_otp_verified_at = EXCLUDED.email_otp_verified_at, - updated_at = EXCLUDED.updated_at; - - RETURN v_now; -END; -$$; - -ALTER FUNCTION "public"."record_email_otp_verified"() OWNER TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."record_email_otp_verified"() TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."record_email_otp_verified"() TO "service_role"; -GRANT EXECUTE ON FUNCTION "public"."record_email_otp_verified"() TO "authenticated"; diff --git a/supabase/migrations/20260205120000_fix_audit_logs_select_rls.sql b/supabase/migrations/20260205120000_fix_audit_logs_select_rls.sql deleted file mode 100644 index cb1c4c5628..0000000000 --- a/supabase/migrations/20260205120000_fix_audit_logs_select_rls.sql +++ /dev/null @@ -1,155 +0,0 @@ --- Fix audit_logs unfiltered SELECT timeouts by avoiding per-row identity resolution. --- The previous policy called get_identity_org_allowed(keymode, org_id) per row, which: --- - parses request headers per row --- - queries apikeys per row --- - logs deny messages per row when no API key is provided --- On large tables this forces a slow scan and can saturate the DB under load. - --- Compute the list of org_ids the current request can read audit logs for once per statement, --- then use a simple index-friendly predicate: org_id = ANY(...) -CREATE OR REPLACE FUNCTION "public"."audit_logs_allowed_orgs"() -RETURNS "uuid"[] -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; - v_allowed uuid[] := '{}'::uuid[]; - v_org_id uuid; - v_use_rbac boolean; - v_perm text := public.rbac_permission_for_legacy( - public.rbac_right_super_admin(), - public.rbac_scope_org() - ); - v_enforcing_2fa boolean; -BEGIN - SELECT auth.uid() INTO v_user_id; - - -- If no authenticated user, attempt Capgo API key auth (capgkey header). - IF v_user_id IS NULL THEN - SELECT public.get_apikey_header() INTO v_api_key_text; - IF v_api_key_text IS NULL THEN - RETURN v_allowed; - END IF; - - SELECT * FROM public.find_apikey_by_value(v_api_key_text) INTO v_api_key; - IF v_api_key.id IS NULL THEN - RETURN v_allowed; - END IF; - - IF NOT (v_api_key.mode = ANY('{read,upload,write,all}'::public.key_mode[])) THEN - RETURN v_allowed; - END IF; - - IF public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN v_allowed; - END IF; - - v_user_id := v_api_key.user_id; - END IF; - - -- Collect candidate orgs from legacy + RBAC bindings. - FOR v_org_id IN - SELECT DISTINCT org_id - FROM ( - SELECT ou.org_id - FROM public.org_users ou - WHERE ou.user_id = v_user_id - AND ou.org_id IS NOT NULL - AND ou.app_id IS NULL - AND ou.channel_id IS NULL - UNION - SELECT rb.org_id - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_user_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - UNION - SELECT rb.org_id - FROM public.role_bindings rb - WHERE v_api_key.rbac_id IS NOT NULL - AND rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = v_api_key.rbac_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - ) candidates - LOOP - -- Enforce API key org restrictions (if present). - IF v_api_key.id IS NOT NULL - AND COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 - AND NOT (v_org_id = ANY(v_api_key.limited_to_orgs)) - THEN - CONTINUE; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(v_org_id); - - IF NOT v_use_rbac THEN - -- Legacy rights (also enforces org 2FA + password policy). - IF public.check_min_rights_legacy( - 'super_admin'::public.user_min_right, - v_user_id, - v_org_id, - NULL::character varying, - NULL::bigint - ) THEN - v_allowed := array_append(v_allowed, v_org_id); - END IF; - ELSE - -- Mirror check_min_rights() org gating for RBAC orgs (2FA + password policy). - SELECT o.enforcing_2fa INTO v_enforcing_2fa - FROM public.orgs o - WHERE o.id = v_org_id; - - IF v_enforcing_2fa = true AND NOT public.has_2fa_enabled(v_user_id) THEN - CONTINUE; - END IF; - - IF NOT public.user_meets_password_policy(v_user_id, v_org_id) THEN - CONTINUE; - END IF; - - -- Allow if the user or the API key principal has the required RBAC permission. - IF public.rbac_has_permission( - public.rbac_principal_user(), - v_user_id, - v_perm, - v_org_id, - NULL::character varying, - NULL::bigint - ) THEN - v_allowed := array_append(v_allowed, v_org_id); - ELSIF v_api_key.id IS NOT NULL - AND v_api_key.rbac_id IS NOT NULL - AND public.rbac_has_permission( - public.rbac_principal_apikey(), - v_api_key.rbac_id, - v_perm, - v_org_id, - NULL::character varying, - NULL::bigint - ) - THEN - v_allowed := array_append(v_allowed, v_org_id); - END IF; - END IF; - END LOOP; - - RETURN v_allowed; -END; -$$; - -DROP POLICY IF EXISTS "Allow select for auth, api keys (super_admin+)" ON "public"."audit_logs"; - -CREATE POLICY "Allow select for auth, api keys (super_admin+)" ON "public"."audit_logs" -FOR SELECT TO "anon", "authenticated" -USING ( - "org_id" = ANY("public"."audit_logs_allowed_orgs"()) -); - --- RLS policies execute functions as the caller; grant EXECUTE explicitly (default privileges were revoked). -GRANT EXECUTE ON FUNCTION "public"."audit_logs_allowed_orgs"() TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."audit_logs_allowed_orgs"() TO "authenticated"; diff --git a/supabase/migrations/20260206120000_apikey_server_generation.sql b/supabase/migrations/20260206120000_apikey_server_generation.sql deleted file mode 100644 index 193749d2f6..0000000000 --- a/supabase/migrations/20260206120000_apikey_server_generation.sql +++ /dev/null @@ -1,261 +0,0 @@ -ALTER TABLE public.apikeys -ALTER COLUMN key DROP DEFAULT; - -DO $$ -BEGIN - UPDATE public.apikeys - SET key = gen_random_uuid()::text - WHERE key IS NULL AND key_hash IS NULL; - - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'apikeys_key_or_hash' - ) THEN - ALTER TABLE public.apikeys - ADD CONSTRAINT apikeys_key_or_hash - CHECK (key IS NOT NULL OR key_hash IS NOT NULL); - END IF; -END; -$$; - -CREATE OR REPLACE FUNCTION public.apikeys_force_server_key() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_plain_key text; - v_is_hashed boolean; -BEGIN - IF pg_trigger_depth() > 1 THEN - RETURN NEW; - END IF; - - IF current_setting('capgo.skip_apikey_trigger', true) = 'true' THEN - RETURN NEW; - END IF; - - -- SECURITY DEFINER makes current_user the function owner, so use session_user to detect the caller. - IF session_user IN ('postgres', 'service_role', 'supabase_admin', 'supabase_auth_admin', 'supabase_storage_admin', 'supabase_realtime_admin') THEN - RETURN NEW; - END IF; - - IF TG_OP = 'UPDATE' THEN - -- Allow callers to force regeneration even if they mistakenly re-submit the same value. - -- This is primarily useful for controlled internal operations; normal API flows always - -- write a different placeholder value. - IF current_setting('capgo.force_regenerate_apikey', true) IS DISTINCT FROM 'true' - AND NEW.key IS NOT DISTINCT FROM OLD.key - AND NEW.key_hash IS NOT DISTINCT FROM OLD.key_hash THEN - RETURN NEW; - END IF; - v_is_hashed := (OLD.key_hash IS NOT NULL AND OLD.key IS NULL) OR NEW.key_hash IS NOT NULL; - ELSE - v_is_hashed := NEW.key_hash IS NOT NULL; - END IF; - - v_plain_key := gen_random_uuid()::text; - - IF v_is_hashed THEN - NEW.key_hash := encode(extensions.digest(v_plain_key, 'sha256'), 'hex'); - NEW.key := v_plain_key; - ELSE - NEW.key := v_plain_key; - NEW.key_hash := NULL; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.apikeys_force_server_key() OWNER TO postgres; - -CREATE OR REPLACE FUNCTION public.apikeys_strip_plain_key_for_hashed() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - IF pg_trigger_depth() > 1 THEN - RETURN NULL; - END IF; - - IF current_setting('capgo.skip_apikey_trigger', true) = 'true' THEN - RETURN NULL; - END IF; - - IF NEW.key_hash IS NOT NULL AND NEW.key IS NOT NULL THEN - UPDATE public.apikeys - SET key = NULL - WHERE id = NEW.id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION public.apikeys_strip_plain_key_for_hashed() OWNER TO postgres; - -DROP TRIGGER IF EXISTS apikeys_force_server_key ON public.apikeys; -CREATE TRIGGER apikeys_force_server_key -BEFORE INSERT OR UPDATE ON public.apikeys -FOR EACH ROW -EXECUTE FUNCTION public.apikeys_force_server_key(); - -DROP TRIGGER IF EXISTS apikeys_strip_plain_key_for_hashed ON public.apikeys; -CREATE CONSTRAINT TRIGGER apikeys_strip_plain_key_for_hashed -AFTER INSERT OR UPDATE ON public.apikeys -DEFERRABLE INITIALLY DEFERRED -FOR EACH ROW -EXECUTE FUNCTION public.apikeys_strip_plain_key_for_hashed(); - --- Internal functions that accept a user_id are intentionally not granted to anon/authenticated. --- Public wrappers below derive the caller identity (supports both JWT and capgkey-based auth). -CREATE OR REPLACE FUNCTION public.create_hashed_apikey_for_user( - p_user_id uuid, - p_mode public.key_mode, - p_name text, - p_limited_to_orgs uuid [], - p_limited_to_apps text [], - p_expires_at timestamptz -) -RETURNS public.apikeys -LANGUAGE plpgsql -SECURITY INVOKER -SET search_path = '' -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; -$$; - -CREATE OR REPLACE FUNCTION public.regenerate_hashed_apikey_for_user( - p_apikey_id bigint, - p_user_id uuid -) -RETURNS public.apikeys -LANGUAGE plpgsql -SECURITY INVOKER -SET search_path = '' -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); - - UPDATE public.apikeys - SET key = NULL, - key_hash = encode(extensions.digest(v_plain_key, 'sha256'), 'hex') - WHERE id = p_apikey_id - AND user_id = p_user_id - RETURNING * INTO v_apikey; - - IF NOT FOUND THEN - RAISE EXCEPTION 'apikey_not_found' - USING ERRCODE = 'P0002'; - END IF; - - v_apikey.key := v_plain_key; - - RETURN v_apikey; -END; -$$; - -CREATE OR REPLACE FUNCTION public.create_hashed_apikey( - p_mode public.key_mode, - p_name text, - p_limited_to_orgs uuid [], - p_limited_to_apps text [], - p_expires_at timestamptz -) -RETURNS public.apikeys -LANGUAGE plpgsql -SECURITY INVOKER -SET search_path = '' -AS $$ - DECLARE - v_user_id uuid; - BEGIN - -- Use the key_mode-aware identity function so this RPC works for both JWT auth - -- (role: authenticated) and API key auth (role: anon + capgkey header). - 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.regenerate_hashed_apikey( - p_apikey_id bigint -) -RETURNS public.apikeys -LANGUAGE plpgsql -SECURITY INVOKER -SET search_path = '' -AS $$ - DECLARE - v_user_id uuid; - BEGIN - -- Use the key_mode-aware identity function so this RPC works for both JWT auth - -- (role: authenticated) and API key auth (role: anon + capgkey header). - 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.regenerate_hashed_apikey_for_user(p_apikey_id, v_user_id); -END; -$$; - -GRANT EXECUTE ON FUNCTION public.create_hashed_apikey( - public.key_mode, text, uuid [], text [], timestamptz -) TO anon, -authenticated; -GRANT EXECUTE ON FUNCTION public.regenerate_hashed_apikey(bigint) TO anon, -authenticated; diff --git a/supabase/migrations/20260206213247_org_has_usage_credits_flag.sql b/supabase/migrations/20260206213247_org_has_usage_credits_flag.sql deleted file mode 100644 index 764f53996a..0000000000 --- a/supabase/migrations/20260206213247_org_has_usage_credits_flag.sql +++ /dev/null @@ -1,135 +0,0 @@ -BEGIN; - --- Read replicas (PlanetScale subscriptions) replicate table data but not views/functions. --- The plugin read-path must not query usage_credit_* relations on replicas, so we store --- a replicated boolean on orgs indicating whether the org uses the credits system. - -ALTER TABLE public.orgs -ADD COLUMN IF NOT EXISTS has_usage_credits boolean NOT NULL DEFAULT false; - -COMMENT ON COLUMN public.orgs.has_usage_credits -IS 'Replicated flag: true when the org uses usage credits (top-up billing). Must be replica-safe for plugin endpoints.'; - --- Backfill immediately on primary DB. -UPDATE public.orgs AS o -SET - has_usage_credits = EXISTS( - SELECT 1 - FROM public.usage_credit_grants AS g - WHERE g.org_id = o.id - ) -WHERE o.has_usage_credits IS DISTINCT FROM EXISTS ( - SELECT 1 - FROM public.usage_credit_grants AS g - WHERE g.org_id = o.id -); - --- Ensure orgs without any grants are false (and avoid needless writes). -UPDATE public.orgs AS o -SET has_usage_credits = false -WHERE NOT EXISTS ( - SELECT 1 - FROM public.usage_credit_grants AS g - WHERE g.org_id = o.id -) -AND o.has_usage_credits IS DISTINCT FROM false; - -CREATE OR REPLACE FUNCTION public.refresh_orgs_has_usage_credits() -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- Update orgs that have at least one grant (credits mode enabled). - UPDATE "public"."orgs" AS o - SET "has_usage_credits" = true - WHERE EXISTS ( - SELECT 1 - FROM "public"."usage_credit_grants" AS g - WHERE g."org_id" = o."id" - ) - AND o."has_usage_credits" IS DISTINCT FROM true; - - -- Orgs without any grants should be false (fallback for edge cases). - UPDATE "public"."orgs" AS o - SET "has_usage_credits" = false - WHERE NOT EXISTS ( - SELECT 1 - FROM "public"."usage_credit_grants" AS g - WHERE g."org_id" = o."id" - ) - AND o."has_usage_credits" IS DISTINCT FROM false; -END; -$$; - -ALTER FUNCTION public.refresh_orgs_has_usage_credits() OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.refresh_orgs_has_usage_credits() FROM public; -GRANT EXECUTE ON FUNCTION public.refresh_orgs_has_usage_credits() TO service_role; - --- Keep the flag updated immediately when credits are granted/consumed/expired. --- This makes seed inserts and runtime credit changes replica-safe without relying on scheduled refresh. -CREATE OR REPLACE FUNCTION public.sync_org_has_usage_credits_from_grants() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- Keep it simple: usage_credit_grants writes are low-frequency and this must work - -- on all Postgres versions. Row-level trigger avoids transition table limitations. - UPDATE "public"."orgs" AS o - SET "has_usage_credits" = EXISTS ( - SELECT 1 - FROM "public"."usage_credit_grants" AS g - WHERE g."org_id" = COALESCE(NEW."org_id", OLD."org_id") - ) - WHERE o."id" = COALESCE(NEW."org_id", OLD."org_id") - AND o."has_usage_credits" IS DISTINCT FROM EXISTS ( - SELECT 1 - FROM "public"."usage_credit_grants" AS g - WHERE g."org_id" = COALESCE(NEW."org_id", OLD."org_id") - ); - - RETURN NULL; -END; -$$; - -ALTER FUNCTION public.sync_org_has_usage_credits_from_grants() OWNER TO "postgres"; - -DROP TRIGGER IF EXISTS trg_sync_org_has_usage_credits ON public.usage_credit_grants; -CREATE TRIGGER trg_sync_org_has_usage_credits -AFTER INSERT OR UPDATE OR DELETE ON public.usage_credit_grants -FOR EACH ROW -EXECUTE FUNCTION public.sync_org_has_usage_credits_from_grants(); - --- Run daily after credits expiry (03:00:30 UTC) so replicas get a stable replicated flag. -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - run_at_hour, - run_at_minute, - run_at_second -) -VALUES ( - 'refresh_org_usage_credits_flag', - 'Refresh orgs.has_usage_credits from usage credit grants (replicated flag for read replicas)', - 'function', - 'public.refresh_orgs_has_usage_credits()', - 3, - 0, - 30 -) -ON CONFLICT (name) DO UPDATE - SET - description = excluded.description, - task_type = excluded.task_type, - target = excluded.target, - run_at_hour = excluded.run_at_hour, - run_at_minute = excluded.run_at_minute, - run_at_second = excluded.run_at_second; - -COMMIT; diff --git a/supabase/migrations/20260207180640_tmp_users_cleanup_7_days.sql b/supabase/migrations/20260207180640_tmp_users_cleanup_7_days.sql deleted file mode 100644 index a3ba63587b..0000000000 --- a/supabase/migrations/20260207180640_tmp_users_cleanup_7_days.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Align tmp_users cleanup with invite validity windows (7 days). --- Previously, tmp_users rows were deleted after 1 hour, which caused invitation --- acceptance to fail. - -CREATE OR REPLACE FUNCTION public.cleanup_tmp_users() -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - DELETE FROM "public"."tmp_users" - WHERE GREATEST(updated_at, created_at) < NOW() - INTERVAL '7 days'; -END; -$$; - --- SECURITY: tmp_users has RLS disabled for all; keep this definer function --- executable only by internal roles to avoid bypassing RLS via PUBLIC execute. -REVOKE EXECUTE ON FUNCTION public.cleanup_tmp_users() FROM public; -GRANT EXECUTE ON FUNCTION public.cleanup_tmp_users() TO service_role; - --- The cron runner is table-driven via public.cron_tasks (see migrations around --- 2025-12-28 and 2026-01-03). Register tmp_users cleanup as a per-minute task. -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - minute_interval, - run_at_second, - enabled -) -VALUES ( - 'cleanup_tmp_users', - 'Cleanup expired tmp_users invitations (7 days)', - 'function'::public.cron_task_type, - 'public.cleanup_tmp_users()', - 1, - 0, - true -) -ON CONFLICT (name) DO UPDATE SET - description = excluded.description, - task_type = excluded.task_type, - target = excluded.target, - minute_interval = excluded.minute_interval, - run_at_second = excluded.run_at_second, - enabled = excluded.enabled, - updated_at = NOW(); diff --git a/supabase/migrations/20260209014020_user_created_via_invite.sql b/supabase/migrations/20260209014020_user_created_via_invite.sql deleted file mode 100644 index 04cafe567d..0000000000 --- a/supabase/migrations/20260209014020_user_created_via_invite.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Track whether a user account was created via the invitation flow. --- This is used for internal onboarding metrics ("User Joined") so we can exclude invited members. -ALTER TABLE public.users -ADD COLUMN IF NOT EXISTS created_via_invite boolean NOT NULL DEFAULT false; - -COMMENT ON COLUMN public.users.created_via_invite IS -'True when the account was created through /private/accept_invitation (invited members), false for normal self-signups.'; diff --git a/supabase/migrations/20260209024134_remove_exceeded_flags_functions.sql b/supabase/migrations/20260209024134_remove_exceeded_flags_functions.sql deleted file mode 100644 index f26b337e26..0000000000 --- a/supabase/migrations/20260209024134_remove_exceeded_flags_functions.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Remove deprecated exceeded-flag RPC helpers; backend now updates stripe_info directly --- via service_role and customer_id. - -DROP FUNCTION IF EXISTS public.set_mau_exceeded_by_org(uuid, boolean); -DROP FUNCTION IF EXISTS public.set_storage_exceeded_by_org(uuid, boolean); -DROP FUNCTION IF EXISTS public.set_bandwidth_exceeded_by_org(uuid, boolean); diff --git a/supabase/migrations/20260210132811_stats_customid_guard.sql b/supabase/migrations/20260210132811_stats_customid_guard.sql deleted file mode 100644 index d1482dec04..0000000000 --- a/supabase/migrations/20260210132811_stats_customid_guard.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Allow app owners to disable device-supplied custom_id persistence coming from --- unauthenticated telemetry (/stats). Default is true for backward --- compatibility with existing behavior. - -ALTER TABLE IF EXISTS public.apps -ADD COLUMN IF NOT EXISTS allow_device_custom_id boolean NOT NULL DEFAULT true; - -COMMENT ON COLUMN public.apps.allow_device_custom_id -IS 'When true, devices can persist custom_id via unauthenticated /stats telemetry. When false, custom_id is ignored and a customIdBlocked stat is emitted.'; - --- Server-side stat emitted when custom_id is provided but rejected for the app. -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'customIdBlocked'; diff --git a/supabase/migrations/20260211034517_add_demo_apps_created_to_global_stats.sql b/supabase/migrations/20260211034517_add_demo_apps_created_to_global_stats.sql deleted file mode 100644 index 479eb020de..0000000000 --- a/supabase/migrations/20260211034517_add_demo_apps_created_to_global_stats.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE public.global_stats -ADD COLUMN demo_apps_created integer NOT NULL DEFAULT 0; - -COMMENT ON COLUMN public.global_stats.demo_apps_created IS 'Number of demo apps created in the last 24 hours'; diff --git a/supabase/migrations/20260214054927_restore_top_up_usage_credits_for_service_role.sql b/supabase/migrations/20260214054927_restore_top_up_usage_credits_for_service_role.sql deleted file mode 100644 index c166666c89..0000000000 --- a/supabase/migrations/20260214054927_restore_top_up_usage_credits_for_service_role.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Restore EXECUTE permission on top_up_usage_credits for service_role. --- --- Migration 20260104120000 --- (revoke_process_function_queue_public_access) revoked --- EXECUTE from ALL roles on this function, including service_role. This was an --- oversight — the same migration correctly preserved service_role access for --- other billing functions --- (apply_usage_overage, set_*_exceeded_by_org) with the --- comment "Do not revoke from service_role as it is used in billing --- operations", --- but missed top_up_usage_credits. --- --- top_up_usage_credits is called via supabaseAdmin (service_role) from: --- 1. supabase/functions/_backend/triggers/stripe_event.ts (line ~197) --- — Stripe checkout.session.completed webhook handler --- 2. supabase/functions/_backend/private/admin_credits.ts (line ~107) --- — Admin credit grant endpoint --- --- It is also called via supabaseAdmin (service_role) from: --- 3. supabase/functions/_backend/private/credits.ts (line ~450) --- — Frontend complete-top-up endpoint (auth enforced in app code) --- --- Without this fix, all three callers fail with: --- 42501: permission denied for function top_up_usage_credits - -GRANT EXECUTE ON FUNCTION public.top_up_usage_credits( - p_org_id uuid, - "p_amount" numeric, - "p_expires_at" timestamp with time zone, - p_source text, - p_source_ref jsonb, - p_notes text -) TO service_role; diff --git a/supabase/migrations/20260216102420_add_build_status_reconciliation_cron.sql b/supabase/migrations/20260216102420_add_build_status_reconciliation_cron.sql deleted file mode 100644 index d467dff804..0000000000 --- a/supabase/migrations/20260216102420_add_build_status_reconciliation_cron.sql +++ /dev/null @@ -1,75 +0,0 @@ -SELECT pgmq.create('cron_reconcile_build_status'); - -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - batch_size, - second_interval, - minute_interval, - hour_interval, - run_at_hour, - run_at_minute, - run_at_second, - run_on_dow, - run_on_day -) VALUES ( - 'reconcile_build_status', - 'Send build status reconciliation job to queue every 15 minutes', - 'queue', - 'cron_reconcile_build_status', - null, - null, - 15, - null, - null, - null, - 0, - null, - null -) -ON CONFLICT (name) DO UPDATE SET - description = excluded.description, - task_type = excluded.task_type, - target = excluded.target, - minute_interval = excluded.minute_interval, - run_at_second = excluded.run_at_second, - updated_at = NOW(); - -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - batch_size, - second_interval, - minute_interval, - hour_interval, - run_at_hour, - run_at_minute, - run_at_second, - run_on_dow, - run_on_day -) VALUES ( - 'reconcile_build_status_queue', - 'Process build status reconciliation queue', - 'function_queue', - '["cron_reconcile_build_status"]', - null, - null, - 1, - null, - null, - null, - 0, - null, - null -) -ON CONFLICT (name) DO UPDATE SET - description = excluded.description, - task_type = excluded.task_type, - target = excluded.target, - minute_interval = excluded.minute_interval, - run_at_second = excluded.run_at_second, - updated_at = NOW(); diff --git a/supabase/migrations/20260221150207_fix_role_bindings_rls_update_insert.sql b/supabase/migrations/20260221150207_fix_role_bindings_rls_update_insert.sql deleted file mode 100644 index 2956036529..0000000000 --- a/supabase/migrations/20260221150207_fix_role_bindings_rls_update_insert.sql +++ /dev/null @@ -1,168 +0,0 @@ --- Fix: Add user_has_app_update_user_roles to role_bindings INSERT and UPDATE policies --- The DELETE policy already has this condition, but INSERT and UPDATE were missing it. --- This caused silent failures when a user with app.update_user_roles permission --- (but not legacy admin rights) tried to update or insert app-scoped role bindings --- via the Supabase client (RLS path). --- --- Also use get_identity_org_appid() for app-scoped branches so that API key holders --- are correctly resolved, matching the pattern used by other app-scoped RLS policies. - --- ============================================================================= --- 1. Fix INSERT policy --- ============================================================================= -DROP POLICY IF EXISTS role_bindings_insert ON public.role_bindings; - -CREATE POLICY role_bindings_insert ON public.role_bindings -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - -- Platform admin - public.is_admin(auth_user.uid) - OR - -- Org admin for org-scoped bindings - ( - role_bindings.scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - role_bindings.org_id, - NULL::varchar, - NULL::bigint - ) - ) - OR - -- App admin (legacy path) or users with app.update_user_roles permission - (role_bindings.scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND ( - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - apps.app_id, - NULL::bigint - ) - OR - public.user_has_app_update_user_roles( - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.id - ) - ) - )) - OR - -- Channel admin for channel-scoped bindings - (role_bindings.scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - channels.app_id, - channels.id - ) - )) - ) -); - -COMMENT ON POLICY role_bindings_insert ON public.role_bindings IS -'Scope admins and users with app.update_user_roles can insert role_bindings within their scope.'; - --- ============================================================================= --- 2. Fix UPDATE policy --- ============================================================================= -DROP POLICY IF EXISTS role_bindings_update ON public.role_bindings; - -CREATE POLICY role_bindings_update ON public.role_bindings -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE - -- Platform admin - public.is_admin(auth_user.uid) - OR - -- Org admin for org-scoped bindings - ( - role_bindings.scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - auth_user.uid, - role_bindings.org_id, - NULL::varchar, - NULL::bigint - ) - ) - OR - -- App admin (legacy path) or users with app.update_user_roles permission - (role_bindings.scope_type = public.rbac_scope_app() AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND ( - public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - apps.app_id, - NULL::bigint - ) - OR - public.user_has_app_update_user_roles( - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.id - ) - ) - )) - OR - -- Channel admin for channel-scoped bindings - (role_bindings.scope_type = public.rbac_scope_channel() AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - channels.app_id, - channels.id - ) - )) - ) -); - -COMMENT ON POLICY role_bindings_update ON public.role_bindings IS -'Scope admins and users with app.update_user_roles can update role_bindings within their scope.'; diff --git a/supabase/migrations/20260223000001_add_sso_providers.sql b/supabase/migrations/20260223000001_add_sso_providers.sql deleted file mode 100644 index 904f454731..0000000000 --- a/supabase/migrations/20260223000001_add_sso_providers.sql +++ /dev/null @@ -1,187 +0,0 @@ --- Migration: Add SSO providers table --- Purpose: Enterprise SSO support (SAML 2.0) with DNS domain verification --- SSO management uses org.update_settings permission - --- Enable citext extension for case-insensitive text -CREATE EXTENSION IF NOT EXISTS citext; - --- ============================================================================= --- 1) Create sso_providers table --- ============================================================================= -CREATE TABLE public.sso_providers ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - org_id uuid NOT NULL REFERENCES public.orgs (id) ON DELETE CASCADE, - domain citext NOT NULL UNIQUE, -- noqa: RF04 - provider_id text, - status text NOT NULL DEFAULT 'pending_verification' CHECK ( - status IN ( - 'pending_verification', - 'verified', - 'active', - 'disabled' - ) - ), - enforce_sso boolean NOT NULL DEFAULT false, - dns_verification_token text NOT NULL, - dns_verified_at timestamptz, - metadata_url text, - attribute_mapping jsonb DEFAULT '{}', - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); - --- Index on org_id for org-scoped queries -CREATE INDEX idx_sso_providers_org_id ON public.sso_providers (org_id); - --- ============================================================================= --- 2) Trigger function for updated_at (with SET search_path = '') --- ============================================================================= -CREATE OR REPLACE FUNCTION public.update_sso_providers_updated_at() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' -AS $$ -BEGIN - NEW.updated_at = now(); - RETURN NEW; -END; -$$; - -CREATE OR REPLACE TRIGGER handle_sso_providers_updated_at -BEFORE UPDATE ON public.sso_providers -FOR EACH ROW -EXECUTE FUNCTION public.update_sso_providers_updated_at(); - --- ============================================================================= --- 3) Enable RLS --- ============================================================================= -ALTER TABLE public.sso_providers ENABLE ROW LEVEL SECURITY; - --- ============================================================================= --- 4) RLS policies using get_identity_org_allowed (sso_providers has NO app_id) --- One policy per operation. Both authenticated and anon roles. --- ============================================================================= - --- SELECT: org admins can read SSO providers -CREATE POLICY allow_org_admins_select_sso_providers -ON public.sso_providers -FOR SELECT -TO anon, authenticated -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ), - org_id, - null::character varying, - null::bigint - ) -); - --- INSERT: org admins can create SSO providers -CREATE POLICY allow_org_admins_insert_sso_providers -ON public.sso_providers -FOR INSERT -TO anon, authenticated -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - org_id - ), - org_id, - null::character varying, - null::bigint - ) -); - --- UPDATE: org admins can update SSO providers -CREATE POLICY allow_org_admins_update_sso_providers -ON public.sso_providers -FOR UPDATE -TO anon, authenticated -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - org_id - ), - org_id, - null::character varying, - null::bigint - ) -) -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{write,all}'::public.key_mode [], - org_id - ), - org_id, - null::character varying, - null::bigint - ) -); - --- DELETE: org super_admins can delete SSO providers -CREATE POLICY allow_org_super_admins_delete_sso_providers -ON public.sso_providers -FOR DELETE -TO anon, authenticated -USING ( - public.check_min_rights( - 'super_admin'::public.user_min_right, - public.get_identity_org_allowed( - '{all}'::public.key_mode [], - org_id - ), - org_id, - null::character varying, - null::bigint - ) -); - --- ============================================================================= --- 5) Grant table permissions to roles --- ============================================================================= -GRANT ALL ON TABLE public.sso_providers TO anon; -GRANT ALL ON TABLE public.sso_providers TO authenticated; -GRANT ALL ON TABLE public.sso_providers TO service_role; - --- Grant function permissions -GRANT ALL ON FUNCTION public.update_sso_providers_updated_at() TO anon; -GRANT ALL ON FUNCTION public.update_sso_providers_updated_at() TO authenticated; -GRANT ALL ON FUNCTION public.update_sso_providers_updated_at() TO service_role; - - --- ============================================================================= --- 6) SQL function to check if a domain has active SSO --- ============================================================================= -CREATE OR REPLACE FUNCTION public.check_domain_sso(p_domain text) -RETURNS TABLE ( - has_sso boolean, - provider_id text, - org_id uuid -) -LANGUAGE sql -STABLE -SET search_path = '' -AS $$ - SELECT - true AS has_sso, - sp.provider_id, - sp.org_id - FROM public.sso_providers AS sp - WHERE sp."domain" = p_domain - AND sp.status = 'active' - LIMIT 1; -$$; - -GRANT ALL ON FUNCTION public.check_domain_sso(text) TO anon; -GRANT ALL ON FUNCTION public.check_domain_sso(text) TO authenticated; -GRANT ALL ON FUNCTION public.check_domain_sso(text) TO service_role; diff --git a/supabase/migrations/20260224091500_fix_get_orgs_v6_access_controls.sql b/supabase/migrations/20260224091500_fix_get_orgs_v6_access_controls.sql deleted file mode 100644 index f2525e24e2..0000000000 --- a/supabase/migrations/20260224091500_fix_get_orgs_v6_access_controls.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Security hardening for get_orgs_v6(user_id) --- The parameterized overload accepts arbitrary user IDs, so it must not be callable --- via anon/authenticated roles directly. - -REVOKE ALL ON FUNCTION public.get_orgs_v6(userid uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v6(userid uuid) FROM ANON; -REVOKE ALL ON FUNCTION public.get_orgs_v6(userid uuid) FROM AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_orgs_v6(userid uuid) TO POSTGRES; -GRANT EXECUTE ON FUNCTION public.get_orgs_v6(userid uuid) TO SERVICE_ROLE; diff --git a/supabase/migrations/20260224093000_fix_get_total_metrics_auth.sql b/supabase/migrations/20260224093000_fix_get_total_metrics_auth.sql deleted file mode 100644 index 8cfc9dcc80..0000000000 --- a/supabase/migrations/20260224093000_fix_get_total_metrics_auth.sql +++ /dev/null @@ -1,237 +0,0 @@ --- Harden get_total_metrics RPC access: --- - provide admin-only UUID overloads for explicit org lookup --- - provide authenticated user overload without UUID that resolves org from caller context - -DROP FUNCTION IF EXISTS public.get_total_metrics(); - -DROP FUNCTION IF EXISTS public.get_total_metrics(uuid, date, date); -DROP FUNCTION IF EXISTS public.get_total_metrics(uuid); - -CREATE FUNCTION public.get_total_metrics( - org_id uuid, - start_date date, - end_date date -) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - cache_entry public.org_metrics_cache%ROWTYPE; - cache_ttl interval := '5 minutes'::interval; -BEGIN - IF start_date IS NULL OR end_date IS NULL THEN - RETURN; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE orgs.id = get_total_metrics.org_id - ) THEN - RETURN; - END IF; - - IF EXISTS ( - SELECT 1 - FROM pg_catalog.pg_stat_xact_user_tables - WHERE relname IN ( - 'apps', - 'deleted_apps', - 'daily_mau', - 'daily_bandwidth', - 'daily_build_time', - 'daily_version', - 'app_versions', - 'app_versions_meta' - ) - AND (n_tup_ins > 0 OR n_tup_upd > 0 OR n_tup_del > 0) - ) THEN - cache_entry := public.seed_org_metrics_cache(get_total_metrics.org_id, start_date, end_date); - - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; - RETURN; - END IF; - - SELECT * INTO cache_entry - FROM public.org_metrics_cache - WHERE org_metrics_cache.org_id = get_total_metrics.org_id; - - IF FOUND - AND cache_entry.start_date = start_date - AND cache_entry.end_date = end_date - AND cache_entry.cached_at > clock_timestamp() - cache_ttl - THEN - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; - RETURN; - END IF; - - cache_entry := public.seed_org_metrics_cache(get_total_metrics.org_id, start_date, end_date); - - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; -END; -$function$; - -ALTER FUNCTION public.get_total_metrics(uuid, date, date) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid, date, date) FROM anon; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid, date, date) FROM public; -GRANT ALL ON FUNCTION public.get_total_metrics( - uuid, date, date -) TO service_role; - -CREATE FUNCTION public.get_total_metrics(org_id uuid) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_start_date date; - v_end_date date; - v_anchor_day interval; -BEGIN - SELECT - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - INTO v_anchor_day - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE o.id = get_total_metrics.org_id; - - IF NOT FOUND THEN - RETURN; - END IF; - - IF v_anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN - v_start_date := (date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + v_anchor_day)::date; - ELSE - v_start_date := (date_trunc('MONTH', NOW()) + v_anchor_day)::date; - END IF; - v_end_date := (v_start_date + INTERVAL '1 MONTH')::date; - - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.get_total_metrics(org_id, v_start_date, v_end_date) AS metrics; -END; -$function$; - -ALTER FUNCTION public.get_total_metrics(uuid) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM authenticated; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM public; -GRANT ALL ON FUNCTION public.get_total_metrics(uuid) TO service_role; - -CREATE FUNCTION public.get_total_metrics() RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_request_user uuid; - v_request_org_id uuid; - v_org_id_text text; -BEGIN - SELECT public.get_identity() INTO v_request_user; - - IF v_request_user IS NULL THEN - RETURN; - END IF; - - SELECT current_setting('request.jwt.claim.org_id', true) INTO v_org_id_text; - - IF v_org_id_text IS NOT NULL AND v_org_id_text <> '' THEN - BEGIN - v_request_org_id := v_org_id_text::uuid; - EXCEPTION WHEN invalid_text_representation THEN - -- Malformed org_id in JWT; fall through to org_users lookup - v_request_org_id := NULL; - END; - END IF; - - IF v_request_org_id IS NULL THEN - SELECT org_users.org_id - INTO v_request_org_id - FROM public.org_users - WHERE org_users.user_id = v_request_user - ORDER BY org_users.org_id - LIMIT 1; - END IF; - - IF v_request_org_id IS NULL OR NOT EXISTS ( - SELECT 1 - FROM public.org_users - WHERE org_users.org_id = v_request_org_id - AND org_users.user_id = v_request_user - ) THEN - RETURN; - END IF; - - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.get_total_metrics(v_request_org_id) AS metrics; -END; -$function$; - -ALTER FUNCTION public.get_total_metrics() OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_total_metrics() FROM anon; -REVOKE ALL ON FUNCTION public.get_total_metrics() FROM public; -GRANT ALL ON FUNCTION public.get_total_metrics() TO authenticated; diff --git a/supabase/migrations/20260224153000_add_org_conversion_rate_to_global_stats.sql b/supabase/migrations/20260224153000_add_org_conversion_rate_to_global_stats.sql deleted file mode 100644 index 498badc6b2..0000000000 --- a/supabase/migrations/20260224153000_add_org_conversion_rate_to_global_stats.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE public.global_stats -ADD COLUMN org_conversion_rate double precision NOT NULL DEFAULT 0; - -COMMENT ON COLUMN public.global_stats.org_conversion_rate IS 'Percentage of organizations that are paying (paying / orgs * 100)'; diff --git a/supabase/migrations/20260224153100_fix_org_member_rpc_access.sql b/supabase/migrations/20260224153100_fix_org_member_rpc_access.sql deleted file mode 100644 index 9d72849ce4..0000000000 --- a/supabase/migrations/20260224153100_fix_org_member_rpc_access.sql +++ /dev/null @@ -1,257 +0,0 @@ --- ============================================================================ --- Fix auth checks and execution privileges for org RPCs --- ============================================================================ -CREATE OR REPLACE FUNCTION public.get_org_members(guild_id uuid) -RETURNS TABLE ( - aid bigint, - uid uuid, - email varchar, - image_url varchar, - role public.user_min_right, - is_tmp boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; - v_is_service_role boolean; -BEGIN - v_user_id := public.get_identity( - '{read,upload,write,all}'::public.key_mode[] - ); - v_is_service_role := ( - (SELECT auth.jwt() ->> 'role') = 'service_role' - OR (SELECT session_user) IS NOT DISTINCT FROM 'postgres' - ); - - IF NOT v_is_service_role THEN - IF v_user_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - v_user_id, - get_org_members.guild_id, - NULL::character varying, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: NO_RIGHTS', - jsonb_build_object( - 'guild_id', get_org_members.guild_id, - 'uid', v_user_id - ) - ); - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - END IF; - - RETURN QUERY - SELECT * - FROM public.get_org_members(v_user_id, get_org_members.guild_id); -END; -$$; - -CREATE OR REPLACE FUNCTION public.get_org_members( - user_id uuid, - guild_id uuid -) -RETURNS TABLE ( - aid bigint, - uid uuid, - email varchar, - image_url varchar, - role public.user_min_right, - is_tmp boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; - v_is_service_role boolean; -BEGIN - v_is_service_role := ( - (SELECT auth.jwt() ->> 'role') = 'service_role' - OR (SELECT session_user) IS NOT DISTINCT FROM 'postgres' - ); - - IF NOT v_is_service_role THEN - v_user_id := public.get_identity( - '{read,upload,write,all}'::public.key_mode[] - ); - - IF v_user_id IS NULL - OR v_user_id IS DISTINCT FROM get_org_members.user_id THEN - PERFORM public.pg_log( - 'deny: NO_RIGHTS', - jsonb_build_object( - 'guild_id', get_org_members.guild_id, - 'uid', v_user_id, - 'requested_uid', get_org_members.user_id - ) - ); - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - IF NOT public.check_min_rights( - 'read'::public.user_min_right, - v_user_id, - get_org_members.guild_id, - NULL::character varying, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: NO_RIGHTS', - jsonb_build_object( - 'guild_id', get_org_members.guild_id, - 'uid', v_user_id - ) - ); - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - END IF; - - RETURN QUERY - -- Get existing org members - SELECT - o.id AS aid, - users.id AS uid, - users.email, - users.image_url, - o.user_right AS role, - false AS is_tmp - FROM public.org_users o - JOIN public.users ON users.id = o.user_id - WHERE o.org_id = get_org_members.guild_id - UNION - -- Get pending invitations from tmp_users - SELECT - (-tmp.id)::bigint AS aid, - tmp.future_uuid AS uid, - tmp.email::varchar, - ''::varchar AS image_url, - public.transform_role_to_invite(tmp.role) AS role, - true AS is_tmp - FROM public.tmp_users tmp - WHERE tmp.org_id = get_org_members.guild_id - AND tmp.cancelled_at IS NULL - AND GREATEST(tmp.updated_at, tmp.created_at) - > (CURRENT_TIMESTAMP - INTERVAL '7 days'); -END; -$$; - -ALTER FUNCTION public.get_org_members(user_id uuid, guild_id uuid) -OWNER TO postgres; - -ALTER FUNCTION public.get_org_members(guild_id uuid) -OWNER TO postgres; - -GRANT EXECUTE -ON FUNCTION public.get_org_members(guild_id uuid) -TO authenticated; - -GRANT EXECUTE -ON FUNCTION public.get_org_members(guild_id uuid) -TO service_role; - -GRANT EXECUTE -ON FUNCTION public.get_org_members(user_id uuid, guild_id uuid) -TO service_role; - -REVOKE ALL -ON FUNCTION public.get_org_members(guild_id uuid) -FROM public; - -REVOKE ALL -ON FUNCTION public.get_org_members(user_id uuid, guild_id uuid) -FROM public; - -CREATE OR REPLACE FUNCTION public.check_org_members_password_policy( - org_id uuid -) -RETURNS TABLE ( - user_id uuid, - email text, - first_name text, - last_name text, - password_policy_compliant boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; - v_is_service_role boolean; -BEGIN - v_user_id := public.get_identity( - '{read,upload,write,all}'::public.key_mode[] - ); - v_is_service_role := ( - (SELECT auth.jwt() ->> 'role') = 'service_role' - OR (SELECT session_user) IS NOT DISTINCT FROM 'postgres' - ); - - IF NOT v_is_service_role THEN - IF v_user_id IS NULL OR NOT public.check_min_rights( - 'super_admin'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - check_org_members_password_policy.org_id - ), - check_org_members_password_policy.org_id, - NULL::character varying, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: NO_RIGHTS', - jsonb_build_object( - 'org_id', check_org_members_password_policy.org_id, - 'uid', v_user_id - ) - ); - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - END IF; - - -- Check if org exists - IF NOT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE public.orgs.id = check_org_members_password_policy.org_id - ) THEN - RAISE EXCEPTION 'Organization does not exist'; - END IF; - - RETURN QUERY - SELECT - ou.user_id, - au.email::text, - u.first_name::text, - u.last_name::text, - public.user_meets_password_policy( - ou.user_id, - check_org_members_password_policy.org_id - ) AS password_policy_compliant - FROM public.org_users ou - JOIN auth.users au ON au.id = ou.user_id - LEFT JOIN public.users u ON u.id = ou.user_id - WHERE ou.org_id = check_org_members_password_policy.org_id; -END; -$$; - -ALTER FUNCTION public.check_org_members_password_policy(org_id uuid) -OWNER TO postgres; - -GRANT EXECUTE -ON FUNCTION public.check_org_members_password_policy(org_id uuid) -TO authenticated; - -GRANT EXECUTE -ON FUNCTION public.check_org_members_password_policy(org_id uuid) -TO service_role; - -REVOKE ALL -ON FUNCTION public.check_org_members_password_policy(org_id uuid) -FROM public; diff --git a/supabase/migrations/20260224153200_fix_webhook_rls_org_scoping.sql b/supabase/migrations/20260224153200_fix_webhook_rls_org_scoping.sql deleted file mode 100644 index efbe7225df..0000000000 --- a/supabase/migrations/20260224153200_fix_webhook_rls_org_scoping.sql +++ /dev/null @@ -1,184 +0,0 @@ --- ============================================================================= --- Migration: Fix webhook RLS policies for org-scoped API key isolation --- --- The 20260107000000 migration introduced anon role support for webhook endpoints, --- but still resolves identity through get_identity(...), which does not enforce --- limited_to_orgs. This allows read-mode API keys scoped to a single org to read --- webhook secrets and delivery logs from other orgs. --- --- This migration switches webhook and webhook_deliveries RLS checks to --- get_identity_org_allowed(..., org_id), so org restrictions from API keys are --- enforced per row. --- ============================================================================= - --- ===================================================== --- Recreate webhooks policies with org-scoped API key identity --- ===================================================== - -DROP POLICY IF EXISTS "Allow org members to select webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to insert webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to update webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to delete webhooks" ON public.webhooks; - -CREATE POLICY "Allow org members to select webhooks" -ON public.webhooks -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'read'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhooks" -ON public.webhooks -FOR INSERT -TO authenticated, anon -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to update webhooks" -ON public.webhooks -FOR UPDATE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ) - ), - org_id, - null::character varying, - null::bigint - ) -) -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to delete webhooks" -ON public.webhooks -FOR DELETE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - --- ===================================================== --- Recreate webhook_deliveries policies with org-scoped API key identity --- ===================================================== - -DROP POLICY IF EXISTS "Allow org members to select webhook_deliveries" ON public.webhook_deliveries; -DROP POLICY IF EXISTS "Allow admin to insert webhook_deliveries" ON public.webhook_deliveries; -DROP POLICY IF EXISTS "Allow admin to update webhook_deliveries" ON public.webhook_deliveries; - -CREATE POLICY "Allow org members to select webhook_deliveries" -ON public.webhook_deliveries -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'read'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhook_deliveries" -ON public.webhook_deliveries -FOR INSERT -TO authenticated, anon -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ) - ), - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to update webhook_deliveries" -ON public.webhook_deliveries -FOR UPDATE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ) - ), - org_id, - null::character varying, - null::bigint - ) -); diff --git a/supabase/migrations/20260224153201_revoke_record_email_otp_verified_auth_role.sql b/supabase/migrations/20260224153201_revoke_record_email_otp_verified_auth_role.sql deleted file mode 100644 index 9e80fe45be..0000000000 --- a/supabase/migrations/20260224153201_revoke_record_email_otp_verified_auth_role.sql +++ /dev/null @@ -1,47 +0,0 @@ --- ========================================================================== --- Restrict email OTP verification bookkeeping and enforce service-side function usage --- ========================================================================== - -CREATE OR REPLACE FUNCTION public.record_email_otp_verified( - "p_user_id" uuid -) -RETURNS timestamptz -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $$ -DECLARE - v_now timestamptz := NOW(); -BEGIN - IF "p_user_id" IS NULL THEN - RAISE EXCEPTION 'user_id required'; - END IF; - - INSERT INTO "public"."user_security" (user_id, email_otp_verified_at, created_at, updated_at) - VALUES ("p_user_id", v_now, v_now, v_now) - ON CONFLICT (user_id) DO UPDATE - SET email_otp_verified_at = EXCLUDED.email_otp_verified_at, - updated_at = EXCLUDED.updated_at; - - RETURN v_now; -END; -$$; - -GRANT EXECUTE ON FUNCTION public.record_email_otp_verified( - uuid -) TO service_role; -GRANT EXECUTE ON FUNCTION public.record_email_otp_verified( - uuid -) TO postgres; - --- The OTP verification marker must only be written by trusted server-side code --- after successful OTP validation. -REVOKE EXECUTE ON FUNCTION public.record_email_otp_verified( - uuid -) FROM public; -REVOKE EXECUTE ON FUNCTION public.record_email_otp_verified( - uuid -) FROM authenticated; - --- Remove the legacy zero-arg function overload now that callers are migrated. -DROP FUNCTION IF EXISTS public.record_email_otp_verified(); diff --git a/supabase/migrations/20260224153300_add_created_at_to_get_orgs_v7.sql b/supabase/migrations/20260224153300_add_created_at_to_get_orgs_v7.sql deleted file mode 100644 index 1a2a0424a5..0000000000 --- a/supabase/migrations/20260224153300_add_created_at_to_get_orgs_v7.sql +++ /dev/null @@ -1,329 +0,0 @@ --- Add org created_at to get_orgs_v7 return type --- The frontend TrialBanner needs the real org creation time to gate display. --- Previously it used subscription_start which is the billing-cycle anchor (bc.cycle_start), --- NOT the account creation time, causing the 3-hour check to pass immediately for new trial orgs. - --- Drop both overloads of get_orgs_v7 (with and without parameters) -DROP FUNCTION IF EXISTS public.get_orgs_v7(); -DROP FUNCTION IF EXISTS public.get_orgs_v7(uuid); - --- Recreate get_orgs_v7(userid) with created_at added to the return type. --- Based on prod.sql (the canonical schema) — only change is the new created_at column. -CREATE FUNCTION public.get_orgs_v7(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - created_at timestamptz, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean -) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - rbac_roles AS ( - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION ALL - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - rbac_org_roles AS ( - SELECT org_id, (ARRAY_AGG(rbac_roles.name ORDER BY rbac_roles.priority_rank DESC))[1] AS role_name - FROM rbac_roles - GROUP BY org_id - ), - user_orgs AS ( - SELECT ou.org_id - FROM public.org_users ou - WHERE ou.user_id = userid - UNION - SELECT rbac_org_roles.org_id - FROM rbac_org_roles - ), - -- Compute next stats update info for all paying orgs at once - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - -- Calculate current billing cycle for each org - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - -- Calculate 2FA access status for user/org combinations - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ), - -- Calculate password policy access status for user/org combinations - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - public.user_meets_password_policy(userid, o.id) AS password_has_access, - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ) - SELECT - o.id AS gid, - o.created_by, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE o.created_at - END AS created_at, - o.logo, - o.name, - CASE - WHEN o.use_new_rbac AND ou.user_right::text LIKE 'invite_%' THEN ou.user_right::varchar - WHEN o.use_new_rbac THEN COALESCE(ror.role_name, ou.rbac_role_name, ou.user_right::varchar) - ELSE COALESCE(ou.user_right::varchar, ror.role_name) - END AS role, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'succeeded', false) - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE((si.status = 'succeeded' AND si.is_good_plan = true) - OR (si.trial_at::date - NOW()::date > 0) - OR COALESCE(ucb.available_credits, 0) > 0, false) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'canceled', false) - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.available_credits, 0) - END AS credit_available, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.total_credits, 0) - END AS credit_total, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE ucb.next_expiration - END AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key, - o.use_new_rbac - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - LEFT JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id - LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(uuid) OWNER TO "postgres"; - --- Revoke from public roles (security: prevents users from querying other users' orgs) -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM public; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM authenticated; - --- Grant only to postgres and service_role (private function) -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO postgres; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO service_role; - --- Recreate the get_orgs_v7() wrapper with created_at in the return type -CREATE OR REPLACE FUNCTION public.get_orgs_v7() -RETURNS TABLE ( - gid uuid, - created_by uuid, - created_at timestamptz, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean -) LANGUAGE plpgsql -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - -- Check if API key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO "postgres"; - -GRANT ALL ON FUNCTION public.get_orgs_v7() TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO service_role; diff --git a/supabase/migrations/20260224153401_fix_transfer_app_security.sql b/supabase/migrations/20260224153401_fix_transfer_app_security.sql deleted file mode 100644 index 569cfbdd23..0000000000 --- a/supabase/migrations/20260224153401_fix_transfer_app_security.sql +++ /dev/null @@ -1,126 +0,0 @@ --- Restrict transfer_app execution to authenticated contexts and avoid app --- enumeration through distinct error payloads. -REVOKE ALL ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) FROM anon; -REVOKE ALL ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) FROM public; - -CREATE OR REPLACE FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) RETURNS void -LANGUAGE plpgsql SECURITY DEFINER -SET search_path TO '' -AS $$ -DECLARE - v_old_org_id uuid; - v_user_id uuid; - v_last_transfer jsonb; - v_last_transfer_date timestamp; -BEGIN - SELECT owner_org, transfer_history[array_length(transfer_history, 1)] - INTO v_old_org_id, v_last_transfer - FROM public.apps - WHERE app_id = p_app_id; - - IF v_old_org_id IS NULL THEN - RAISE EXCEPTION 'Unable to process transfer request.'; - END IF; - - v_user_id := (SELECT auth.uid()); - - IF v_user_id IS NULL THEN - PERFORM public.pg_log( - 'deny: TRANSFER_NO_AUTH', - jsonb_build_object('app_id', p_app_id, 'new_org_id', p_new_org_id) - ); - RAISE EXCEPTION 'Unable to process transfer request.'; - END IF; - - IF NOT public.rbac_check_permission( - public.rbac_perm_app_transfer(), - v_old_org_id, - p_app_id, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: TRANSFER_OLD_ORG_RIGHTS', - jsonb_build_object( - 'app_id', p_app_id, - 'old_org_id', v_old_org_id, - 'new_org_id', p_new_org_id, - 'uid', v_user_id - ) - ); - RAISE EXCEPTION 'Unable to process transfer request.'; - END IF; - - IF NOT public.rbac_check_permission( - public.rbac_perm_app_transfer(), - p_new_org_id, - NULL::character varying, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: TRANSFER_NEW_ORG_RIGHTS', - jsonb_build_object( - 'app_id', p_app_id, - 'old_org_id', v_old_org_id, - 'new_org_id', p_new_org_id, - 'uid', v_user_id - ) - ); - RAISE EXCEPTION 'Unable to process transfer request.'; - END IF; - - IF v_last_transfer IS NOT NULL THEN - v_last_transfer_date := (v_last_transfer->>'transferred_at')::timestamp; - IF v_last_transfer_date + interval '32 days' > now() THEN - RAISE EXCEPTION - 'Cannot transfer app. Must wait at least 32 days ' - 'between transfers. Last transfer was on %', - v_last_transfer_date; - END IF; - END IF; - - UPDATE public.apps - SET - owner_org = p_new_org_id, - updated_at = now(), - transfer_history = COALESCE(transfer_history, '{}') || jsonb_build_object( - 'transferred_at', now(), - 'transferred_from', v_old_org_id, - 'transferred_to', p_new_org_id, - 'initiated_by', v_user_id - )::jsonb - WHERE app_id = p_app_id; - - UPDATE public.app_versions - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.app_versions_meta - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channel_devices - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channels - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - -END; -$$; - -COMMENT ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) IS 'Transfers an app and all its related data to a new ' -'organization. Requires app.transfer permission on both ' -'source and destination organizations.'; diff --git a/supabase/migrations/20260224153500_restrict_rpc_api_key_oracles.sql b/supabase/migrations/20260224153500_restrict_rpc_api_key_oracles.sql deleted file mode 100644 index 25090be3b9..0000000000 --- a/supabase/migrations/20260224153500_restrict_rpc_api_key_oracles.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Revoke anonymous execution of key-validation RPCs to prevent unauthenticated oracles -REVOKE ALL ON FUNCTION public.get_user_id("apikey" text) FROM anon; -REVOKE ALL ON FUNCTION public.get_user_id( - "apikey" text, "app_id" text -) FROM anon; -REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey( - "apikey" text, "app_id" text -) FROM anon; diff --git a/supabase/migrations/20260224160000_fix_find_apikey_rpc_permissions.sql b/supabase/migrations/20260224160000_fix_find_apikey_rpc_permissions.sql deleted file mode 100644 index 0aa3a9bf77..0000000000 --- a/supabase/migrations/20260224160000_fix_find_apikey_rpc_permissions.sql +++ /dev/null @@ -1,16 +0,0 @@ --- ============================================================================ --- Restrict find_apikey_by_value RPC access to service-role callers only --- ============================================================================ --- Even after the previous security hardening migration, `find_apikey_by_value` --- was still exposed via PUBLIC execute privilege. --- This removes any remaining broad execute permissions and keeps service-role --- access only so the function cannot be called through unauthenticated RPC. - -REVOKE ALL ON FUNCTION public.find_apikey_by_value(text) FROM PUBLIC; -REVOKE EXECUTE ON FUNCTION public.find_apikey_by_value(text) FROM ANON; -REVOKE EXECUTE ON FUNCTION public.find_apikey_by_value( - text -) FROM AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.find_apikey_by_value( - text -) TO SERVICE_ROLE; diff --git a/supabase/migrations/20260225000000_image_metadata_cleanup_triggers.sql b/supabase/migrations/20260225000000_image_metadata_cleanup_triggers.sql deleted file mode 100644 index 2f6570c579..0000000000 --- a/supabase/migrations/20260225000000_image_metadata_cleanup_triggers.sql +++ /dev/null @@ -1,51 +0,0 @@ --- Add queue-backed image metadata cleanup triggers for user-uploaded images. - --- Create queues used by the backend trigger worker. -SELECT pgmq.create('on_app_update'); - -SELECT pgmq.create('on_org_update'); - --- Run image metadata cleanup on app icon updates. -DROP TRIGGER IF EXISTS on_app_update ON public.apps; -CREATE TRIGGER on_app_update -AFTER -UPDATE OF icon_url ON public.apps FOR EACH ROW -EXECUTE FUNCTION public.trigger_http_queue_post_to_function( - 'on_app_update' -); - --- Run image metadata cleanup on org logo updates. -DROP TRIGGER IF EXISTS on_org_update ON public.orgs; -CREATE TRIGGER on_org_update -AFTER -UPDATE OF logo ON public.orgs FOR EACH ROW -EXECUTE FUNCTION public.trigger_http_queue_post_to_function( - 'on_org_update' -); - --- Keep high-frequency queue processing up-to-date with new image cleanup triggers. -WITH updated_target AS ( - SELECT - ct.name, - ( - SELECT - COALESCE( - JSONB_AGG(value ORDER BY value), - '["on_app_update","on_org_update"]'::jsonb - )::text - FROM ( - SELECT JSONB_ARRAY_ELEMENTS_TEXT(ct.target::jsonb) AS value - UNION - SELECT 'on_app_update' - UNION - SELECT 'on_org_update' - ) AS items - ) AS normalized_target - FROM public.cron_tasks AS ct - WHERE ct.name = 'high_frequency_queues' -) - -UPDATE public.cron_tasks ct -SET target = updated_target.normalized_target -FROM updated_target -WHERE ct.name = updated_target.name; diff --git a/supabase/migrations/20260225000100_atomic_demo_app_creation.sql b/supabase/migrations/20260225000100_atomic_demo_app_creation.sql deleted file mode 100644 index 2049dbb6f1..0000000000 --- a/supabase/migrations/20260225000100_atomic_demo_app_creation.sql +++ /dev/null @@ -1,162 +0,0 @@ --- Atomically enforce demo app quota limits and insert the demo app row. --- This avoids check-then-act race conditions when multiple users create demo apps --- concurrently in the same organization. -CREATE OR REPLACE FUNCTION public.create_demo_app_with_limits( - p_owner_org uuid, - p_user_id uuid, - p_app_id text, - p_name text, - p_icon_url text, - p_retention bigint, - p_default_upload_channel text, - p_last_version text, - p_active_window_days integer, - p_user_per_hour integer, - p_org_per_hour integer, - p_user_per_24h integer, - p_org_per_24h integer, - p_max_active_per_org integer -) RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_active_window_start timestamptz := now() - make_interval(days => p_active_window_days); - v_hour_window_start timestamptz := now() - interval '1 hour'; - v_24h_window_start timestamptz := now() - interval '24 hours'; - v_created_app public.apps; - v_active_demo_apps bigint; - v_user_demo_apps_1h bigint; - v_org_demo_apps_1h bigint; - v_user_demo_apps_24h bigint; - v_org_demo_apps_24h bigint; -BEGIN - IF p_app_id IS NULL OR LEFT(p_app_id, LENGTH('com.capdemo.')) <> 'com.capdemo.' THEN - RETURN jsonb_build_object( - 'created', false, - 'reason', 'invalid_demo_app_id' - ); - END IF; - - -- Serialize demo app creation decisions per organization to avoid races. - PERFORM pg_advisory_xact_lock(hashtext(p_owner_org::text)); - - -- Active-demo-app cap (recent demo apps for this org). - SELECT COUNT(*) INTO v_active_demo_apps - FROM public.apps - WHERE owner_org = p_owner_org - AND app_id LIKE 'com.capdemo.%' - AND created_at >= v_active_window_start; - - IF v_active_demo_apps >= p_max_active_per_org THEN - RETURN jsonb_build_object( - 'created', false, - 'reason', 'demo_app_quota_exceeded', - 'count', v_active_demo_apps, - 'limit', p_max_active_per_org - ); - END IF; - - -- Per-user limit in the last hour. - SELECT COUNT(*) INTO v_user_demo_apps_1h - FROM public.apps - WHERE owner_org = p_owner_org - AND user_id = p_user_id - AND app_id LIKE 'com.capdemo.%' - AND created_at >= v_hour_window_start; - - IF v_user_demo_apps_1h >= p_user_per_hour THEN - RETURN jsonb_build_object( - 'created', false, - 'reason', 'demo_app_user_rate_limit_exceeded', - 'count', v_user_demo_apps_1h, - 'limit', p_user_per_hour, - 'window_seconds', 3600, - 'retry_after_seconds', 60 * 60 - ); - END IF; - - -- Per-org limit in the last hour. - SELECT COUNT(*) INTO v_org_demo_apps_1h - FROM public.apps - WHERE owner_org = p_owner_org - AND app_id LIKE 'com.capdemo.%' - AND created_at >= v_hour_window_start; - - IF v_org_demo_apps_1h >= p_org_per_hour THEN - RETURN jsonb_build_object( - 'created', false, - 'reason', 'demo_app_org_rate_limit_exceeded', - 'count', v_org_demo_apps_1h, - 'limit', p_org_per_hour, - 'window_seconds', 3600, - 'retry_after_seconds', 60 * 60 - ); - END IF; - - -- Per-user limit in the last 24h. - SELECT COUNT(*) INTO v_user_demo_apps_24h - FROM public.apps - WHERE owner_org = p_owner_org - AND user_id = p_user_id - AND app_id LIKE 'com.capdemo.%' - AND created_at >= v_24h_window_start; - - IF v_user_demo_apps_24h >= p_user_per_24h THEN - RETURN jsonb_build_object( - 'created', false, - 'reason', 'demo_app_user_rate_limit_exceeded', - 'count', v_user_demo_apps_24h, - 'limit', p_user_per_24h, - 'window_seconds', 86400, - 'retry_after_seconds', 24 * 60 * 60 - ); - END IF; - - -- Per-org limit in the last 24h. - SELECT COUNT(*) INTO v_org_demo_apps_24h - FROM public.apps - WHERE owner_org = p_owner_org - AND app_id LIKE 'com.capdemo.%' - AND created_at >= v_24h_window_start; - - IF v_org_demo_apps_24h >= p_org_per_24h THEN - RETURN jsonb_build_object( - 'created', false, - 'reason', 'demo_app_org_rate_limit_exceeded', - 'count', v_org_demo_apps_24h, - 'limit', p_org_per_24h, - 'window_seconds', 86400, - 'retry_after_seconds', 24 * 60 * 60 - ); - END IF; - - INSERT INTO public.apps ( - owner_org, - app_id, - user_id, - icon_url, - name, - retention, - default_upload_channel, - last_version - ) - VALUES ( - p_owner_org, - p_app_id, - p_user_id, - p_icon_url, - p_name, - p_retention, - p_default_upload_channel, - p_last_version - ) - RETURNING * INTO v_created_app; - - RETURN jsonb_build_object( - 'created', true, - 'app', to_jsonb(v_created_app) - ); -END -$$; diff --git a/supabase/migrations/20260225105000_exist_app_v2_apikey_auth.sql b/supabase/migrations/20260225105000_exist_app_v2_apikey_auth.sql deleted file mode 100644 index 2948923267..0000000000 --- a/supabase/migrations/20260225105000_exist_app_v2_apikey_auth.sql +++ /dev/null @@ -1,33 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."exist_app_v2" ("appid" character varying) RETURNS boolean -LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key text; -BEGIN - IF session_user IN ('postgres', 'service_role') THEN - RETURN (SELECT EXISTS (SELECT 1 - FROM public.apps - WHERE app_id = appid)); - END IF; - - SELECT public.get_apikey_header() INTO api_key; - - IF api_key IS NULL OR api_key = '' THEN - RETURN false; - END IF; - - IF NOT public.is_allowed_capgkey(api_key, '{read,upload,write,all}'::"public"."key_mode"[], appid) THEN - RETURN false; - END IF; - - RETURN (SELECT EXISTS (SELECT 1 - FROM public.apps - WHERE app_id = appid)); -END; -$$; - -REVOKE ALL ON FUNCTION "public"."exist_app_v2" ("appid" character varying) FROM "public"; -GRANT ALL ON FUNCTION "public"."exist_app_v2" ("appid" character varying) TO "anon"; -GRANT ALL ON FUNCTION "public"."exist_app_v2" ("appid" character varying) TO "authenticated"; -GRANT ALL ON FUNCTION "public"."exist_app_v2" ("appid" character varying) TO "service_role"; diff --git a/supabase/migrations/20260225120000_restrict_webhooks_select_for_admin_only.sql b/supabase/migrations/20260225120000_restrict_webhooks_select_for_admin_only.sql deleted file mode 100644 index 4a4e887d44..0000000000 --- a/supabase/migrations/20260225120000_restrict_webhooks_select_for_admin_only.sql +++ /dev/null @@ -1,29 +0,0 @@ --- ============================================================================= --- Migration: Restrict webhook secret exposure to admin readers --- --- Reverts the org-reader regression introduced in --- 20260224153200_fix_webhook_rls_org_scoping.sql. Non-admin/API-key users --- with read-only rights were able to query `public.webhooks` directly and read --- signing `secret` values. --- ============================================================================= - --- Ensure only admin users can SELECT webhook rows. -DROP POLICY IF EXISTS "Allow org members to select webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to select webhooks" ON public.webhooks; - -CREATE POLICY "Allow admin to select webhooks" -ON public.webhooks -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - org_id - ), - org_id, - null::character varying, - null::bigint - ) -); diff --git a/supabase/migrations/20260226000000_org_rls_require_self_2fa_update.sql b/supabase/migrations/20260226000000_org_rls_require_self_2fa_update.sql deleted file mode 100644 index 5fcfb82660..0000000000 --- a/supabase/migrations/20260226000000_org_rls_require_self_2fa_update.sql +++ /dev/null @@ -1,32 +0,0 @@ -DROP POLICY IF EXISTS "Allow update for auth (admin+)" ON public.orgs; - -CREATE POLICY "Allow update for auth (admin+)" ON public.orgs -FOR UPDATE -TO authenticated, -anon USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{all,write}'::public.key_mode [], id - ), - id, - NULL::character varying, - NULL::bigint - ) -) -WITH -CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{all,write}'::public.key_mode [], id - ), - id, - NULL::character varying, - NULL::bigint - ) - AND ( - enforcing_2fa IS NOT TRUE - OR public.has_2fa_enabled((auth.uid())::uuid) - ) -); diff --git a/supabase/migrations/20260226000100_fix_org_rls_2fa_function_permissions.sql b/supabase/migrations/20260226000100_fix_org_rls_2fa_function_permissions.sql deleted file mode 100644 index 9c83942e9b..0000000000 --- a/supabase/migrations/20260226000100_fix_org_rls_2fa_function_permissions.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Fix: use the no-arg has_2fa_enabled() in the orgs UPDATE RLS policy. --- The uuid overload has_2fa_enabled(uuid) is restricted to postgres/service_role, --- but this policy runs as authenticated/anon, causing "permission denied". --- The no-arg version is granted to authenticated and uses auth.uid() internally. - -DROP POLICY IF EXISTS "Allow update for auth (admin+)" ON public.orgs; - -CREATE POLICY "Allow update for auth (admin+)" ON public.orgs -FOR UPDATE -TO authenticated, -anon USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{all,write}'::public.key_mode [], id - ), - id, - NULL::character varying, - NULL::bigint - ) -) -WITH -CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - public.get_identity_org_allowed( - '{all,write}'::public.key_mode [], id - ), - id, - NULL::character varying, - NULL::bigint - ) - AND ( - enforcing_2fa IS NOT TRUE - OR public.has_2fa_enabled() - ) -); diff --git a/supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql b/supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql deleted file mode 100644 index 9013b0b2e8..0000000000 --- a/supabase/migrations/20260226090000_require_verified_email_for_delete_user.sql +++ /dev/null @@ -1,85 +0,0 @@ --- Prevent unverified accounts from starting the account deletion lifecycle. - -CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - user_id_fn uuid; - user_email text; - old_record_json jsonb; - last_sign_in_at_ts timestamptz; - email_confirmed_at_ts timestamptz; - did_schedule integer; -BEGIN - -- Get the current user ID and email details - SELECT "auth"."uid"() INTO user_id_fn; - IF user_id_fn IS NULL THEN - RAISE EXCEPTION 'not_authenticated' USING ERRCODE = '42501'; - END IF; - - SELECT "email", "last_sign_in_at", "email_confirmed_at" - INTO user_email, last_sign_in_at_ts, email_confirmed_at_ts - FROM "auth"."users" - WHERE "id" = user_id_fn; - - -- Require a verified email address before allowing account deletion - IF email_confirmed_at_ts IS NULL THEN - RAISE EXCEPTION 'email_not_verified' USING ERRCODE = 'P0003'; - END IF; - - -- Require a fresh reauthentication (password confirmation) - IF last_sign_in_at_ts IS NULL OR last_sign_in_at_ts < NOW() - INTERVAL '5 minutes' THEN - RAISE EXCEPTION 'reauth_required' USING ERRCODE = 'P0001'; - END IF; - - -- Fetch the old_record using the specified query format - SELECT row_to_json(u)::jsonb INTO old_record_json - FROM ( - SELECT * - FROM "public"."users" - WHERE id = user_id_fn - ) AS u; - - IF old_record_json IS NULL THEN - RAISE EXCEPTION 'user_not_found' USING ERRCODE = 'P0002'; - END IF; - - -- Mark the user for deletion - INSERT INTO "public"."to_delete_accounts" ( - "account_id", - "removal_date", - "removed_data" - ) VALUES - ( - user_id_fn, - NOW() + INTERVAL '30 days', - "jsonb_build_object"('email', user_email, 'apikeys', COALESCE((SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn), '[]'::jsonb)) - ) - ON CONFLICT ("account_id") DO NOTHING - RETURNING 1 INTO did_schedule; - - -- Retry-safe: only enqueue cleanup actions when this is a new delete request. - IF did_schedule IS NULL THEN - RETURN; - END IF; - - -- Trigger the queue-based deletion process - -- This cancels the subscriptions of the user's organizations - PERFORM "pgmq"."send"( - 'on_user_delete'::text, - "jsonb_build_object"( - 'payload', "jsonb_build_object"( - 'old_record', old_record_json, - 'table', 'users', - 'type', 'DELETE' - ), - 'function_name', 'on_user_delete' - ) - ); - - -- Delete the API keys - DELETE FROM "public"."apikeys" WHERE "public"."apikeys"."user_id" = user_id_fn; -END; -$$; - -ALTER FUNCTION "public"."delete_user"() OWNER TO "postgres"; diff --git a/supabase/migrations/20260226153000_restrict_apikey_oracle_rpcs.sql b/supabase/migrations/20260226153000_restrict_apikey_oracle_rpcs.sql deleted file mode 100644 index 68b5e26584..0000000000 --- a/supabase/migrations/20260226153000_restrict_apikey_oracle_rpcs.sql +++ /dev/null @@ -1,10 +0,0 @@ --- ============================================================================ --- Revoke anonymous access to API-key introspection RPCs --- ============================================================================ -REVOKE EXECUTE ON FUNCTION public.get_org_perm_for_apikey( - "apikey" text, "app_id" text -) FROM anon; -REVOKE EXECUTE ON FUNCTION public.get_user_id("apikey" text) FROM anon; -REVOKE EXECUTE ON FUNCTION public.get_user_id( - "apikey" text, "app_id" text -) FROM anon; diff --git a/supabase/migrations/20260227000000_fix_rescind_invitation_rpc_access.sql b/supabase/migrations/20260227000000_fix_rescind_invitation_rpc_access.sql deleted file mode 100644 index 4da3f084da..0000000000 --- a/supabase/migrations/20260227000000_fix_rescind_invitation_rpc_access.sql +++ /dev/null @@ -1,43 +0,0 @@ --- Fix rescind_invitation RPC: remove anonymous access and avoid org existence enumeration. -CREATE OR REPLACE FUNCTION public.rescind_invitation( - "email" TEXT, "org_id" UUID -) RETURNS VARCHAR LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -DECLARE - tmp_user record; -BEGIN - IF NOT (public.check_min_rights('admin'::public.user_min_right, (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], rescind_invitation.org_id)), rescind_invitation.org_id, NULL::varchar, NULL::bigint)) THEN - RETURN 'NO_RIGHTS'; - END IF; - - PERFORM 1 FROM public.orgs WHERE public.orgs.id = rescind_invitation.org_id; - IF NOT FOUND THEN - RETURN 'NO_RIGHTS'; - END IF; - - SELECT * INTO tmp_user FROM public.tmp_users WHERE public.tmp_users.email = rescind_invitation.email AND public.tmp_users.org_id = rescind_invitation.org_id; - IF NOT FOUND THEN - RETURN 'NO_INVITATION'; - END IF; - - IF tmp_user.cancelled_at IS NOT NULL THEN - RETURN 'ALREADY_CANCELLED'; - END IF; - - UPDATE public.tmp_users SET cancelled_at = CURRENT_TIMESTAMP WHERE public.tmp_users.id = tmp_user.id; - RETURN 'OK'; -END; -$$; - -REVOKE ALL ON FUNCTION public.rescind_invitation(TEXT, UUID) FROM public; -REVOKE ALL ON FUNCTION public.rescind_invitation(TEXT, UUID) FROM anon; -REVOKE ALL ON FUNCTION public.rescind_invitation( - TEXT, UUID -) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.rescind_invitation( - TEXT, UUID -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.rescind_invitation( - TEXT, UUID -) TO service_role; diff --git a/supabase/migrations/20260227000001_secure_record_build_time_rpc.sql b/supabase/migrations/20260227000001_secure_record_build_time_rpc.sql deleted file mode 100644 index 4a52cb0243..0000000000 --- a/supabase/migrations/20260227000001_secure_record_build_time_rpc.sql +++ /dev/null @@ -1,90 +0,0 @@ --- Revoke public execution of record_build_time and enforce identity checks. --- Keep the existing parameter signature for backward compatibility. - -REVOKE ALL ON FUNCTION public.record_build_time( - uuid, - uuid, - character varying, - character varying, - bigint -) FROM anon, authenticated; - -GRANT EXECUTE ON FUNCTION public.record_build_time( - uuid, - uuid, - character varying, - character varying, - bigint -) TO service_role; - -CREATE OR REPLACE FUNCTION public.record_build_time( - p_org_id uuid, - p_user_id uuid, - p_build_id character varying, - p_platform character varying, - p_build_time_unit bigint -) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER -SET -search_path = '' AS $$ -DECLARE - v_build_log_id uuid; - v_multiplier numeric; - v_billable_seconds bigint; - v_caller_user_id uuid; - v_invoking_role text; -BEGIN - SELECT NULLIF(current_setting('role', true), '') INTO v_invoking_role; - - -- Service-role callers do not have JWT/API key context and pass p_user_id directly. - -- Keep this path for internal calls from backend services. - IF v_invoking_role = 'service_role' THEN - v_caller_user_id := p_user_id; - ELSE - v_caller_user_id := public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - p_org_id - ); - END IF; - - IF v_caller_user_id IS NULL THEN - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - IF NOT public.check_min_rights( - 'write'::public.user_min_right, - v_caller_user_id, - p_org_id, - NULL::character varying, - NULL::bigint - ) THEN - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - IF p_build_time_unit < 0 THEN - RAISE EXCEPTION 'Build time cannot be negative'; - END IF; - IF p_platform NOT IN ('ios', 'android') THEN - RAISE EXCEPTION 'Invalid platform: %', p_platform; - END IF; - - -- Apply platform multiplier - v_multiplier := CASE p_platform - WHEN 'ios' THEN 2 - WHEN 'android' THEN 1 - ELSE 1 - END; - - v_billable_seconds := (p_build_time_unit * v_multiplier)::bigint; - - INSERT INTO public.build_logs (org_id, user_id, build_id, platform, build_time_unit, billable_seconds) - VALUES (p_org_id, v_caller_user_id, p_build_id, p_platform, p_build_time_unit, v_billable_seconds) - ON CONFLICT (build_id, org_id) DO UPDATE SET - user_id = EXCLUDED.user_id, - platform = EXCLUDED.platform, - build_time_unit = EXCLUDED.build_time_unit, - billable_seconds = EXCLUDED.billable_seconds - RETURNING id INTO v_build_log_id; - - RETURN v_build_log_id; -END; -$$; diff --git a/supabase/migrations/20260227010000_restrict_upsert_version_meta_exec.sql b/supabase/migrations/20260227010000_restrict_upsert_version_meta_exec.sql deleted file mode 100644 index 86d31e1f13..0000000000 --- a/supabase/migrations/20260227010000_restrict_upsert_version_meta_exec.sql +++ /dev/null @@ -1,13 +0,0 @@ -REVOKE ALL ON FUNCTION public.upsert_version_meta( - "p_app_id" character varying, "p_version_id" bigint, "p_size" bigint -) -FROM -anon, -authenticated; - -GRANT -EXECUTE ON FUNCTION public.upsert_version_meta( - "p_app_id" character varying, "p_version_id" bigint, "p_size" bigint -) -TO -service_role; diff --git a/supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql b/supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql deleted file mode 100644 index e6e47ccf48..0000000000 --- a/supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql +++ /dev/null @@ -1,141 +0,0 @@ --- Harden invite_user_to_org RPC against anonymous enumeration and disclosure. - -CREATE OR REPLACE FUNCTION public.invite_user_to_org( - "email" character varying, - "org_id" uuid, - "invite_type" public.user_min_right -) RETURNS character varying -LANGUAGE plpgsql SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - org record; - invited_user record; - current_record record; - current_tmp_user record; - calling_user_id uuid; - v_is_super_admin boolean := false; - v_use_rbac boolean := false; -BEGIN - -- Get the calling user's ID. - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id) - INTO calling_user_id; - - -- Treat missing orgs as unauthorized to avoid org existence enumeration. - SELECT * INTO org FROM public.orgs WHERE public.orgs.id=invite_user_to_org.org_id; - IF org IS NULL OR calling_user_id IS NULL THEN - RETURN 'NO_RIGHTS'; - END IF; - - -- Check if user has at least public.rbac_right_admin() rights. - IF NOT public.check_min_rights(public.rbac_right_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN - PERFORM public.pg_log('deny: NO_RIGHTS_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type)); - RETURN 'NO_RIGHTS'; - END IF; - - -- If inviting as super_admin, caller must be super_admin. - IF (invite_type = public.rbac_right_super_admin()::public.user_min_right OR invite_type = public.rbac_right_invite_super_admin()::public.user_min_right) THEN - v_use_rbac := public.rbac_is_enabled_for_org(invite_user_to_org.org_id); - - IF v_use_rbac THEN - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - JOIN public.roles r ON r.id = rb.role_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = calling_user_id - AND ( - (rb.scope_type = public.rbac_scope_org() - AND rb.org_id = invite_user_to_org.org_id - AND r.name = public.rbac_role_org_super_admin()) - OR - (rb.scope_type = public.rbac_scope_platform() - AND r.name = public.rbac_role_platform_super_admin()) - ) - ) INTO v_is_super_admin; - - IF NOT v_is_super_admin THEN - PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type)); - RETURN 'NO_RIGHTS'; - END IF; - - IF org.enforcing_2fa AND NOT public.has_2fa_enabled(calling_user_id) THEN - PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type, 'uid', calling_user_id)); - RETURN 'NO_RIGHTS'; - END IF; - ELSE - IF NOT public.check_min_rights(public.rbac_right_super_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN - PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type)); - RETURN 'NO_RIGHTS'; - END IF; - END IF; - END IF; - - -- Check if user already exists. - SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email=invite_user_to_org.email; - - IF invited_user IS NOT NULL THEN - -- User exists, check if already in org. - SELECT public.org_users.id INTO current_record - FROM public.org_users - WHERE public.org_users.user_id=invited_user.id - AND public.org_users.org_id=invite_user_to_org.org_id; - - IF current_record IS NOT NULL THEN - RETURN 'ALREADY_INVITED'; - ELSE - -- Add user to org. - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - RETURN 'OK'; - END IF; - ELSE - -- User doesn't exist, check tmp_users for pending invitations. - SELECT * INTO current_tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email=invite_user_to_org.email - AND public.tmp_users.org_id=invite_user_to_org.org_id; - - IF current_tmp_user IS NOT NULL THEN - -- Invitation already exists. - IF current_tmp_user.cancelled_at IS NOT NULL THEN - -- Invitation was cancelled, check if recent. - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - -- No invitation exists, need to create one (handled elsewhere). - RETURN 'NO_EMAIL'; - END IF; - END IF; -END; -$$; - -REVOKE EXECUTE ON FUNCTION public.invite_user_to_org( - character varying, - uuid, - public.user_min_right -) FROM public; - -GRANT EXECUTE ON FUNCTION public.invite_user_to_org( - character varying, - uuid, - public.user_min_right -) TO anon; - -GRANT EXECUTE ON FUNCTION public.invite_user_to_org( - character varying, - uuid, - public.user_min_right -) TO authenticated; - -GRANT EXECUTE ON FUNCTION public.invite_user_to_org( - character varying, - uuid, - public.user_min_right -) TO service_role; diff --git a/supabase/migrations/20260228000000_role_bindings_rls_assignable.sql b/supabase/migrations/20260228000000_role_bindings_rls_assignable.sql deleted file mode 100644 index 1b67771423..0000000000 --- a/supabase/migrations/20260228000000_role_bindings_rls_assignable.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Add is_assignable check to role_bindings INSERT RLS policy --- Without this, a direct PostgREST INSERT could bypass the endpoint's is_assignable check - -DROP POLICY IF EXISTS "role_bindings_insert" ON "public"."role_bindings"; - -CREATE POLICY "role_bindings_insert" ON "public"."role_bindings" FOR INSERT TO "authenticated" WITH CHECK ( - -- The role must be assignable - (EXISTS ( - SELECT 1 FROM "public"."roles" r - WHERE r.id = "role_bindings"."role_id" AND r.is_assignable = true - )) - AND - (EXISTS ( SELECT 1 - FROM ( SELECT "auth"."uid"() AS "uid") "auth_user" - WHERE ("public"."is_admin"("auth_user"."uid") OR (("role_bindings"."scope_type" = "public"."rbac_scope_org"()) AND "public"."check_min_rights"("public"."rbac_right_admin"(), "auth_user"."uid", "role_bindings"."org_id", NULL::character varying, NULL::bigint)) OR (("role_bindings"."scope_type" = "public"."rbac_scope_app"()) AND (EXISTS ( SELECT 1 - FROM "public"."apps" - WHERE (("apps"."id" = "role_bindings"."app_id") AND ("public"."check_min_rights"("public"."rbac_right_admin"(), "public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."owner_org", "apps"."app_id", NULL::bigint) OR "public"."user_has_app_update_user_roles"("public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."id")))))) OR (("role_bindings"."scope_type" = "public"."rbac_scope_channel"()) AND (EXISTS ( SELECT 1 - FROM ("public"."channels" - JOIN "public"."apps" ON ((("apps"."app_id")::"text" = ("channels"."app_id")::"text"))) - WHERE (("channels"."rbac_id" = "role_bindings"."channel_id") AND "public"."check_min_rights"("public"."rbac_right_admin"(), "public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."owner_org", "channels"."app_id", "channels"."id")))))))) -); - -COMMENT ON POLICY "role_bindings_insert" ON "public"."role_bindings" IS 'Scope admins and users with app.update_user_roles can insert role_bindings within their scope. Role must be assignable.'; diff --git a/supabase/migrations/20260228000100_delete_member_cascade_bindings.sql b/supabase/migrations/20260228000100_delete_member_cascade_bindings.sql deleted file mode 100644 index 4f908422a3..0000000000 --- a/supabase/migrations/20260228000100_delete_member_cascade_bindings.sql +++ /dev/null @@ -1,61 +0,0 @@ --- Fix delete_org_member_role to cascade all bindings (org, app, channel) --- Previously only deleted the org-level binding, leaving orphaned app/channel bindings - -CREATE OR REPLACE FUNCTION "public"."delete_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid") RETURNS "text" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_org_created_by uuid; -BEGIN - -- Check if user has permission to update roles - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), p_org_id, NULL, NULL) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; - END IF; - - -- Get org owner to prevent removing the last super admin - SELECT created_by INTO v_org_created_by - FROM public.orgs - WHERE id = p_org_id; - - -- Prevent removing the org owner - IF p_user_id = v_org_created_by THEN - RAISE EXCEPTION 'CANNOT_CHANGE_OWNER_ROLE'; - END IF; - - -- Check if removing a super_admin and if this is the last super_admin - IF EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_id = p_user_id - AND rb.principal_type = public.rbac_principal_user() - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - AND r.name = public.rbac_role_org_super_admin() - ) THEN - IF ( - SELECT COUNT(*) - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.scope_type = public.rbac_scope_org() - AND rb.org_id = p_org_id - AND rb.principal_type = public.rbac_principal_user() - AND r.name = public.rbac_role_org_super_admin() - ) <= 1 THEN - RAISE EXCEPTION 'CANNOT_REMOVE_LAST_SUPER_ADMIN'; - END IF; - END IF; - - -- Delete ALL role bindings for this user in this org (org, app, and channel scopes) - -- to prevent orphaned app/channel bindings after org-level removal - DELETE FROM public.role_bindings - WHERE principal_id = p_user_id - AND principal_type = public.rbac_principal_user() - AND org_id = p_org_id; - - RETURN 'OK'; -END; -$$; - -COMMENT ON FUNCTION "public"."delete_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid") IS 'Deletes all of an organization member''s role bindings (org, app, and channel scopes). Requires org.update_user_roles permission. Returns OK on success.'; diff --git a/supabase/migrations/20260228000200_prevent_last_super_admin_delete.sql b/supabase/migrations/20260228000200_prevent_last_super_admin_delete.sql deleted file mode 100644 index 06cd33d94e..0000000000 --- a/supabase/migrations/20260228000200_prevent_last_super_admin_delete.sql +++ /dev/null @@ -1,61 +0,0 @@ --- Add a trigger to prevent deleting the last org super_admin role binding --- This protects against direct PostgREST DELETE operations that bypass the SQL function guards - -CREATE OR REPLACE FUNCTION "public"."prevent_last_super_admin_binding_delete"() -RETURNS TRIGGER -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_remaining_count integer; -BEGIN - -- Only check org-level super_admin bindings - IF OLD.scope_type != public.rbac_scope_org() THEN - RETURN OLD; - END IF; - - -- Only check if the deleted binding is a super_admin role - IF NOT EXISTS ( - SELECT 1 FROM public.roles r - WHERE r.id = OLD.role_id - AND r.name = public.rbac_role_org_super_admin() - ) THEN - RETURN OLD; - END IF; - - -- Lock all super_admin bindings in this org to prevent write-skew under concurrent deletes - PERFORM 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.scope_type = public.rbac_scope_org() - AND rb.org_id = OLD.org_id - AND rb.principal_type = public.rbac_principal_user() - AND r.name = public.rbac_role_org_super_admin() - FOR UPDATE; - - -- Count remaining super_admin bindings in this org (excluding the one being deleted) - SELECT COUNT(*) INTO v_remaining_count - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.scope_type = public.rbac_scope_org() - AND rb.org_id = OLD.org_id - AND rb.principal_type = public.rbac_principal_user() - AND r.name = public.rbac_role_org_super_admin() - AND rb.id != OLD.id; - - IF v_remaining_count < 1 THEN - RAISE EXCEPTION 'CANNOT_DELETE_LAST_SUPER_ADMIN_BINDING' - USING HINT = 'At least one super_admin binding must remain in the org'; - END IF; - - RETURN OLD; -END; -$$; - -DROP TRIGGER IF EXISTS "prevent_last_super_admin_delete" ON "public"."role_bindings"; - -CREATE TRIGGER "prevent_last_super_admin_delete" - BEFORE DELETE ON "public"."role_bindings" - FOR EACH ROW - EXECUTE FUNCTION "public"."prevent_last_super_admin_binding_delete"(); diff --git a/supabase/migrations/20260228000300_fix_apikey_hashed_lookup.sql b/supabase/migrations/20260228000300_fix_apikey_hashed_lookup.sql deleted file mode 100644 index e4d1cffad8..0000000000 --- a/supabase/migrations/20260228000300_fix_apikey_hashed_lookup.sql +++ /dev/null @@ -1,200 +0,0 @@ --- Fix API key lookup in rbac_check_permission_direct to support hashed keys --- Previously used `WHERE key = p_apikey` which only matches plain-text keys. --- Hashed keys were silently ignored, losing their RBAC principal permissions. - -CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text" DEFAULT NULL::"text") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_effective_user_id uuid := p_user_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; - v_override boolean; - v_channel_scope boolean := false; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; -BEGIN - -- Validate permission key - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - IF p_channel_id IS NOT NULL AND p_permission_key LIKE 'channel.%' THEN - v_channel_scope := true; - END IF; - - -- Derive org from app/channel when not provided - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF v_effective_org_id IS NULL AND p_channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - END IF; - - -- Resolve user from API key when needed (handles hashed keys too). - IF v_effective_user_id IS NULL AND p_apikey IS NOT NULL THEN - SELECT user_id INTO v_effective_user_id - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(v_effective_user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Check if RBAC is enabled for this org - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - -- RBAC path: Check user permission directly - IF p_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), p_user_id, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - - IF v_channel_scope THEN - -- Direct user override - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_user() - AND o.principal_id = p_user_id - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - ELSE - -- Group overrides (deny > allow) - IF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = p_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = false - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := false; - ELSIF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = p_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = true - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := true; - END IF; - END IF; - END IF; - END IF; - - -- If user doesn't have permission, check apikey permission - -- Use find_apikey_by_value to support both plain-text and hashed keys - IF NOT v_allowed AND p_apikey IS NOT NULL THEN - SELECT rbac_id INTO v_apikey_principal - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_apikey() - AND o.principal_id = v_apikey_principal - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - END IF; - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', p_user_id, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - ELSE - -- Legacy path: Map permission to min_right and use legacy check - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', p_user_id - )); - RETURN false; - END IF; - - IF p_apikey IS NOT NULL AND p_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, p_user_id, p_apikey); - ELSIF p_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(p_app_id, v_legacy_right, p_user_id); - ELSE - RETURN public.check_min_rights_legacy(v_legacy_right, p_user_id, v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - -COMMENT ON FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") IS 'Direct RBAC permission check with automatic legacy fallback based on org feature flag. Uses channel overrides when present. Supports hashed API keys via find_apikey_by_value.'; diff --git a/supabase/migrations/20260228154639_fix_check_domain_sso_security.sql b/supabase/migrations/20260228154639_fix_check_domain_sso_security.sql deleted file mode 100644 index 4044a8b9cb..0000000000 --- a/supabase/migrations/20260228154639_fix_check_domain_sso_security.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Fix check_domain_sso function to use SECURITY DEFINER --- This allows anonymous users at login to check for SSO providers --- without being blocked by RLS policies on sso_providers table - -CREATE OR REPLACE FUNCTION "public"."check_domain_sso"("p_domain" text) -RETURNS TABLE("has_sso" boolean, "provider_id" text, "org_id" uuid) -LANGUAGE "sql" -STABLE -SECURITY DEFINER -SET "search_path" TO '' -AS $$ - SELECT - true AS has_sso, - sp.provider_id, - sp.org_id - FROM "public"."sso_providers" sp - WHERE sp.domain = p_domain - AND sp.status = 'active' - LIMIT 1; -$$; diff --git a/supabase/migrations/20260228172308_fix_prevent_last_super_admin_cascade.sql b/supabase/migrations/20260228172308_fix_prevent_last_super_admin_cascade.sql deleted file mode 100644 index 1b85c05162..0000000000 --- a/supabase/migrations/20260228172308_fix_prevent_last_super_admin_cascade.sql +++ /dev/null @@ -1,65 +0,0 @@ --- Fix prevent_last_super_admin_binding_delete trigger to allow CASCADE deletions --- When an org is being deleted, all its role_bindings are deleted via CASCADE. --- The trigger should not block this - only prevent direct deletes of the last super_admin. - -CREATE OR REPLACE FUNCTION "public"."prevent_last_super_admin_binding_delete"() -RETURNS TRIGGER -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_remaining_count integer; - v_org_exists boolean; -BEGIN - -- Only check org-level super_admin bindings - IF OLD.scope_type != public.rbac_scope_org() THEN - RETURN OLD; - END IF; - - -- Only check if the deleted binding is a super_admin role - IF NOT EXISTS ( - SELECT 1 FROM public.roles r - WHERE r.id = OLD.role_id - AND r.name = public.rbac_role_org_super_admin() - ) THEN - RETURN OLD; - END IF; - - -- Allow deletion if the org itself is being deleted (CASCADE scenario) - SELECT EXISTS( - SELECT 1 FROM public.orgs WHERE id = OLD.org_id - ) INTO v_org_exists; - - IF NOT v_org_exists THEN - RETURN OLD; - END IF; - - -- Lock all super_admin bindings in this org to prevent write-skew under concurrent deletes - PERFORM 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.scope_type = public.rbac_scope_org() - AND rb.org_id = OLD.org_id - AND rb.principal_type = public.rbac_principal_user() - AND r.name = public.rbac_role_org_super_admin() - FOR UPDATE; - - -- Count remaining super_admin bindings in this org (excluding the one being deleted) - SELECT COUNT(*) INTO v_remaining_count - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.scope_type = public.rbac_scope_org() - AND rb.org_id = OLD.org_id - AND rb.principal_type = public.rbac_principal_user() - AND r.name = public.rbac_role_org_super_admin() - AND rb.id != OLD.id; - - IF v_remaining_count < 1 THEN - RAISE EXCEPTION 'CANNOT_DELETE_LAST_SUPER_ADMIN_BINDING' - USING HINT = 'At least one super_admin binding must remain in the org'; - END IF; - - RETURN OLD; -END; -$$; diff --git a/supabase/migrations/20260228172309_fix_rbac_test_compatibility.sql b/supabase/migrations/20260228172309_fix_rbac_test_compatibility.sql deleted file mode 100644 index b314eb602e..0000000000 --- a/supabase/migrations/20260228172309_fix_rbac_test_compatibility.sql +++ /dev/null @@ -1,60 +0,0 @@ --- Fix RBAC test compatibility: enforce last super_admin protection trigger for all roles --- The trigger prevents deletion of the last org-level super_admin binding to protect org access. --- SERVICE_ROLE IS NOT EXEMPT: All roles (including service_role) must respect this guard. - -CREATE OR REPLACE FUNCTION "public"."prevent_last_super_admin_binding_delete"() -RETURNS TRIGGER -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_remaining_count integer; - v_org_exists boolean; -BEGIN - - -- Only check org-level super_admin bindings - IF OLD.scope_type != public.rbac_scope_org() THEN - RETURN OLD; - END IF; - - -- Only check if the deleted binding is a super_admin role - IF NOT EXISTS ( - SELECT 1 FROM public.roles r - WHERE r.id = OLD.role_id - AND r.name = public.rbac_role_org_super_admin() - ) THEN - RETURN OLD; - END IF; - - -- Allow deletion if the org itself is being deleted (CASCADE scenario) - SELECT EXISTS( - SELECT 1 FROM public.orgs WHERE id = OLD.org_id - ) INTO v_org_exists; - - IF NOT v_org_exists THEN - RETURN OLD; - END IF; - - -- Serialize operations on this org's super_admin bindings using advisory lock - -- This prevents write-skew anomalies under concurrent deletes without FOR UPDATE deadlocks - PERFORM pg_advisory_xact_lock(hashtext(OLD.org_id::text)); - - -- Count remaining super_admin bindings in this org (excluding the one being deleted) - SELECT COUNT(*) INTO v_remaining_count - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.scope_type = public.rbac_scope_org() - AND rb.org_id = OLD.org_id - AND rb.principal_type = public.rbac_principal_user() - AND r.name = public.rbac_role_org_super_admin() - AND rb.id != OLD.id; - - IF v_remaining_count < 1 THEN - RAISE EXCEPTION 'CANNOT_DELETE_LAST_SUPER_ADMIN_BINDING' - USING HINT = 'At least one super_admin binding must remain in the org'; - END IF; - - RETURN OLD; -END; -$$; diff --git a/supabase/migrations/20260302000000_rbac_default_for_new_orgs.sql b/supabase/migrations/20260302000000_rbac_default_for_new_orgs.sql deleted file mode 100644 index 61780df24a..0000000000 --- a/supabase/migrations/20260302000000_rbac_default_for_new_orgs.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Make RBAC the default for all newly created organizations. --- Existing orgs are not affected (their current use_new_rbac value is preserved). -ALTER TABLE public.orgs ALTER COLUMN use_new_rbac SET DEFAULT true; diff --git a/supabase/migrations/20260302185011_fix_rbac_check_effective_user.sql b/supabase/migrations/20260302185011_fix_rbac_check_effective_user.sql deleted file mode 100644 index 790d4d62d0..0000000000 --- a/supabase/migrations/20260302185011_fix_rbac_check_effective_user.sql +++ /dev/null @@ -1,304 +0,0 @@ --- Fix rbac_check_permission_direct: use v_effective_user_id instead of p_user_id --- in the RBAC path. When called via API key auth (auth.uid() = NULL), the function --- resolves the user from the API key into v_effective_user_id, but the RBAC path --- was still checking p_user_id (the original NULL parameter), causing permission --- checks to be skipped entirely for API key authenticated requests. --- --- The _no_password_policy variant already uses v_effective_user_id correctly. - -CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text" DEFAULT NULL::"text") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_effective_user_id uuid := p_user_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; - v_override boolean; - v_channel_scope boolean := false; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; -BEGIN - -- Validate permission key - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - IF p_channel_id IS NOT NULL AND p_permission_key LIKE 'channel.%' THEN - v_channel_scope := true; - END IF; - - -- Derive org from app/channel when not provided - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF v_effective_org_id IS NULL AND p_channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - END IF; - - -- Resolve user from API key when needed (handles hashed keys too). - IF v_effective_user_id IS NULL AND p_apikey IS NOT NULL THEN - SELECT user_id INTO v_effective_user_id - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(v_effective_user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Check if RBAC is enabled for this org - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - -- RBAC path: Check user permission directly (use v_effective_user_id, NOT p_user_id) - IF v_effective_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), v_effective_user_id, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - - IF v_channel_scope THEN - -- Direct user override - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_user() - AND o.principal_id = v_effective_user_id - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - ELSE - -- Group overrides (deny > allow) - IF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = false - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := false; - ELSIF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = true - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := true; - END IF; - END IF; - END IF; - END IF; - - -- If user doesn't have permission, check apikey permission - IF NOT v_allowed AND p_apikey IS NOT NULL THEN - SELECT rbac_id INTO v_apikey_principal - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_apikey() - AND o.principal_id = v_apikey_principal - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - END IF; - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id, - 'org_id', v_effective_org_id, - 'app_id', p_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - ELSE - -- Legacy path: Map permission to min_right and use legacy check - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', p_user_id - )); - RETURN false; - END IF; - - IF p_apikey IS NOT NULL AND p_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, p_user_id, p_apikey); - ELSIF p_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(p_app_id, v_legacy_right, p_user_id); - ELSE - RETURN public.check_min_rights_legacy(v_legacy_right, p_user_id, v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - --- Fix invite_user_to_org_rbac: create role_binding directly after org_users insert. --- The sync trigger (sync_org_user_to_role_binding_on_insert) intentionally skips --- role_binding creation when use_new_rbac=true AND rbac_role_name IS NOT NULL, --- expecting the caller to handle it. This function must create the binding itself. - -CREATE OR REPLACE FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") RETURNS character varying - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - org record; - invited_user record; - current_record record; - current_tmp_user record; - role_id uuid; - legacy_right public.user_min_right; - invite_right public.user_min_right; - api_key_text text; - v_granted_by uuid; -BEGIN - SELECT * INTO org FROM public.orgs WHERE public.orgs.id = invite_user_to_org_rbac.org_id; - IF org IS NULL THEN - RETURN 'NO_ORG'; - END IF; - - IF NOT public.rbac_is_enabled_for_org(invite_user_to_org_rbac.org_id) THEN - RETURN 'RBAC_NOT_ENABLED'; - END IF; - - SELECT id INTO role_id - FROM public.roles r - WHERE r.name = invite_user_to_org_rbac.role_name - AND r.scope_type = public.rbac_scope_org() - AND r.is_assignable = true - LIMIT 1; - - IF role_id IS NULL THEN - RETURN 'ROLE_NOT_FOUND'; - END IF; - - SELECT public.get_apikey_header() INTO api_key_text; - - IF invite_user_to_org_rbac.role_name = public.rbac_role_org_super_admin() THEN - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN - RETURN 'NO_RIGHTS'; - END IF; - ELSE - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_invite_user(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN - RETURN 'NO_RIGHTS'; - END IF; - END IF; - - legacy_right := public.rbac_legacy_right_for_org_role(invite_user_to_org_rbac.role_name); - invite_right := public.transform_role_to_invite(legacy_right); - v_granted_by := COALESCE(auth.uid(), (SELECT user_id FROM public.find_apikey_by_value(api_key_text) LIMIT 1)); - - SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email = invite_user_to_org_rbac.email; - - IF invited_user IS NOT NULL THEN - SELECT public.org_users.id INTO current_record - FROM public.org_users - WHERE public.org_users.user_id = invited_user.id - AND public.org_users.org_id = invite_user_to_org_rbac.org_id; - - IF current_record IS NOT NULL THEN - RETURN 'ALREADY_INVITED'; - ELSE - INSERT INTO public.org_users (user_id, org_id, user_right, rbac_role_name) - VALUES (invited_user.id, invite_user_to_org_rbac.org_id, invite_right, invite_user_to_org_rbac.role_name); - - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, - granted_by, granted_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), invited_user.id, role_id, public.rbac_scope_org(), invite_user_to_org_rbac.org_id, - COALESCE(v_granted_by, invited_user.id), now(), 'Invited via invite_user_to_org_rbac', true - ) ON CONFLICT DO NOTHING; - - RETURN 'OK'; - END IF; - ELSE - SELECT * INTO current_tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email = invite_user_to_org_rbac.email - AND public.tmp_users.org_id = invite_user_to_org_rbac.org_id; - - IF current_tmp_user IS NOT NULL THEN - IF current_tmp_user.cancelled_at IS NOT NULL THEN - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - RETURN 'NO_EMAIL'; - END IF; - END IF; -END; -$$; diff --git a/supabase/migrations/20260303150634_sso_per_org_feature_flag.sql b/supabase/migrations/20260303150634_sso_per_org_feature_flag.sql deleted file mode 100644 index e9d24badc4..0000000000 --- a/supabase/migrations/20260303150634_sso_per_org_feature_flag.sql +++ /dev/null @@ -1,354 +0,0 @@ --- Migration: Per-organization SSO feature flag --- Replaces the global ENABLE_SSO env var with a per-org sso_enabled column. --- Only orgs with sso_enabled=true will have SSO functionality available. --- This flag is Capgo-managed (not self-service). - --- ============================================================================= --- 1) Add sso_enabled column to orgs table --- ============================================================================= -ALTER TABLE public.orgs -ADD COLUMN sso_enabled boolean NOT NULL DEFAULT false; - --- ============================================================================= --- 2) Update check_domain_sso to join orgs.sso_enabled --- Only returns has_sso=true when the org has SSO enabled and provider --- is active --- ============================================================================= -CREATE OR REPLACE FUNCTION public.check_domain_sso(p_domain text) -RETURNS TABLE ( - has_sso boolean, - provider_id text, - org_id uuid -) -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = '' -AS $$ - SELECT - true AS has_sso, - sp.provider_id, - sp.org_id - FROM public.sso_providers AS sp - JOIN public.orgs AS o ON o.id = sp.org_id - WHERE sp."domain" = p_domain - AND sp.status = 'active' - AND o.sso_enabled = true - LIMIT 1; -$$; - --- ============================================================================= --- 3) Update get_orgs_v7(userid) to return sso_enabled --- Must DROP first because CREATE OR REPLACE cannot change return type. --- Drop no-args overload first (it depends on the with-args overload). --- ============================================================================= -DROP FUNCTION IF EXISTS public.get_orgs_v7(); -DROP FUNCTION IF EXISTS public.get_orgs_v7(uuid); - -CREATE OR REPLACE FUNCTION public.get_orgs_v7(userid uuid) -RETURNS TABLE ( - gid uuid, - created_by uuid, - created_at timestamptz, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean, - sso_enabled boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - rbac_roles AS ( - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION ALL - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - rbac_org_roles AS ( - SELECT org_id, (ARRAY_AGG(rbac_roles.name ORDER BY rbac_roles.priority_rank DESC))[1] AS role_name - FROM rbac_roles - GROUP BY org_id - ), - user_orgs AS ( - SELECT ou.org_id - FROM public.org_users ou - WHERE ou.user_id = userid - UNION - SELECT rbac_org_roles.org_id - FROM rbac_org_roles - ), - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ), - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - public.user_meets_password_policy(userid, o.id) AS password_has_access, - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ) - SELECT - o.id AS gid, - o.created_by, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE o.created_at - END AS created_at, - o.logo, - o.name, - CASE - WHEN o.use_new_rbac AND ou.user_right::text LIKE 'invite_%' THEN ou.user_right::varchar - WHEN o.use_new_rbac THEN COALESCE(ror.role_name, ou.rbac_role_name, ou.user_right::varchar) - ELSE COALESCE(ou.user_right::varchar, ror.role_name) - END AS role, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'succeeded', false) - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE((si.status = 'succeeded' AND si.is_good_plan = true) - OR (si.trial_at::date - NOW()::date > 0) - OR COALESCE(ucb.available_credits, 0) > 0, false) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'canceled', false) - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.available_credits, 0) - END AS credit_available, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.total_credits, 0) - END AS credit_total, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE ucb.next_expiration - END AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key, - o.use_new_rbac, - o.sso_enabled - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - LEFT JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id - LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - --- ============================================================================= --- 4) Update get_orgs_v7() (no args) wrapper to match new return type --- ============================================================================= -CREATE OR REPLACE FUNCTION public.get_orgs_v7() -RETURNS TABLE ( - gid uuid, - created_by uuid, - created_at timestamptz, - logo text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamptz, - subscription_end timestamptz, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - next_stats_update_at timestamptz, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamptz, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean, - sso_enabled boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; diff --git a/supabase/migrations/20260308121758_fix_get_app_global_metrics_rbac.sql b/supabase/migrations/20260308121758_fix_get_app_global_metrics_rbac.sql deleted file mode 100644 index 9617320219..0000000000 --- a/supabase/migrations/20260308121758_fix_get_app_global_metrics_rbac.sql +++ /dev/null @@ -1,240 +0,0 @@ --- Harden app/global metrics RPC access: --- - require org-level read access for all org_id overloads --- - keep existing UUID-based signatures for compatibility - -CREATE OR REPLACE FUNCTION public.get_app_metrics("org_id" uuid, "start_date" date, "end_date" date) -RETURNS TABLE( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql SECURITY DEFINER -SET search_path TO '' AS $function$ -DECLARE - cache_entry public.app_metrics_cache%ROWTYPE; - request_role text; - org_exists boolean; -BEGIN - request_role := NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''); - IF request_role IS NULL THEN - RETURN; - END IF; - - IF request_role <> 'service_role' THEN - IF NOT public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_app_metrics.org_id), - get_app_metrics.org_id, - NULL::CHARACTER VARYING, - NULL::BIGINT - ) THEN - RETURN; - END IF; - END IF; - - SELECT EXISTS ( - SELECT 1 FROM public.orgs WHERE id = get_app_metrics.org_id - ) INTO org_exists; - - IF NOT org_exists THEN - RETURN; - END IF; - - SELECT * - INTO cache_entry - FROM public.app_metrics_cache - WHERE app_metrics_cache.org_id = get_app_metrics.org_id; - - IF cache_entry.id IS NULL - OR cache_entry.start_date IS DISTINCT FROM get_app_metrics.start_date - OR cache_entry.end_date IS DISTINCT FROM get_app_metrics.end_date - OR cache_entry.cached_at IS NULL - OR cache_entry.cached_at < (pg_catalog.now() - interval '5 minutes') THEN - cache_entry := public.seed_get_app_metrics_caches(get_app_metrics.org_id, get_app_metrics.start_date, get_app_metrics.end_date); - END IF; - - IF cache_entry.response IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT - metrics.app_id, - metrics.date, - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM pg_catalog.jsonb_to_recordset(cache_entry.response) AS metrics( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint - ) - ORDER BY metrics.app_id, metrics.date; -END; -$function$; - -ALTER FUNCTION public.get_app_metrics("org_id" uuid, "start_date" date, "end_date" date) - OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION public.get_app_metrics("org_id" uuid) -RETURNS TABLE( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql SECURITY DEFINER -SET search_path TO '' AS $function$ -DECLARE - request_role text; - cycle_start timestamptz; - cycle_end timestamptz; -BEGIN - request_role := NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''); - IF request_role IS NULL THEN - RETURN; - END IF; - - IF request_role <> 'service_role' THEN - IF NOT public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_app_metrics.org_id), - get_app_metrics.org_id, - NULL::CHARACTER VARYING, - NULL::BIGINT - ) THEN - RETURN; - END IF; - END IF; - - SELECT subscription_anchor_start, subscription_anchor_end INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - RETURN QUERY SELECT * FROM public.get_app_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$function$; - -ALTER FUNCTION public.get_app_metrics("org_id" uuid) - OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION public.get_global_metrics("org_id" uuid, "start_date" date, "end_date" date) -RETURNS TABLE( - date date, - mau bigint, - storage bigint, - bandwidth bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql -SET search_path TO '' AS $function$ -DECLARE - request_role text; -BEGIN - request_role := NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''); - IF request_role IS NULL THEN - RETURN; - END IF; - - IF request_role <> 'service_role' THEN - IF NOT public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_global_metrics.org_id), - get_global_metrics.org_id, - NULL::CHARACTER VARYING, - NULL::BIGINT - ) THEN - RETURN; - END IF; - END IF; - - RETURN QUERY - SELECT - metrics.date, - SUM(metrics.mau)::bigint AS mau, - SUM(metrics.storage)::bigint AS storage, - SUM(metrics.bandwidth)::bigint AS bandwidth, - SUM(metrics.get)::bigint AS get, - SUM(metrics.fail)::bigint AS fail, - SUM(metrics.install)::bigint AS install, - SUM(metrics.uninstall)::bigint AS uninstall - FROM - public.get_app_metrics(org_id, start_date, end_date) AS metrics - GROUP BY - metrics.date - ORDER BY - metrics.date; -END; -$function$; - -ALTER FUNCTION public.get_global_metrics("org_id" uuid, "start_date" date, "end_date" date) - OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION public.get_global_metrics("org_id" uuid) -RETURNS TABLE( - date date, - mau bigint, - storage bigint, - bandwidth bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql -SET search_path TO '' AS $function$ -DECLARE - request_role text; - cycle_start timestamptz; - cycle_end timestamptz; -BEGIN - request_role := NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''); - IF request_role IS NULL THEN - RETURN; - END IF; - - IF request_role <> 'service_role' THEN - IF NOT public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_global_metrics.org_id), - get_global_metrics.org_id, - NULL::CHARACTER VARYING, - NULL::BIGINT - ) THEN - RETURN; - END IF; - END IF; - - SELECT subscription_anchor_start, subscription_anchor_end - INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - - RETURN QUERY - SELECT * FROM public.get_global_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$function$; - -ALTER FUNCTION public.get_global_metrics("org_id" uuid) - OWNER TO "postgres"; diff --git a/supabase/migrations/20260308121933_restrict_global_stats_access.sql b/supabase/migrations/20260308121933_restrict_global_stats_access.sql deleted file mode 100644 index d282c0f797..0000000000 --- a/supabase/migrations/20260308121933_restrict_global_stats_access.sql +++ /dev/null @@ -1,14 +0,0 @@ --- ============================================================================= --- Migration: Restrict direct global_stats access --- --- GHSA-73rv-fpp7-r3r4 reported global platform metrics were exposed through --- PostgREST with an unauthenticated publishable key. This removes all direct --- table access for anon/authenticated roles so only service-side usage remains. --- ============================================================================= - --- Remove the permissive policy that allowed anonymous reads. -DROP POLICY IF EXISTS "Allow anon to select" ON public.global_stats; - --- Ensure non-service roles cannot query global_stats directly. -REVOKE ALL PRIVILEGES ON TABLE public.global_stats FROM anon, -authenticated; diff --git a/supabase/migrations/20260308203352_restrict-org-status-rpc-access.sql b/supabase/migrations/20260308203352_restrict-org-status-rpc-access.sql deleted file mode 100644 index b7d274500b..0000000000 --- a/supabase/migrations/20260308203352_restrict-org-status-rpc-access.sql +++ /dev/null @@ -1,69 +0,0 @@ --- Restrict org metadata RPCs so anonymous callers cannot enumerate org IDs or infer billing status. -CREATE OR REPLACE FUNCTION "public"."is_paying_org" ("orgid" "uuid") RETURNS boolean - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = '' AS $$ -DECLARE - caller_role text; -BEGIN - SELECT current_setting('role', true) INTO caller_role; - - IF COALESCE(caller_role, '') NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - IF NOT (public.check_min_rights( - 'read'::public.user_min_right, - (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_paying_org.orgid)), - is_paying_org.orgid, - NULL::character varying, - NULL::bigint - )) THEN - RETURN false; - END IF; - END IF; - - RETURN (SELECT EXISTS ( - SELECT 1 - FROM public.stripe_info - WHERE customer_id=(SELECT customer_id FROM public.orgs WHERE id=orgid) - AND status = 'succeeded' - )); -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."is_trial_org" ("orgid" "uuid") RETURNS integer - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = '' AS $$ -DECLARE - caller_role text; -BEGIN - SELECT current_setting('role', true) INTO caller_role; - - IF COALESCE(caller_role, '') NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - IF NOT (public.check_min_rights( - 'read'::public.user_min_right, - (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_trial_org.orgid)), - is_trial_org.orgid, - NULL::character varying, - NULL::bigint - )) THEN - RETURN 0; - END IF; - END IF; - - RETURN COALESCE((SELECT GREATEST((trial_at::date - (NOW())::date), 0) - FROM public.stripe_info - WHERE customer_id=(SELECT customer_id FROM public.orgs WHERE id=orgid)), 0); -END; -$$; - -REVOKE ALL ON FUNCTION "public"."is_paying_org" ("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_paying_org" ("orgid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_paying_org" ("orgid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_paying_org" ("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_paying_org" ("orgid" "uuid") TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."is_trial_org" ("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_trial_org" ("orgid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_trial_org" ("orgid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_trial_org" ("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_trial_org" ("orgid" "uuid") TO "service_role"; diff --git a/supabase/migrations/20260311120000_allow_shared_public_images.sql b/supabase/migrations/20260311120000_allow_shared_public_images.sql deleted file mode 100644 index 5af9512566..0000000000 --- a/supabase/migrations/20260311120000_allow_shared_public_images.sql +++ /dev/null @@ -1,78 +0,0 @@ --- Allow shared signed images under images/public/* --- This extends the current private images bucket RLS without changing the --- existing app-icon, org-logo, or user-avatar ownership rules. --- Intended use case: store shared defaults like images/public/capgo.png once --- and let any client with anon/authenticated access create a signed URL. - --- SELECT -DROP POLICY IF EXISTS "Allow user or apikey to read they own folder in images" ON storage.objects; -CREATE POLICY "Allow user or apikey to read they own folder in images" -ON storage.objects -FOR SELECT -TO anon, authenticated -USING ( - bucket_id = 'images' - AND ( - -- Shared images: public/... - (storage.foldername(name))[1] = 'public' - OR ( - -- App icons: org/{org_id}/{app_id}/... - CASE - WHEN - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] IS NOT NULL - AND (storage.foldername(name))[3] <> 'logo' - THEN - public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3] - ), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ELSE FALSE - END - ) - OR ( - -- Org logos: org/{org_id}/logo/... - (storage.foldername(name))[1] = 'org' - AND (storage.foldername(name))[3] = 'logo' - AND public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - ((storage.foldername(name))[2])::uuid - ), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - ) - OR ( - -- User avatars stored under user_id/* (allow same org members) - (storage.foldername(name))[1] <> 'org' - AND (storage.foldername(name))[1] <> 'public' - AND EXISTS ( - SELECT 1 - FROM public.org_users AS ou - WHERE - ou.user_id::text - = (storage.foldername(storage.objects.name))[1] - AND public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode [], - ou.org_id - ), - ou.org_id, - NULL::character varying, - NULL::bigint - ) - ) - ) - ) -); diff --git a/supabase/migrations/20260311123000_fix_rbac_has_permission_preserve_org_for_new_app.sql b/supabase/migrations/20260311123000_fix_rbac_has_permission_preserve_org_for_new_app.sql deleted file mode 100644 index 37c8af5e7d..0000000000 --- a/supabase/migrations/20260311123000_fix_rbac_has_permission_preserve_org_for_new_app.sql +++ /dev/null @@ -1,115 +0,0 @@ -CREATE OR REPLACE FUNCTION public.rbac_has_permission( - p_principal_type text, - p_principal_id uuid, - p_permission_key text, - p_org_id uuid, - p_app_id character varying, - p_channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $function$ -DECLARE - v_org_id uuid := p_org_id; - v_app_uuid uuid; - v_app_owner_org uuid; - v_channel_uuid uuid; - v_channel_app_id text; - v_channel_org_id uuid; - v_has boolean := false; -BEGIN - IF p_permission_key IS NULL THEN - RETURN false; - END IF; - - -- Resolve scope identifiers to UUIDs. Preserve the caller org when the app does not exist yet. - IF p_app_id IS NOT NULL THEN - SELECT id, owner_org INTO v_app_uuid, v_app_owner_org - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - - IF v_app_owner_org IS NOT NULL THEN - v_org_id := v_app_owner_org; - END IF; - END IF; - - IF p_channel_id IS NOT NULL THEN - SELECT rbac_id, app_id, owner_org INTO v_channel_uuid, v_channel_app_id, v_channel_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_uuid IS NOT NULL THEN - IF v_app_uuid IS NULL THEN - SELECT id INTO v_app_uuid FROM public.apps WHERE app_id = v_channel_app_id LIMIT 1; - END IF; - IF v_org_id IS NULL THEN - v_org_id := v_channel_org_id; - END IF; - END IF; - END IF; - - WITH RECURSIVE scope_catalog AS ( - SELECT public.rbac_scope_platform()::text AS scope_type, NULL::uuid AS org_id, NULL::uuid AS app_id, NULL::uuid AS channel_id - UNION ALL - SELECT public.rbac_scope_org(), v_org_id, NULL::uuid, NULL::uuid WHERE v_org_id IS NOT NULL - UNION ALL - SELECT public.rbac_scope_app(), v_org_id, v_app_uuid, NULL::uuid WHERE v_app_uuid IS NOT NULL - UNION ALL - SELECT public.rbac_scope_channel(), v_org_id, v_app_uuid, v_channel_uuid WHERE v_channel_uuid IS NOT NULL - ), - direct_roles AS ( - SELECT rb.role_id - FROM scope_catalog s - JOIN public.role_bindings rb ON rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_platform()) OR - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.channel_id = s.channel_id) - ) - WHERE rb.principal_type = p_principal_type - AND rb.principal_id = p_principal_id - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - group_roles AS ( - SELECT rb.role_id - FROM scope_catalog s - JOIN public.group_members gm ON gm.user_id = p_principal_id - JOIN public.groups g ON g.id = gm.group_id - JOIN public.role_bindings rb ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = gm.group_id - WHERE p_principal_type = public.rbac_principal_user() - AND rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.channel_id = s.channel_id) - ) - AND (v_org_id IS NULL OR g.org_id = v_org_id) - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - combined_roles AS ( - SELECT role_id FROM direct_roles - UNION - SELECT role_id FROM group_roles - ), - role_closure AS ( - SELECT role_id FROM combined_roles - UNION - SELECT rh.child_role_id - FROM public.role_hierarchy rh - JOIN role_closure rc ON rc.role_id = rh.parent_role_id - ), - perm_set AS ( - SELECT DISTINCT p.key - FROM role_closure rc - JOIN public.role_permissions rp ON rp.role_id = rc.role_id - JOIN public.permissions p ON p.id = rp.permission_id - ) - SELECT EXISTS (SELECT 1 FROM perm_set WHERE key = p_permission_key) INTO v_has; - - RETURN v_has; -END; -$function$; diff --git a/supabase/migrations/20260311124500_fix_get_org_perm_for_apikey_rbac.sql b/supabase/migrations/20260311124500_fix_get_org_perm_for_apikey_rbac.sql deleted file mode 100644 index b5a21d02e5..0000000000 --- a/supabase/migrations/20260311124500_fix_get_org_perm_for_apikey_rbac.sql +++ /dev/null @@ -1,57 +0,0 @@ -CREATE OR REPLACE FUNCTION public.get_org_perm_for_apikey( - apikey text, app_id text -) -RETURNS text -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $function$ -<> -DECLARE - apikey_user_id uuid; - org_id uuid; - api_key record; -BEGIN - SELECT * FROM public.find_apikey_by_value(apikey) INTO api_key; - apikey_user_id := api_key.user_id; - - IF apikey_user_id IS NULL THEN - PERFORM public.pg_log('deny: INVALID_APIKEY', jsonb_build_object('app_id', get_org_perm_for_apikey.app_id)); - RETURN 'INVALID_APIKEY'; - END IF; - - SELECT owner_org - INTO org_id - FROM public.apps - WHERE apps.app_id = get_org_perm_for_apikey.app_id - LIMIT 1; - - IF org_id IS NULL THEN - PERFORM public.pg_log('deny: NO_APP', jsonb_build_object('app_id', get_org_perm_for_apikey.app_id)); - RETURN 'NO_APP'; - END IF; - - IF public.rbac_check_permission_direct(public.rbac_perm_app_transfer(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN - RETURN 'perm_owner'; - END IF; - - IF public.rbac_check_permission_direct(public.rbac_perm_app_delete(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN - RETURN 'perm_admin'; - END IF; - - IF public.rbac_check_permission_direct(public.rbac_perm_app_update_settings(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN - RETURN 'perm_write'; - END IF; - - IF public.rbac_check_permission_direct(public.rbac_perm_app_upload_bundle(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN - RETURN 'perm_upload'; - END IF; - - IF public.rbac_check_permission_direct(public.rbac_perm_app_read(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN - RETURN 'perm_read'; - END IF; - - PERFORM public.pg_log('deny: perm_none', jsonb_build_object('org_id', org_id, 'apikey_user_id', apikey_user_id)); - RETURN 'perm_none'; -END; -$function$; diff --git a/supabase/migrations/20260311150453_secure_sso_enforcement_lookup.sql b/supabase/migrations/20260311150453_secure_sso_enforcement_lookup.sql deleted file mode 100644 index 8198b00621..0000000000 --- a/supabase/migrations/20260311150453_secure_sso_enforcement_lookup.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."get_sso_enforcement_by_domain"("p_domain" text) -RETURNS TABLE("org_id" uuid, "enforce_sso" boolean) -LANGUAGE "sql" -STABLE -SECURITY DEFINER -SET "search_path" TO '' -AS $$ - SELECT - sp.org_id, - sp.enforce_sso - FROM "public"."sso_providers" sp - JOIN "public"."orgs" o ON o.id = sp.org_id - WHERE sp.domain = p_domain - AND sp.status = 'active' - AND o.sso_enabled = true - LIMIT 1; -$$; - -GRANT ALL ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) TO "anon"; -GRANT ALL ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) TO "authenticated"; -GRANT ALL ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) TO "service_role"; diff --git a/supabase/migrations/20260311162400_sync_org_user_delete_role_bindings.sql b/supabase/migrations/20260311162400_sync_org_user_delete_role_bindings.sql deleted file mode 100644 index 55328c410f..0000000000 --- a/supabase/migrations/20260311162400_sync_org_user_delete_role_bindings.sql +++ /dev/null @@ -1,188 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."resync_org_user_role_bindings"( - "p_user_id" "uuid", - "p_org_id" "uuid" -) RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_org_user "public"."org_users"%ROWTYPE; - role_name_to_bind text; - role_id_to_bind uuid; - org_member_role_id uuid; - app_role_name text; - app_role_id uuid; - v_app RECORD; - v_app_uuid uuid; - v_channel_uuid uuid; - v_granted_by uuid; - v_sync_reason text := 'Synced from org_users'; -BEGIN - DELETE FROM "public"."role_bindings" - WHERE "principal_type" = "public"."rbac_principal_user"() - AND "principal_id" = p_user_id - AND "org_id" = p_org_id - AND "reason" IN ( - 'Synced from org_users', - 'Updated from org_users', - 'Migrated from org_users (legacy)' - ); - - FOR v_org_user IN - SELECT * - FROM "public"."org_users" - WHERE "user_id" = p_user_id - AND "org_id" = p_org_id - LOOP - v_granted_by := COALESCE("auth"."uid"(), v_org_user.user_id); - - IF v_org_user.app_id IS NULL AND v_org_user.channel_id IS NULL THEN - IF v_org_user.user_right IN ("public"."rbac_right_super_admin"(), "public"."rbac_right_admin"()) THEN - CASE v_org_user.user_right - WHEN "public"."rbac_right_super_admin"() THEN role_name_to_bind := "public"."rbac_role_org_super_admin"(); - WHEN "public"."rbac_right_admin"() THEN role_name_to_bind := "public"."rbac_role_org_admin"(); - END CASE; - - SELECT id INTO role_id_to_bind - FROM "public"."roles" - WHERE "name" = role_name_to_bind - LIMIT 1; - - IF role_id_to_bind IS NOT NULL THEN - INSERT INTO "public"."role_bindings" ( - "principal_type", "principal_id", "role_id", "scope_type", "org_id", - "granted_by", "granted_at", "reason", "is_direct" - ) VALUES ( - "public"."rbac_principal_user"(), v_org_user.user_id, role_id_to_bind, "public"."rbac_scope_org"(), v_org_user.org_id, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - ELSIF v_org_user.user_right IN ("public"."rbac_right_read"(), "public"."rbac_right_upload"(), "public"."rbac_right_write"()) THEN - SELECT id INTO org_member_role_id - FROM "public"."roles" - WHERE "name" = "public"."rbac_role_org_member"() - LIMIT 1; - - IF org_member_role_id IS NOT NULL THEN - INSERT INTO "public"."role_bindings" ( - "principal_type", "principal_id", "role_id", "scope_type", "org_id", - "granted_by", "granted_at", "reason", "is_direct" - ) VALUES ( - "public"."rbac_principal_user"(), v_org_user.user_id, org_member_role_id, "public"."rbac_scope_org"(), v_org_user.org_id, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - - CASE v_org_user.user_right - WHEN "public"."rbac_right_read"() THEN app_role_name := "public"."rbac_role_app_reader"(); - WHEN "public"."rbac_right_upload"() THEN app_role_name := "public"."rbac_role_app_uploader"(); - WHEN "public"."rbac_right_write"() THEN app_role_name := "public"."rbac_role_app_developer"(); - END CASE; - - SELECT id INTO app_role_id - FROM "public"."roles" - WHERE "name" = app_role_name - LIMIT 1; - - IF app_role_id IS NOT NULL THEN - FOR v_app IN - SELECT id - FROM "public"."apps" - WHERE "owner_org" = v_org_user.org_id - LOOP - INSERT INTO "public"."role_bindings" ( - "principal_type", "principal_id", "role_id", "scope_type", "org_id", "app_id", - "granted_by", "granted_at", "reason", "is_direct" - ) VALUES ( - "public"."rbac_principal_user"(), v_org_user.user_id, app_role_id, "public"."rbac_scope_app"(), v_org_user.org_id, v_app.id, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END LOOP; - END IF; - END IF; - ELSIF v_org_user.app_id IS NOT NULL AND v_org_user.channel_id IS NULL THEN - CASE v_org_user.user_right - WHEN "public"."rbac_right_super_admin"() THEN role_name_to_bind := "public"."rbac_role_app_admin"(); - WHEN "public"."rbac_right_admin"() THEN role_name_to_bind := "public"."rbac_role_app_admin"(); - WHEN "public"."rbac_right_write"() THEN role_name_to_bind := "public"."rbac_role_app_developer"(); - WHEN "public"."rbac_right_upload"() THEN role_name_to_bind := "public"."rbac_role_app_uploader"(); - WHEN "public"."rbac_right_read"() THEN role_name_to_bind := "public"."rbac_role_app_reader"(); - ELSE role_name_to_bind := "public"."rbac_role_app_reader"(); - END CASE; - - SELECT id INTO role_id_to_bind - FROM "public"."roles" - WHERE "name" = role_name_to_bind - LIMIT 1; - - SELECT id INTO v_app_uuid - FROM "public"."apps" - WHERE "app_id" = v_org_user.app_id - LIMIT 1; - - IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL THEN - INSERT INTO "public"."role_bindings" ( - "principal_type", "principal_id", "role_id", "scope_type", "org_id", "app_id", - "granted_by", "granted_at", "reason", "is_direct" - ) VALUES ( - "public"."rbac_principal_user"(), v_org_user.user_id, role_id_to_bind, "public"."rbac_scope_app"(), v_org_user.org_id, v_app_uuid, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - ELSIF v_org_user.app_id IS NOT NULL AND v_org_user.channel_id IS NOT NULL THEN - CASE v_org_user.user_right - WHEN "public"."rbac_right_super_admin"() THEN role_name_to_bind := "public"."rbac_role_channel_admin"(); - WHEN "public"."rbac_right_admin"() THEN role_name_to_bind := "public"."rbac_role_channel_admin"(); - WHEN "public"."rbac_right_write"() THEN role_name_to_bind := 'channel_developer'; - WHEN "public"."rbac_right_upload"() THEN role_name_to_bind := 'channel_uploader'; - WHEN "public"."rbac_right_read"() THEN role_name_to_bind := "public"."rbac_role_channel_reader"(); - ELSE role_name_to_bind := "public"."rbac_role_channel_reader"(); - END CASE; - - SELECT id INTO role_id_to_bind - FROM "public"."roles" - WHERE "name" = role_name_to_bind - LIMIT 1; - - SELECT id INTO v_app_uuid - FROM "public"."apps" - WHERE "app_id" = v_org_user.app_id - LIMIT 1; - - SELECT "rbac_id" INTO v_channel_uuid - FROM "public"."channels" - WHERE "id" = v_org_user.channel_id - LIMIT 1; - - IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL AND v_channel_uuid IS NOT NULL THEN - INSERT INTO "public"."role_bindings" ( - "principal_type", "principal_id", "role_id", "scope_type", "org_id", "app_id", "channel_id", - "granted_by", "granted_at", "reason", "is_direct" - ) VALUES ( - "public"."rbac_principal_user"(), v_org_user.user_id, role_id_to_bind, "public"."rbac_scope_channel"(), v_org_user.org_id, v_app_uuid, v_channel_uuid, - v_granted_by, now(), v_sync_reason, true - ) ON CONFLICT DO NOTHING; - END IF; - END IF; - END LOOP; -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."sync_org_user_role_binding_on_delete"() -RETURNS "trigger" -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -BEGIN - PERFORM "public"."resync_org_user_role_bindings"(OLD.user_id, OLD.org_id); - RETURN OLD; -END; -$$; - -DROP TRIGGER IF EXISTS "sync_org_user_role_binding_on_delete" ON "public"."org_users"; -CREATE TRIGGER "sync_org_user_role_binding_on_delete" -AFTER DELETE ON "public"."org_users" -FOR EACH ROW -EXECUTE FUNCTION "public"."sync_org_user_role_binding_on_delete"(); diff --git a/supabase/migrations/20260311164503_split_is_admin_platform_admin_and_rls.sql b/supabase/migrations/20260311164503_split_is_admin_platform_admin_and_rls.sql deleted file mode 100644 index be9d1c7350..0000000000 --- a/supabase/migrations/20260311164503_split_is_admin_platform_admin_and_rls.sql +++ /dev/null @@ -1,1086 +0,0 @@ --- Define platform admin detection as the single canonical platform-admin helper -CREATE OR REPLACE FUNCTION public.is_platform_admin(userid uuid) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - admin_ids_jsonb jsonb; - is_platform_admin_from_secret boolean; - mfa_verified boolean; -BEGIN - SELECT public.verify_mfa() INTO mfa_verified; - IF NOT mfa_verified THEN - RETURN false; - END IF; - - SELECT decrypted_secret::jsonb INTO admin_ids_jsonb - FROM vault.decrypted_secrets - WHERE name = 'admin_users'; - - is_platform_admin_from_secret := COALESCE(admin_ids_jsonb ? userid::text, false); - - RETURN is_platform_admin_from_secret; -END; -$$; - -ALTER FUNCTION public.is_platform_admin(userid uuid) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION public.is_platform_admin() -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - RETURN public.is_platform_admin((SELECT auth.uid())); -END; -$$; - -ALTER FUNCTION public.is_platform_admin() OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.is_platform_admin(userid uuid) FROM public; -REVOKE ALL ON FUNCTION public.is_platform_admin() FROM public; -GRANT ALL ON FUNCTION public.is_platform_admin(userid uuid) TO service_role; -GRANT ALL ON FUNCTION public.is_platform_admin() TO authenticated; -GRANT ALL ON FUNCTION public.is_platform_admin() TO service_role; - -COMMENT ON FUNCTION public.is_platform_admin( - uuid -) IS 'Checks platform admin status from admin_users and requires MFA.'; - --- --------------------------------------------------------------------------- --- RLS migration: --- Remove legacy policy-level admin checks by rewriting them to literal false. --- --------------------------------------------------------------------------- -DO $$ -DECLARE - v_policy RECORD; - v_roles TEXT; - v_using TEXT; - v_with_check TEXT; - v_roles_sql TEXT; - v_cmd TEXT; -BEGIN - FOR v_policy IN - SELECT * - FROM pg_policies - WHERE schemaname = 'public' - AND ( - qual LIKE '%is_admin%' - OR with_check LIKE '%is_admin%' - ) - LOOP - v_using := COALESCE(v_policy.qual, ''); - v_with_check := COALESCE(v_policy.with_check, ''); - v_roles_sql := ''; - - v_using := replace(v_using, 'public.is_admin(auth_user.uid)', 'false'); - v_using := replace(v_using, 'public.is_admin(auth.uid())', 'false'); - v_using := replace(v_using, '"public"."is_admin"("auth_user"."uid")', 'false'); - v_using := replace(v_using, 'public.is_admin((SELECT auth.uid()))', 'false'); - v_using := replace(v_using, '"public"."is_admin"((SELECT auth.uid()))', 'false'); - v_using := replace(v_using, 'is_admin(auth_user.uid)', 'false'); - v_using := replace(v_using, 'is_admin(auth.uid())', 'false'); - v_using := replace(v_using, 'is_admin((SELECT auth.uid()))', 'false'); - - v_with_check := replace(v_with_check, 'public.is_admin(auth_user.uid)', 'false'); - v_with_check := replace(v_with_check, 'public.is_admin(auth.uid())', 'false'); - v_with_check := replace(v_with_check, '"public"."is_admin"("auth_user"."uid")', 'false'); - v_with_check := replace(v_with_check, 'public.is_admin((SELECT auth.uid()))', 'false'); - v_with_check := replace(v_with_check, '"public"."is_admin"((SELECT auth.uid()))', 'false'); - v_with_check := replace(v_with_check, 'is_admin(auth_user.uid)', 'false'); - v_with_check := replace(v_with_check, 'is_admin(auth.uid())', 'false'); - v_with_check := replace(v_with_check, 'is_admin((SELECT auth.uid()))', 'false'); - - IF v_using = v_policy.qual AND v_with_check = COALESCE(v_policy.with_check, '') THEN - CONTINUE; - END IF; - - IF array_length(v_policy.roles, 1) > 0 THEN - SELECT string_agg(format('%I', policy_role), ', ') - INTO v_roles - FROM unnest(v_policy.roles) AS x(policy_role); - v_roles_sql := format(' TO %s', v_roles); - END IF; - - v_using := NULLIF(BTRIM(v_using), ''); - v_with_check := NULLIF(BTRIM(v_with_check), ''); - - IF v_using IS NULL THEN - v_using := 'true'; - END IF; - - IF v_policy.with_check IS NOT NULL AND v_with_check IS NULL THEN - v_with_check := 'true'; - END IF; - - IF v_policy.cmd = 'INSERT' THEN - IF v_with_check IS NULL THEN - v_with_check := 'true'; - END IF; - v_cmd := format( - 'ALTER POLICY %I ON %I.%I', - v_policy.policyname, - v_policy.schemaname, - v_policy.tablename - ); - v_cmd := v_cmd || v_roles_sql || format(' WITH CHECK (%s)', v_with_check); - ELSIF v_policy.with_check IS NOT NULL AND v_policy.cmd IN ('UPDATE', 'ALL') THEN - v_cmd := format( - 'ALTER POLICY %I ON %I.%I', - v_policy.policyname, - v_policy.schemaname, - v_policy.tablename - ); - v_cmd := v_cmd || v_roles_sql || format(' USING (%s) WITH CHECK (%s)', v_using, v_with_check); - ELSIF v_policy.cmd = 'SELECT' OR v_policy.cmd = 'DELETE' OR v_policy.cmd = 'UPDATE' THEN - IF v_using IS NULL THEN - v_using := 'true'; - END IF; - v_cmd := format( - 'ALTER POLICY %I ON %I.%I', - v_policy.policyname, - v_policy.schemaname, - v_policy.tablename - ); - v_cmd := v_cmd || v_roles_sql || format(' USING (%s)', v_using); - ELSE - v_cmd := format( - 'ALTER POLICY %I ON %I.%I', - v_policy.policyname, - v_policy.schemaname, - v_policy.tablename - ); - v_cmd := v_cmd || v_roles_sql || format(' USING (%s)', v_using); - END IF; - - EXECUTE v_cmd; - END LOOP; -END -$$; - --- --------------------------------------------------------------------------- --- Lock rbac_settings behind deny-all RLS. Only internal SECURITY DEFINER --- helpers should read it. --- --------------------------------------------------------------------------- -ALTER TABLE public.rbac_settings ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS rbac_settings_read_authenticated ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_admin_all ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_select ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_insert ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_update ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_delete ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_no_select ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_no_insert ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_no_update ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_no_delete ON public.rbac_settings; - -CREATE POLICY rbac_settings_no_select ON public.rbac_settings -FOR SELECT -TO public -USING (false); - -CREATE POLICY rbac_settings_no_insert ON public.rbac_settings -FOR INSERT -TO public -WITH CHECK (false); - -CREATE POLICY rbac_settings_no_update ON public.rbac_settings -FOR UPDATE -TO public -USING (false) -WITH CHECK (false); - -CREATE POLICY rbac_settings_no_delete ON public.rbac_settings -FOR DELETE -TO public -USING (false); - --- --------------------------------------------------------------------------- --- Remove the deprecated platform RBAC scope from live data and prevent new --- platform-scoped roles, permissions, and bindings. --- --------------------------------------------------------------------------- -DELETE FROM public.role_bindings -WHERE scope_type = public.rbac_scope_platform(); - -DELETE FROM public.permissions -WHERE scope_type = public.rbac_scope_platform(); - -DELETE FROM public.roles -WHERE scope_type = public.rbac_scope_platform(); - -DROP INDEX IF EXISTS public.role_bindings_platform_scope_uniq; - -ALTER TABLE public.roles DROP CONSTRAINT IF EXISTS roles_scope_type_no_platform; -ALTER TABLE public.permissions -DROP CONSTRAINT IF EXISTS permissions_scope_type_no_platform; -ALTER TABLE public.role_bindings -DROP CONSTRAINT IF EXISTS role_bindings_scope_type_no_platform; - -ALTER TABLE public.roles -ADD CONSTRAINT roles_scope_type_no_platform -CHECK (scope_type <> public.rbac_scope_platform()); - -ALTER TABLE public.permissions -ADD CONSTRAINT permissions_scope_type_no_platform -CHECK (scope_type <> public.rbac_scope_platform()); - -ALTER TABLE public.role_bindings -ADD CONSTRAINT role_bindings_scope_type_no_platform -CHECK (scope_type <> public.rbac_scope_platform()); - -CREATE OR REPLACE FUNCTION public.rbac_has_permission( - p_principal_type text, - p_principal_id uuid, - p_permission_key text, - p_org_id uuid, - p_app_id character varying, - p_channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $function$ -DECLARE - v_org_id uuid := p_org_id; - v_app_uuid uuid; - v_app_owner_org uuid; - v_channel_uuid uuid; - v_channel_app_id text; - v_channel_org_id uuid; - v_has boolean := false; -BEGIN - IF p_permission_key IS NULL THEN - RETURN false; - END IF; - - -- Resolve scope identifiers to UUIDs. Preserve the caller org when the app does not exist yet. - IF p_app_id IS NOT NULL THEN - SELECT id, owner_org INTO v_app_uuid, v_app_owner_org - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - - IF v_app_owner_org IS NOT NULL THEN - v_org_id := v_app_owner_org; - END IF; - END IF; - - IF p_channel_id IS NOT NULL THEN - SELECT rbac_id, app_id, owner_org INTO v_channel_uuid, v_channel_app_id, v_channel_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_uuid IS NOT NULL THEN - IF v_app_uuid IS NULL THEN - SELECT id INTO v_app_uuid FROM public.apps WHERE app_id = v_channel_app_id LIMIT 1; - END IF; - IF v_org_id IS NULL THEN - v_org_id := v_channel_org_id; - END IF; - END IF; - END IF; - - WITH RECURSIVE scope_catalog AS ( - SELECT public.rbac_scope_org()::text AS scope_type, v_org_id AS org_id, NULL::uuid AS app_id, NULL::uuid AS channel_id WHERE v_org_id IS NOT NULL - UNION ALL - SELECT public.rbac_scope_app(), v_org_id, v_app_uuid, NULL::uuid WHERE v_app_uuid IS NOT NULL - UNION ALL - SELECT public.rbac_scope_channel(), v_org_id, v_app_uuid, v_channel_uuid WHERE v_channel_uuid IS NOT NULL - ), - direct_roles AS ( - SELECT rb.role_id - FROM scope_catalog s - JOIN public.role_bindings rb ON rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.channel_id = s.channel_id) - ) - WHERE rb.principal_type = p_principal_type - AND rb.principal_id = p_principal_id - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - group_roles AS ( - SELECT rb.role_id - FROM scope_catalog s - JOIN public.group_members gm ON gm.user_id = p_principal_id - JOIN public.groups g ON g.id = gm.group_id - JOIN public.role_bindings rb ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = gm.group_id - WHERE p_principal_type = public.rbac_principal_user() - AND rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.channel_id = s.channel_id) - ) - AND (v_org_id IS NULL OR g.org_id = v_org_id) - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - combined_roles AS ( - SELECT role_id FROM direct_roles - UNION - SELECT role_id FROM group_roles - ), - role_closure AS ( - SELECT role_id FROM combined_roles - UNION - SELECT rh.child_role_id - FROM public.role_hierarchy rh - JOIN role_closure rc ON rc.role_id = rh.parent_role_id - ), - perm_set AS ( - SELECT DISTINCT p.key - FROM role_closure rc - JOIN public.role_permissions rp ON rp.role_id = rc.role_id - JOIN public.permissions p ON p.id = rp.permission_id - ) - SELECT EXISTS (SELECT 1 FROM perm_set WHERE key = p_permission_key) INTO v_has; - - RETURN v_has; -END; -$function$; - -CREATE OR REPLACE FUNCTION public.is_user_org_admin( - p_user_id uuid, - p_org_id uuid -) -RETURNS boolean -LANGUAGE sql -SECURITY DEFINER -STABLE -SET search_path = '' -AS $$ - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - AND rb.org_id = p_org_id - AND rb.scope_type = public.rbac_scope_org() - AND r.name IN (public.rbac_role_org_super_admin(), public.rbac_role_org_admin()) - ); -$$; - -CREATE OR REPLACE FUNCTION public.is_user_app_admin( - p_user_id uuid, - p_app_id uuid -) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -STABLE -SET search_path = '' -AS $$ -DECLARE - v_org_id uuid; -BEGIN - SELECT owner_org INTO v_org_id - FROM public.apps - WHERE id = p_app_id - LIMIT 1; - - IF v_org_id IS NULL THEN - RETURN false; - END IF; - - RETURN EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - AND ( - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = p_app_id) - OR (rb.scope_type = public.rbac_scope_org() AND rb.org_id = v_org_id) - ) - AND r.name IN (public.rbac_role_app_admin(), public.rbac_role_org_super_admin(), public.rbac_role_org_admin()) - ); -END; -$$; - -CREATE OR REPLACE FUNCTION public.check_org_user_privileges() RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' -AS $$ -DECLARE - v_is_super_admin boolean := false; - v_use_rbac boolean := false; - v_enforcing_2fa boolean := false; -BEGIN - -- Allow service_role / postgres to bypass - IF (((SELECT auth.jwt() ->> 'role') = 'service_role') OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres')) THEN - RETURN NEW; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(NEW.org_id); - - IF v_use_rbac THEN - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - JOIN public.roles r ON r.id = rb.role_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = auth.uid() - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = NEW.org_id - AND r.name = public.rbac_role_org_super_admin() - ) INTO v_is_super_admin; - - IF v_is_super_admin THEN - SELECT enforcing_2fa INTO v_enforcing_2fa - FROM public.orgs - WHERE id = NEW.org_id; - - IF v_enforcing_2fa AND NOT public.has_2fa_enabled(auth.uid()) THEN - PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); - v_is_super_admin := false; - END IF; - END IF; - ELSE - v_is_super_admin := public.check_min_rights( - 'super_admin'::public.user_min_right, - (SELECT auth.uid()), - NEW.org_id, - NULL::character varying, - NULL::bigint - ); - END IF; - - IF v_is_super_admin THEN - RETURN NEW; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'super_admin'::public.user_min_right THEN - PERFORM public.pg_log('deny: ELEVATE_SUPER_ADMIN', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - IF NEW.user_right IS NOT DISTINCT FROM 'invite_super_admin'::public.user_min_right THEN - PERFORM public.pg_log('deny: ELEVATE_INVITE_SUPER_ADMIN', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); - RAISE EXCEPTION 'Admins cannot elevate privileges!'; - END IF; - - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.invite_user_to_org( - "email" character varying, - "org_id" uuid, - "invite_type" public.user_min_right -) RETURNS character varying -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - org record; - invited_user record; - current_record record; - current_tmp_user record; - calling_user_id uuid; - v_is_super_admin boolean := false; - v_use_rbac boolean := false; -BEGIN - -- Get the calling user's ID. - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id) - INTO calling_user_id; - - -- Treat missing orgs as unauthorized to avoid org existence enumeration. - SELECT * INTO org FROM public.orgs WHERE public.orgs.id = invite_user_to_org.org_id; - IF org IS NULL OR calling_user_id IS NULL THEN - RETURN 'NO_RIGHTS'; - END IF; - - -- Check if user has at least public.rbac_right_admin() rights. - IF NOT public.check_min_rights(public.rbac_right_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN - PERFORM public.pg_log('deny: NO_RIGHTS_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type)); - RETURN 'NO_RIGHTS'; - END IF; - - -- If inviting as super_admin, caller must be super_admin. - IF (invite_type = public.rbac_right_super_admin()::public.user_min_right OR invite_type = public.rbac_right_invite_super_admin()::public.user_min_right) THEN - v_use_rbac := public.rbac_is_enabled_for_org(invite_user_to_org.org_id); - - IF v_use_rbac THEN - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - JOIN public.roles r ON r.id = rb.role_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = calling_user_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = invite_user_to_org.org_id - AND r.name = public.rbac_role_org_super_admin() - ) INTO v_is_super_admin; - - IF NOT v_is_super_admin THEN - PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type)); - RETURN 'NO_RIGHTS'; - END IF; - - IF org.enforcing_2fa AND NOT public.has_2fa_enabled(calling_user_id) THEN - PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type, 'uid', calling_user_id)); - RETURN 'NO_RIGHTS'; - END IF; - ELSE - IF NOT public.check_min_rights(public.rbac_right_super_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN - PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type)); - RETURN 'NO_RIGHTS'; - END IF; - END IF; - END IF; - - -- Check if user already exists. - SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email = invite_user_to_org.email; - - IF invited_user IS NOT NULL THEN - -- User exists, check if already in org. - SELECT public.org_users.id INTO current_record - FROM public.org_users - WHERE public.org_users.user_id = invited_user.id - AND public.org_users.org_id = invite_user_to_org.org_id; - - IF current_record IS NOT NULL THEN - RETURN 'ALREADY_INVITED'; - ELSE - -- Add user to org. - INSERT INTO public.org_users (user_id, org_id, user_right) - VALUES (invited_user.id, invite_user_to_org.org_id, invite_type); - RETURN 'OK'; - END IF; - ELSE - -- User doesn't exist, check tmp_users for pending invitations. - SELECT * INTO current_tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email = invite_user_to_org.email - AND public.tmp_users.org_id = invite_user_to_org.org_id; - - IF current_tmp_user IS NOT NULL THEN - -- Invitation already exists. - IF current_tmp_user.cancelled_at IS NOT NULL THEN - -- Invitation was cancelled, check if recent. - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - -- No invitation exists, need to create one (handled elsewhere). - RETURN 'NO_EMAIL'; - END IF; - END IF; -END; -$$; - --- --------------------------------------------------------------------------- --- Explicitly rebuild the known RBAC policies that historically referenced --- public.is_admin(). Relying only on pg_policies text replacement is brittle --- because PostgreSQL can deparse policy expressions differently across --- environments. --- --------------------------------------------------------------------------- -DROP POLICY IF EXISTS roles_insert ON public.roles; -DROP POLICY IF EXISTS roles_update ON public.roles; -DROP POLICY IF EXISTS roles_delete ON public.roles; - -CREATE POLICY roles_insert ON public.roles -FOR INSERT -TO authenticated -WITH CHECK (false); - -CREATE POLICY roles_update ON public.roles -FOR UPDATE -TO authenticated -USING (false); - -CREATE POLICY roles_delete ON public.roles -FOR DELETE -TO authenticated -USING (false); - -DROP POLICY IF EXISTS permissions_insert ON public.permissions; -DROP POLICY IF EXISTS permissions_update ON public.permissions; -DROP POLICY IF EXISTS permissions_delete ON public.permissions; - -CREATE POLICY permissions_insert ON public.permissions -FOR INSERT -TO authenticated -WITH CHECK (false); - -CREATE POLICY permissions_update ON public.permissions -FOR UPDATE -TO authenticated -USING (false); - -CREATE POLICY permissions_delete ON public.permissions -FOR DELETE -TO authenticated -USING (false); - -DROP POLICY IF EXISTS role_permissions_insert ON public.role_permissions; -DROP POLICY IF EXISTS role_permissions_update ON public.role_permissions; -DROP POLICY IF EXISTS role_permissions_delete ON public.role_permissions; - -CREATE POLICY role_permissions_insert ON public.role_permissions -FOR INSERT -TO authenticated -WITH CHECK (false); - -CREATE POLICY role_permissions_update ON public.role_permissions -FOR UPDATE -TO authenticated -USING (false); - -CREATE POLICY role_permissions_delete ON public.role_permissions -FOR DELETE -TO authenticated -USING (false); - -DROP POLICY IF EXISTS role_hierarchy_insert ON public.role_hierarchy; -DROP POLICY IF EXISTS role_hierarchy_update ON public.role_hierarchy; -DROP POLICY IF EXISTS role_hierarchy_delete ON public.role_hierarchy; - -CREATE POLICY role_hierarchy_insert ON public.role_hierarchy -FOR INSERT -TO authenticated -WITH CHECK (false); - -CREATE POLICY role_hierarchy_update ON public.role_hierarchy -FOR UPDATE -TO authenticated -USING (false); - -CREATE POLICY role_hierarchy_delete ON public.role_hierarchy -FOR DELETE -TO authenticated -USING (false); - -DROP POLICY IF EXISTS groups_select ON public.groups; -DROP POLICY IF EXISTS groups_insert ON public.groups; -DROP POLICY IF EXISTS groups_update ON public.groups; -DROP POLICY IF EXISTS groups_delete ON public.groups; - -CREATE POLICY groups_select ON public.groups -FOR SELECT -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE EXISTS ( - SELECT 1 FROM public.org_users - WHERE - org_users.org_id = groups.org_id - AND org_users.user_id = actor_ref.current_uid - ) - ) -); - -CREATE POLICY groups_insert ON public.groups -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - groups.org_id, - null::varchar, - null::bigint - ) - ) -); - -CREATE POLICY groups_update ON public.groups -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - groups.org_id, - null::varchar, - null::bigint - ) - ) -); - -CREATE POLICY groups_delete ON public.groups -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - groups.org_id, - null::varchar, - null::bigint - ) - ) -); - -DROP POLICY IF EXISTS group_members_select ON public.group_members; -DROP POLICY IF EXISTS group_members_insert ON public.group_members; -DROP POLICY IF EXISTS group_members_update ON public.group_members; -DROP POLICY IF EXISTS group_members_delete ON public.group_members; - -CREATE POLICY group_members_select ON public.group_members -FOR SELECT -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE EXISTS ( - SELECT 1 FROM public.groups - INNER JOIN public.org_users ON groups.org_id = org_users.org_id - WHERE - groups.id = group_members.group_id - AND org_users.user_id = actor_ref.current_uid - ) - ) -); - -CREATE POLICY group_members_insert ON public.group_members -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE EXISTS ( - SELECT 1 FROM public.groups - WHERE - groups.id = group_members.group_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - groups.org_id, - null::varchar, - null::bigint - ) - ) - ) -); - -CREATE POLICY group_members_update ON public.group_members -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE EXISTS ( - SELECT 1 FROM public.groups - WHERE - groups.id = group_members.group_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - groups.org_id, - null::varchar, - null::bigint - ) - ) - ) -); - -CREATE POLICY group_members_delete ON public.group_members -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE EXISTS ( - SELECT 1 FROM public.groups - WHERE - groups.id = group_members.group_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - groups.org_id, - null::varchar, - null::bigint - ) - ) - ) -); - -DROP POLICY IF EXISTS role_bindings_select ON public.role_bindings; -DROP POLICY IF EXISTS role_bindings_insert ON public.role_bindings; -DROP POLICY IF EXISTS role_bindings_update ON public.role_bindings; -DROP POLICY IF EXISTS role_bindings_delete ON public.role_bindings; - -CREATE POLICY role_bindings_select ON public.role_bindings -FOR SELECT -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE - public.is_user_org_admin( - actor_ref.current_uid, - role_bindings.org_id - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_app() - AND public.is_user_app_admin( - actor_ref.current_uid, - role_bindings.app_id - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_app() - AND role_bindings.app_id IS NOT null - AND public.user_has_role_in_app( - actor_ref.current_uid, - role_bindings.app_id - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_channel() - AND role_bindings.channel_id IS NOT null - AND EXISTS ( - SELECT 1 FROM public.channels AS c - INNER JOIN public.apps AS a ON c.app_id = a.app_id - WHERE - c.rbac_id = role_bindings.channel_id - AND public.is_user_app_admin( - actor_ref.current_uid, - a.id - ) - ) - ) - ) -); - -CREATE POLICY role_bindings_insert ON public.role_bindings -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE - ( - role_bindings.scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - role_bindings.org_id, - null::varchar, - null::bigint - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_app() - AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND ( - public.check_min_rights( - ( - public.rbac_right_admin() - )::public.user_min_right, - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - apps.app_id, - null::bigint - ) - OR - public.user_has_app_update_user_roles( - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.id - ) - ) - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_channel() - AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - channels.app_id, - channels.id - ) - ) - ) - ) -); - -CREATE POLICY role_bindings_update ON public.role_bindings -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE - ( - role_bindings.scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - role_bindings.org_id, - null::varchar, - null::bigint - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_app() - AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND ( - public.check_min_rights( - ( - public.rbac_right_admin() - )::public.user_min_right, - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - apps.app_id, - null::bigint - ) - OR - public.user_has_app_update_user_roles( - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.id - ) - ) - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_channel() - AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - public.get_identity_org_appid( - '{all}'::public.key_mode [], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - channels.app_id, - channels.id - ) - ) - ) - ) -); - -CREATE POLICY role_bindings_delete ON public.role_bindings -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 FROM (SELECT auth.uid() AS current_uid) AS actor_ref - WHERE - ( - role_bindings.scope_type = public.rbac_scope_org() - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - role_bindings.org_id, - null::varchar, - null::bigint - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_app() - AND EXISTS ( - SELECT 1 FROM public.apps - WHERE - apps.id = role_bindings.app_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - apps.owner_org, - apps.app_id, - null::bigint - ) - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_channel() - AND EXISTS ( - SELECT 1 FROM public.channels - INNER JOIN public.apps ON channels.app_id = apps.app_id - WHERE - channels.rbac_id = role_bindings.channel_id - AND public.check_min_rights( - public.rbac_right_admin()::public.user_min_right, - actor_ref.current_uid, - apps.owner_org, - channels.app_id, - channels.id - ) - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_app() - AND public.user_has_app_update_user_roles( - actor_ref.current_uid, - role_bindings.app_id - ) - ) - OR - ( - role_bindings.scope_type = public.rbac_scope_app() - AND role_bindings.principal_type = public.rbac_principal_user() - AND role_bindings.principal_id = actor_ref.current_uid - ) - ) -); - -DROP FUNCTION IF EXISTS public.is_admin(userid uuid); -DROP FUNCTION IF EXISTS public.is_admin(); diff --git a/supabase/migrations/20260312000000_remove_rbac_security_settings_singletons.sql b/supabase/migrations/20260312000000_remove_rbac_security_settings_singletons.sql deleted file mode 100644 index 0b90e6863e..0000000000 --- a/supabase/migrations/20260312000000_remove_rbac_security_settings_singletons.sql +++ /dev/null @@ -1,168 +0,0 @@ --- ============================================================================ --- Use environment variables instead of singleton settings tables. --- ============================================================================ - --- Drop any policies that may have been created on the legacy setting tables. -DROP POLICY IF EXISTS rbac_settings_read_authenticated ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_admin_all ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_select ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_insert ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_update ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_delete ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_no_select ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_no_insert ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_no_update ON public.rbac_settings; -DROP POLICY IF EXISTS rbac_settings_no_delete ON public.rbac_settings; -DROP POLICY IF EXISTS "Deny access to security settings" ON public.security_settings; - --- Remove singleton tables. -DROP TABLE IF EXISTS public.rbac_settings CASCADE; -DROP TABLE IF EXISTS public.security_settings CASCADE; - --- ============================================================================ --- RBAC global setting from environment --- ============================================================================ - -CREATE OR REPLACE FUNCTION public.is_rbac_enabled_globally() -RETURNS boolean -LANGUAGE plpgsql -STABLE -SET search_path = '' -AS $$ -DECLARE - v_setting text; -BEGIN - SELECT decrypted_secret - INTO v_setting - FROM vault.decrypted_secrets - WHERE name = 'CAPGO_RBAC_ENABLED' - LIMIT 1; - - IF v_setting IS NULL OR btrim(v_setting) = '' THEN - RETURN false; - END IF; - - RETURN lower(v_setting) IN ('1', 'true', 'on', 'yes'); -END; -$$; - -CREATE OR REPLACE FUNCTION public.rbac_is_enabled_for_org(p_org_id uuid) RETURNS boolean -LANGUAGE plpgsql -SET search_path = '' -AS $$ -DECLARE - v_org_enabled boolean; -BEGIN - SELECT use_new_rbac INTO v_org_enabled FROM public.orgs WHERE id = p_org_id; - RETURN COALESCE(v_org_enabled, false) OR public.is_rbac_enabled_globally(); -END; -$$; - -COMMENT ON FUNCTION public.rbac_is_enabled_for_org(uuid) IS - 'Feature-flag gate for RBAC. Defaults to false; true when org or global env setting is enabled.'; - --- ============================================================================ --- Email OTP enforcement threshold from environment --- ============================================================================ - -CREATE OR REPLACE FUNCTION public.get_mfa_email_otp_enforced_at() -RETURNS timestamptz -LANGUAGE plpgsql -STABLE -SET search_path = '' -AS $$ -DECLARE - v_setting text; -BEGIN - SELECT decrypted_secret - INTO v_setting - FROM vault.decrypted_secrets - WHERE name = 'CAPGO_MFA_EMAIL_OTP_ENFORCED_AT' - LIMIT 1; - - IF v_setting IS NULL OR btrim(v_setting) = '' THEN - RETURN NULL; - END IF; - - BEGIN - RETURN v_setting::timestamptz; - EXCEPTION WHEN others THEN - RETURN NULL; - END; -END; -$$; - -DO $$ -DECLARE - v_can_manage_auth boolean := has_schema_privilege('auth', 'CREATE'); -BEGIN - IF NOT v_can_manage_auth THEN - RAISE NOTICE 'Skipping auth.enforce_email_otp_for_mfa setup (insufficient privileges on auth schema)'; - RETURN; - END IF; - - BEGIN - CREATE OR REPLACE FUNCTION "auth"."enforce_email_otp_for_mfa"() RETURNS trigger - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $body$ - DECLARE - otp_ok boolean; - enforced_at timestamptz; - user_created_at timestamptz; - BEGIN - enforced_at := public.get_mfa_email_otp_enforced_at(); - - IF enforced_at IS NOT NULL THEN - SELECT auth.users.created_at - INTO user_created_at - FROM auth.users - WHERE auth.users.id = NEW.user_id; - - IF user_created_at IS NOT NULL AND user_created_at < enforced_at THEN - RETURN NEW; - END IF; - END IF; - - IF TG_OP = 'INSERT' THEN - otp_ok := public.is_recent_email_otp_verified(NEW.user_id); - IF NOT otp_ok THEN - RAISE EXCEPTION 'email otp verification required for mfa enrollment'; - END IF; - RETURN NEW; - END IF; - - IF TG_OP = 'UPDATE' - AND (NEW.status IS DISTINCT FROM OLD.status) - AND NEW.status = 'verified' THEN - otp_ok := public.is_recent_email_otp_verified(NEW.user_id); - IF NOT otp_ok THEN - RAISE EXCEPTION 'email otp verification required for mfa enrollment'; - END IF; - END IF; - - RETURN NEW; - END; - $body$; - EXCEPTION - WHEN insufficient_privilege THEN - RAISE NOTICE 'Skipping auth.enforce_email_otp_for_mfa setup (insufficient privileges)'; - RETURN; - WHEN OTHERS THEN - RAISE NOTICE 'Skipping auth.enforce_email_otp_for_mfa setup: %', SQLERRM; - RETURN; - END; - - BEGIN - EXECUTE 'ALTER FUNCTION "auth"."enforce_email_otp_for_mfa"() OWNER TO "postgres"'; - EXECUTE 'DROP TRIGGER IF EXISTS "trg_enforce_email_otp_for_mfa" ON auth.mfa_factors'; - EXECUTE 'CREATE TRIGGER "trg_enforce_email_otp_for_mfa" BEFORE INSERT OR UPDATE ON auth.mfa_factors FOR EACH ROW EXECUTE FUNCTION auth.enforce_email_otp_for_mfa()'; - EXCEPTION - WHEN insufficient_privilege THEN - RAISE NOTICE 'Skipping auth.mfa_factors trigger setup (insufficient privileges)'; - END; -EXCEPTION - WHEN insufficient_privilege THEN - RAISE NOTICE 'Skipping auth.mfa_factors trigger setup (insufficient privileges)'; -END; -$$; diff --git a/supabase/migrations/20260312183000_normalize_sso_provider_domain_lowercase.sql b/supabase/migrations/20260312183000_normalize_sso_provider_domain_lowercase.sql deleted file mode 100644 index eab82111ce..0000000000 --- a/supabase/migrations/20260312183000_normalize_sso_provider_domain_lowercase.sql +++ /dev/null @@ -1,92 +0,0 @@ --- Migration: Normalize SSO provider domains to lowercase and remove citext dependency --- This migration can be applied after SSO provider support is enabled. - --- Make sure existing data is persisted as lowercase text -ALTER TABLE public.sso_providers -ALTER COLUMN domain TYPE text USING lower(btrim(domain)); - --- Enforce lowercase values for all future writes -ALTER TABLE public.sso_providers -DROP CONSTRAINT IF EXISTS sso_providers_domain_lowercase_check; - -ALTER TABLE public.sso_providers -ADD CONSTRAINT sso_providers_domain_lowercase_check -CHECK (domain = lower(btrim(domain))); - --- Remove citext only after no longer needed by sso_providers.domain -DROP EXTENSION IF EXISTS "citext"; - -CREATE OR REPLACE FUNCTION public.normalize_sso_provider_domain() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' -AS $$ -BEGIN - NEW.domain := lower(btrim(NEW.domain)); - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.normalize_sso_provider_domain() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION public.normalize_sso_provider_domain() FROM PUBLIC; - -DROP TRIGGER IF EXISTS normalize_sso_provider_domain_before_upsert ON public.sso_providers; -CREATE TRIGGER normalize_sso_provider_domain_before_upsert -BEFORE INSERT OR UPDATE OF domain -ON public.sso_providers -FOR EACH ROW -EXECUTE FUNCTION public.normalize_sso_provider_domain(); - --- Keep SSO lookups deterministic for caller-supplied email domain values -CREATE OR REPLACE FUNCTION public.check_domain_sso(p_domain text) -RETURNS TABLE ( - has_sso boolean, - provider_id text, - org_id uuid -) -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = '' -AS $$ - SELECT - true AS has_sso, - sp.provider_id, - sp.org_id - FROM public.sso_providers AS sp - JOIN public.orgs AS o ON o.id = sp.org_id - WHERE sp."domain" = lower(btrim(p_domain)) - AND sp.status = 'active' - AND o.sso_enabled = true - LIMIT 1; -$$; - -ALTER FUNCTION public.check_domain_sso(text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION public.check_domain_sso(text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.check_domain_sso(text) TO anon; -GRANT EXECUTE ON FUNCTION public.check_domain_sso(text) TO authenticated; -GRANT EXECUTE ON FUNCTION public.check_domain_sso(text) TO service_role; - -CREATE OR REPLACE FUNCTION "public"."get_sso_enforcement_by_domain"("p_domain" text) -RETURNS TABLE("org_id" uuid, "enforce_sso" boolean) -LANGUAGE "sql" -STABLE -SECURITY DEFINER -SET "search_path" TO '' -AS $$ - SELECT - sp.org_id, - sp.enforce_sso - FROM "public"."sso_providers" sp - JOIN "public"."orgs" o ON o.id = sp.org_id - WHERE sp.domain = lower(btrim(p_domain)) - AND sp.status = 'active' - AND o.sso_enabled = true - LIMIT 1; -$$; - -ALTER FUNCTION "public"."get_sso_enforcement_by_domain"(text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) TO anon; -GRANT EXECUTE ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) TO authenticated; -GRANT EXECUTE ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) TO service_role; diff --git a/supabase/migrations/20260312202155_hardening_get_identity_apikey_only_rpcs.sql b/supabase/migrations/20260312202155_hardening_get_identity_apikey_only_rpcs.sql deleted file mode 100644 index 3bce75e247..0000000000 --- a/supabase/migrations/20260312202155_hardening_get_identity_apikey_only_rpcs.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Revoke public/unauthenticated exposure of API key identity helper RPC. --- Keeping SERVICE_ROLE access is required for internal backend paths that still --- rely on this helper for authorization checks. - -REVOKE ALL ON FUNCTION public.get_identity_apikey_only( - keymode public.key_mode [] -) FROM public; - -REVOKE ALL ON FUNCTION public.get_identity_apikey_only( - keymode public.key_mode [] -) FROM anon; - -REVOKE ALL ON FUNCTION public.get_identity_apikey_only( - keymode public.key_mode [] -) FROM authenticated; - -GRANT EXECUTE ON FUNCTION public.get_identity_apikey_only( - keymode public.key_mode [] -) TO service_role; - -GRANT EXECUTE ON FUNCTION public.get_identity_apikey_only( - keymode public.key_mode [] -) TO postgres; diff --git a/supabase/migrations/20260312202212_fix_rescind_invitation_rpc_access_hardening.sql b/supabase/migrations/20260312202212_fix_rescind_invitation_rpc_access_hardening.sql deleted file mode 100644 index 564edf66be..0000000000 --- a/supabase/migrations/20260312202212_fix_rescind_invitation_rpc_access_hardening.sql +++ /dev/null @@ -1,61 +0,0 @@ --- Fix rescind_invitation RPC security hardening: --- keep function security-definer behavior but block unauthenticated access --- and avoid leaking org existence via distinct messages. -CREATE OR REPLACE FUNCTION "public"."rescind_invitation" ("email" TEXT, "org_id" UUID) -RETURNS varchar -LANGUAGE plpgsql -SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - tmp_user record; -BEGIN - IF NOT ( - public.check_min_rights( - 'admin'::public.user_min_right, - ( - SELECT public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - rescind_invitation.org_id - ) - ), - rescind_invitation.org_id, - NULL::varchar, - NULL::bigint - ) - ) THEN - RETURN 'NO_RIGHTS'; - END IF; - - PERFORM 1 - FROM public.orgs - WHERE public.orgs.id = rescind_invitation.org_id; - IF NOT FOUND THEN - RETURN 'NO_RIGHTS'; - END IF; - - SELECT * INTO tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email = rescind_invitation.email - AND public.tmp_users.org_id = rescind_invitation.org_id - FOR UPDATE; - IF NOT FOUND THEN - RETURN 'NO_INVITATION'; - END IF; - - IF tmp_user.cancelled_at IS NOT NULL THEN - RETURN 'ALREADY_CANCELLED'; - END IF; - - UPDATE public.tmp_users - SET cancelled_at = CURRENT_TIMESTAMP - WHERE public.tmp_users.id = tmp_user.id; - RETURN 'OK'; -END; -$$; - -REVOKE ALL ON FUNCTION "public"."rescind_invitation" (TEXT, UUID) FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."rescind_invitation" (TEXT, UUID) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."rescind_invitation" (TEXT, UUID) FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."rescind_invitation" (TEXT, UUID) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."rescind_invitation" (TEXT, UUID) TO "service_role"; diff --git a/supabase/migrations/20260312202227_fix_rbac_org_user_access_null_auth_gate.sql b/supabase/migrations/20260312202227_fix_rbac_org_user_access_null_auth_gate.sql deleted file mode 100644 index d1f3e671d5..0000000000 --- a/supabase/migrations/20260312202227_fix_rbac_org_user_access_null_auth_gate.sql +++ /dev/null @@ -1,68 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."get_org_user_access_rbac"(p_user_id uuid, p_org_id uuid) -RETURNS TABLE ( - id uuid, - principal_type text, - principal_id uuid, - role_id uuid, - role_name text, - role_description text, - scope_type text, - org_id uuid, - app_id uuid, - channel_id uuid, - granted_at timestamptz, - granted_by uuid, - expires_at timestamptz, - reason text, - is_direct boolean, - principal_name text, - user_email text, - group_name text -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - IF auth.uid() IS NULL THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_BINDINGS'; - END IF; - - IF auth.uid() IS DISTINCT FROM p_user_id AND NOT public.rbac_check_permission_direct(public.rbac_perm_org_read(), auth.uid(), p_org_id, NULL::text, NULL::bigint) THEN - RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_BINDINGS'; - END IF; - - RETURN QUERY - SELECT - rb.id, - rb.principal_type, - rb.principal_id, - rb.role_id, - r.name as role_name, - r.description as role_description, - rb.scope_type, - rb.org_id, - rb.app_id, - rb.channel_id, - rb.granted_at, - rb.granted_by, - rb.expires_at, - rb.reason, - rb.is_direct, - CASE - WHEN rb.principal_type = public.rbac_principal_user() THEN u.email::text - WHEN rb.principal_type = public.rbac_principal_group() THEN g.name::text - ELSE rb.principal_id::text - END as principal_name, - u.email::text as user_email, - g.name::text as group_name - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - LEFT JOIN public.users u ON rb.principal_type = public.rbac_principal_user() AND rb.principal_id = u.id - LEFT JOIN public.groups g ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = g.id - WHERE rb.org_id = p_org_id - AND rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = p_user_id - ORDER BY rb.granted_at DESC; -END; -$$; diff --git a/supabase/migrations/20260312202250_cli_created_record_build_time_public_revoke_fix.sql b/supabase/migrations/20260312202250_cli_created_record_build_time_public_revoke_fix.sql deleted file mode 100644 index 907bdcb4b3..0000000000 --- a/supabase/migrations/20260312202250_cli_created_record_build_time_public_revoke_fix.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Ensure record_build_time cannot be executed by SQL PUBLIC role. - -REVOKE ALL ON FUNCTION public.record_build_time( - uuid, - uuid, - character varying, - character varying, - bigint -) FROM public; diff --git a/supabase/migrations/20260313104400_fix_get_current_plan_max_org_access_cli.sql b/supabase/migrations/20260313104400_fix_get_current_plan_max_org_access_cli.sql deleted file mode 100644 index 326ac2b5bf..0000000000 --- a/supabase/migrations/20260313104400_fix_get_current_plan_max_org_access_cli.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Restrict get_current_plan_max_org to authorized org callers --- Security fix for GHSA-v3jp-r95g-x4mm - -CREATE OR REPLACE FUNCTION public.get_current_plan_max_org( - orgid uuid -) RETURNS TABLE ( - mau bigint, - bandwidth bigint, - storage bigint, - build_time_unit bigint -) LANGUAGE plpgsql STABLE SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - v_request_user uuid; - v_is_service_role boolean; -BEGIN - v_is_service_role := ( - ((SELECT auth.jwt() ->> 'role') = 'service_role') - OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') - ); - - IF NOT v_is_service_role THEN - v_request_user := public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - get_current_plan_max_org.orgid - ); - - IF v_request_user IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - v_request_user, - get_current_plan_max_org.orgid, - NULL::varchar, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: NO_RIGHTS', - pg_catalog.jsonb_build_object( - 'orgid', - get_current_plan_max_org.orgid, - 'uid', - v_request_user - ) - ); - RETURN; - END IF; - END IF; - - RETURN QUERY - SELECT p.mau, p.bandwidth, p.storage, p.build_time_unit - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; -END; -$$; - -ALTER FUNCTION public.get_current_plan_max_org(uuid) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_current_plan_max_org(uuid) FROM public; -REVOKE ALL ON FUNCTION public.get_current_plan_max_org(uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.get_current_plan_max_org(uuid) -TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_current_plan_max_org(uuid) TO service_role; diff --git a/supabase/migrations/20260313104427_webhook-api-key-org-scope-cli.sql b/supabase/migrations/20260313104427_webhook-api-key-org-scope-cli.sql deleted file mode 100644 index e8a762f6bf..0000000000 --- a/supabase/migrations/20260313104427_webhook-api-key-org-scope-cli.sql +++ /dev/null @@ -1,242 +0,0 @@ --- ============================================================================= --- Migration: Enforce API-key scoped org checks when API key header is present --- --- If an authenticated user provides both a user session and a limited API key, we --- must evaluate permissions against the API key identity first. This prevents user --- session rights from bypassing org/app key scope and leaking webhook secrets. --- ============================================================================= - -CREATE OR REPLACE FUNCTION "public"."get_identity_org_allowed_apikey_only" ( - "keymode" "public"."key_mode" [], - "org_id" uuid -) RETURNS uuid -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key record; -BEGIN - SELECT "public"."get_apikey_header"() into api_key_text; - - -- No api key found in headers, return - IF api_key_text IS NULL THEN - PERFORM public.pg_log('deny: IDENTITY_ORG_NO_AUTH', jsonb_build_object('org_id', org_id)); - RETURN NULL; - END IF; - - -- Use find_apikey_by_value to support both plain and hashed keys - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - -- Check if key was found (api_key.id will be NULL if no match) and mode matches - IF api_key.id IS NOT NULL AND api_key.mode = ANY(keymode) THEN - -- Check if key is expired - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: IDENTITY_ORG_EXPIRED', jsonb_build_object('key_id', api_key.id, 'org_id', org_id)); - RETURN NULL; - END IF; - - -- Check org restrictions - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: IDENTITY_ORG_UNALLOWED', jsonb_build_object('org_id', org_id)); - RETURN NULL; - END IF; - END IF; - - RETURN api_key.user_id; - END IF; - - PERFORM public.pg_log('deny: IDENTITY_ORG_NO_MATCH', jsonb_build_object('org_id', org_id)); - RETURN NULL; -END; -$$; - -DROP POLICY IF EXISTS "Allow admin to select webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to insert webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to update webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to delete webhooks" ON public.webhooks; - -CREATE POLICY "Allow admin to select webhooks" -ON public.webhooks -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN public.get_apikey_header() IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode[], - org_id - ) - ELSE auth.uid() - END, - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhooks" -ON public.webhooks -FOR INSERT -TO authenticated, anon -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN public.get_apikey_header() IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode[], - org_id - ) - ELSE auth.uid() - END, - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to update webhooks" -ON public.webhooks -FOR UPDATE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN public.get_apikey_header() IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode[], - org_id - ) - ELSE auth.uid() - END, - org_id, - null::character varying, - null::bigint - ) -) -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN public.get_apikey_header() IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode[], - org_id - ) - ELSE auth.uid() - END, - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to delete webhooks" -ON public.webhooks -FOR DELETE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN public.get_apikey_header() IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode[], - org_id - ) - ELSE auth.uid() - END, - org_id, - null::character varying, - null::bigint - ) -); - -DROP POLICY IF EXISTS "Allow org members to select webhook_deliveries" ON public.webhook_deliveries; -DROP POLICY IF EXISTS "Allow admin to insert webhook_deliveries" ON public.webhook_deliveries; -DROP POLICY IF EXISTS "Allow admin to update webhook_deliveries" ON public.webhook_deliveries; - -CREATE POLICY "Allow org members to select webhook_deliveries" -ON public.webhook_deliveries -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'read'::public.user_min_right, - CASE - WHEN public.get_apikey_header() IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{read,write,upload,all}'::public.key_mode[], - org_id - ) - ELSE auth.uid() - END, - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhook_deliveries" -ON public.webhook_deliveries -FOR INSERT -TO authenticated, anon -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN public.get_apikey_header() IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode[], - org_id - ) - ELSE auth.uid() - END, - org_id, - null::character varying, - null::bigint - ) -); - -CREATE POLICY "Allow admin to update webhook_deliveries" -ON public.webhook_deliveries -FOR UPDATE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN public.get_apikey_header() IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode[], - org_id - ) - ELSE auth.uid() - END, - org_id, - null::character varying, - null::bigint - ) -) -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN public.get_apikey_header() IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode[], - org_id - ) - ELSE auth.uid() - END, - org_id, - null::character varying, - null::bigint - ) -); diff --git a/supabase/migrations/20260313121928_fix-onboarding-needed-org-nonexistent.sql b/supabase/migrations/20260313121928_fix-onboarding-needed-org-nonexistent.sql deleted file mode 100644 index 76ba8741da..0000000000 --- a/supabase/migrations/20260313121928_fix-onboarding-needed-org-nonexistent.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Keep onboarding-needed checks false for missing org IDs to avoid org existence disclosure. -CREATE OR REPLACE FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" = '' - AS $$ -BEGIN - RETURN ( - EXISTS ( - SELECT 1 FROM public.orgs - WHERE id = is_onboarding_needed_org.orgid - ) - AND - NOT public.is_onboarded_org(is_onboarding_needed_org.orgid) - AND public.is_trial_org(is_onboarding_needed_org.orgid) = 0 - ); -END; -$$; diff --git a/supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql b/supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql deleted file mode 100644 index 52591c7931..0000000000 --- a/supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql +++ /dev/null @@ -1,111 +0,0 @@ --- Harden version metadata writes against cross-tenant RPC abuse. -REVOKE ALL ON FUNCTION "public"."upsert_version_meta"("p_app_id" character varying, "p_version_id" bigint, "p_size" bigint) -FROM - "public", - public, - "anon", - "authenticated"; - -GRANT -EXECUTE ON FUNCTION "public"."upsert_version_meta"( - "p_app_id" character varying, - "p_version_id" bigint, - "p_size" bigint -) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."upsert_version_meta"( - "p_app_id" character varying, - "p_version_id" bigint, - "p_size" bigint -) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' -AS $$ -DECLARE - v_owner_org uuid; - v_caller_id uuid; - v_existing_count integer; - v_version_exists boolean; -BEGIN - IF p_size = 0 THEN - RETURN FALSE; - END IF; - - SELECT owner_org - INTO v_owner_org - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - - IF v_owner_org IS NULL THEN - RETURN FALSE; - END IF; - - SELECT EXISTS ( - SELECT 1 - FROM public.app_versions av - WHERE av.app_id = p_app_id - AND av.id = p_version_id - ) - INTO v_version_exists; - - IF NOT v_version_exists THEN - RETURN FALSE; - END IF; - - IF COALESCE(current_setting('role', true), '') NOT IN ('service_role', 'postgres') - AND COALESCE(session_user, current_user) NOT IN ('service_role', 'postgres') THEN - SELECT public.get_identity_org_appid('{write,all}'::public.key_mode[], v_owner_org, p_app_id) - INTO v_caller_id; - - IF v_caller_id IS NULL THEN - RETURN FALSE; - END IF; - - IF NOT public.check_min_rights( - 'write'::public.user_min_right, - v_caller_id, - v_owner_org, - p_app_id, - NULL::bigint - ) THEN - RETURN FALSE; - END IF; - END IF; - - -- Check if a row already exists for this app_id/version_id with same sign. - IF p_size > 0 THEN - SELECT COUNT(*) INTO v_existing_count - FROM public.version_meta - WHERE public.version_meta.app_id = p_app_id - AND public.version_meta.version_id = p_version_id - AND public.version_meta.size > 0; - ELSIF p_size < 0 THEN - SELECT COUNT(*) INTO v_existing_count - FROM public.version_meta - WHERE public.version_meta.app_id = p_app_id - AND public.version_meta.version_id = p_version_id - AND public.version_meta.size < 0; - END IF; - - -- If row already exists, do nothing and return false. - IF v_existing_count > 0 THEN - RETURN FALSE; - END IF; - - INSERT INTO public.version_meta (app_id, version_id, size) - VALUES ( - p_app_id, - p_version_id, - p_size - ); - - RETURN TRUE; - -EXCEPTION - WHEN unique_violation THEN - RETURN FALSE; -END; -$$; - -ALTER FUNCTION "public"."upsert_version_meta"("p_app_id" character varying, "p_version_id" bigint, "p_size" bigint) OWNER TO "postgres"; diff --git a/supabase/migrations/20260316132841_move_mfa_email_otp_trigger_to_public.sql b/supabase/migrations/20260316132841_move_mfa_email_otp_trigger_to_public.sql deleted file mode 100644 index 23886987e5..0000000000 --- a/supabase/migrations/20260316132841_move_mfa_email_otp_trigger_to_public.sql +++ /dev/null @@ -1,121 +0,0 @@ --- ============================================================================ --- Move MFA email OTP enforcement trigger function out of the auth schema. --- ============================================================================ - -CREATE OR REPLACE FUNCTION public.enforce_email_otp_for_mfa() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - otp_ok boolean; - enforced_at timestamptz; - user_created_at timestamptz; -BEGIN - enforced_at := public.get_mfa_email_otp_enforced_at(); - - IF enforced_at IS NOT NULL THEN - SELECT auth.users.created_at - INTO user_created_at - FROM auth.users - WHERE auth.users.id = NEW.user_id; - - IF user_created_at IS NOT NULL AND user_created_at < enforced_at THEN - RETURN NEW; - END IF; - END IF; - - IF TG_OP = 'INSERT' THEN - otp_ok := public.is_recent_email_otp_verified(NEW.user_id); - IF NOT otp_ok THEN - RAISE EXCEPTION 'email otp verification required for mfa enrollment'; - END IF; - RETURN NEW; - END IF; - - IF TG_OP = 'UPDATE' - AND (NEW.status IS DISTINCT FROM OLD.status) - AND NEW.status = 'verified' THEN - otp_ok := public.is_recent_email_otp_verified(NEW.user_id); - IF NOT otp_ok THEN - RAISE EXCEPTION 'email otp verification required for mfa enrollment'; - END IF; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.enforce_email_otp_for_mfa() OWNER TO postgres; -REVOKE ALL ON FUNCTION public.enforce_email_otp_for_mfa() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.enforce_email_otp_for_mfa() FROM anon; -REVOKE ALL ON FUNCTION public.enforce_email_otp_for_mfa() FROM authenticated; -REVOKE ALL ON FUNCTION public.enforce_email_otp_for_mfa() FROM service_role; -GRANT EXECUTE ON FUNCTION public.enforce_email_otp_for_mfa() TO postgres; - -DO $$ -DECLARE - v_can_manage_auth_trigger boolean := has_schema_privilege(current_user, 'auth', 'USAGE') - AND has_table_privilege(current_user, 'auth.mfa_factors', 'TRIGGER') - AND has_function_privilege(current_user, 'public.enforce_email_otp_for_mfa()', 'EXECUTE'); -BEGIN - IF NOT v_can_manage_auth_trigger THEN - RAISE NOTICE 'Skipping auth.mfa_factors trigger rewrite (insufficient privileges)'; - RETURN; - END IF; - - EXECUTE 'DROP TRIGGER IF EXISTS trg_enforce_email_otp_for_mfa ON auth.mfa_factors'; - EXECUTE 'CREATE TRIGGER trg_enforce_email_otp_for_mfa BEFORE INSERT OR UPDATE ON auth.mfa_factors FOR EACH ROW EXECUTE FUNCTION public.enforce_email_otp_for_mfa()'; -END; -$$; - -DO $$ -DECLARE - v_has_legacy_auth_function boolean := EXISTS ( - SELECT 1 - FROM pg_proc proc - JOIN pg_namespace ns ON ns.oid = proc.pronamespace - WHERE ns.nspname = 'auth' - AND proc.proname = 'enforce_email_otp_for_mfa' - AND COALESCE(pg_get_function_identity_arguments(proc.oid), '') = '' - ); - v_can_drop_legacy_auth_function boolean := has_schema_privilege(current_user, 'auth', 'USAGE') - AND EXISTS ( - SELECT 1 - FROM pg_proc proc - JOIN pg_namespace ns ON ns.oid = proc.pronamespace - WHERE ns.nspname = 'auth' - AND proc.proname = 'enforce_email_otp_for_mfa' - AND COALESCE(pg_get_function_identity_arguments(proc.oid), '') = '' - AND pg_get_userbyid(proc.proowner) = current_user - ); - v_legacy_auth_function_has_dependents boolean := EXISTS ( - SELECT 1 - FROM pg_depend dep - JOIN pg_proc proc ON proc.oid = dep.refobjid - JOIN pg_namespace ns ON ns.oid = proc.pronamespace - WHERE ns.nspname = 'auth' - AND proc.proname = 'enforce_email_otp_for_mfa' - AND COALESCE(pg_get_function_identity_arguments(proc.oid), '') = '' - AND dep.deptype IN ('n', 'a', 'i') - AND dep.classid <> 'pg_proc'::regclass - ); -BEGIN - IF NOT v_has_legacy_auth_function THEN - RETURN; - END IF; - - IF NOT v_can_drop_legacy_auth_function THEN - RAISE NOTICE 'Skipping cleanup of auth.enforce_email_otp_for_mfa() (insufficient privileges)'; - RETURN; - END IF; - - IF v_legacy_auth_function_has_dependents THEN - RAISE NOTICE 'Skipping cleanup of auth.enforce_email_otp_for_mfa() (still referenced by another object)'; - RETURN; - END IF; - - EXECUTE 'DROP FUNCTION auth.enforce_email_otp_for_mfa()'; -END; -$$; diff --git a/supabase/migrations/20260316220423_harden_plan_usage_org_rpc_access.sql b/supabase/migrations/20260316220423_harden_plan_usage_org_rpc_access.sql deleted file mode 100644 index fe7f0bb0fe..0000000000 --- a/supabase/migrations/20260316220423_harden_plan_usage_org_rpc_access.sql +++ /dev/null @@ -1,337 +0,0 @@ --- Harden plan/billing org RPCs against cross-tenant and anonymous access. --- Security fix for GHSA-wh77-4qcm-f8j6. - -CREATE OR REPLACE FUNCTION public.get_current_plan_name_org(orgid uuid) -RETURNS character varying -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_request_user uuid; - v_is_service_role boolean; -BEGIN - v_is_service_role := ( - ((SELECT auth.jwt() ->> 'role') = 'service_role') - OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') - ); - - IF NOT v_is_service_role THEN - v_request_user := public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - get_current_plan_name_org.orgid - ); - - IF v_request_user IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - v_request_user, - get_current_plan_name_org.orgid, - NULL::varchar, - NULL::bigint - ) THEN - RETURN NULL; - END IF; - END IF; - - RETURN ( - SELECT p.name - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid - LIMIT 1 - ); -END; -$$; - -ALTER FUNCTION public.get_current_plan_name_org(uuid) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_current_plan_name_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_current_plan_name_org(uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.get_current_plan_name_org(uuid) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_current_plan_name_org(uuid) TO service_role; -COMMENT ON FUNCTION public.get_current_plan_name_org(uuid) IS - 'Return the Stripe plan name for the supplied organization after enforcing read-level access; returns NULL when the org is missing or the caller is unauthorized.'; - -CREATE OR REPLACE FUNCTION public.get_cycle_info_org(orgid uuid) -RETURNS TABLE ( - subscription_anchor_start timestamptz, - subscription_anchor_end timestamptz -) -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - customer_id_var text; - stripe_info_row public.stripe_info%ROWTYPE; - anchor_day interval; - start_date timestamptz; - end_date timestamptz; - v_request_user uuid; - v_is_service_role boolean; -BEGIN - v_is_service_role := ( - ((SELECT auth.jwt() ->> 'role') = 'service_role') - OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') - ); - - IF NOT v_is_service_role THEN - v_request_user := public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - get_cycle_info_org.orgid - ); - - IF v_request_user IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - v_request_user, - get_cycle_info_org.orgid, - NULL::varchar, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - SELECT customer_id - INTO customer_id_var - FROM public.orgs - WHERE id = orgid; - - SELECT * - INTO stripe_info_row - FROM public.stripe_info - WHERE customer_id = customer_id_var; - - anchor_day := COALESCE( - stripe_info_row.subscription_anchor_start - date_trunc('MONTH', stripe_info_row.subscription_anchor_start), - '0 DAYS'::interval - ); - - IF anchor_day > now() - date_trunc('MONTH', now()) THEN - start_date := date_trunc('MONTH', now() - interval '1 MONTH') + anchor_day; - ELSE - start_date := date_trunc('MONTH', now()) + anchor_day; - END IF; - - end_date := start_date + interval '1 MONTH'; - - RETURN QUERY - SELECT start_date, end_date; -END; -$$; - -ALTER FUNCTION public.get_cycle_info_org(uuid) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_cycle_info_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_cycle_info_org(uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.get_cycle_info_org(uuid) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_cycle_info_org(uuid) TO service_role; -COMMENT ON FUNCTION public.get_cycle_info_org(uuid) IS - 'Return the billing cycle start and end for the supplied organization after verifying read access, using Stripe anchor dates to compute the boundaries.'; - -CREATE OR REPLACE FUNCTION public.get_plan_usage_percent_detailed(orgid uuid) -RETURNS TABLE ( - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_start_date date; - v_end_date date; - v_plan_mau bigint; - v_plan_bandwidth bigint; - v_plan_storage bigint; - v_plan_build_time bigint; - v_anchor_day interval; - total_stats record; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; - percent_build_time double precision; - v_request_user uuid; - v_is_service_role boolean; - v_tx_read_only boolean := current_setting('transaction_read_only') = 'on'; -BEGIN - v_is_service_role := ( - ((SELECT auth.jwt() ->> 'role') = 'service_role') - OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') - ); - - IF NOT v_is_service_role THEN - v_request_user := public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - get_plan_usage_percent_detailed.orgid - ); - - IF v_request_user IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - v_request_user, - get_plan_usage_percent_detailed.orgid, - NULL::varchar, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - SELECT - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::interval), - p.mau, - p.bandwidth, - p.storage, - p.build_time_unit - INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; - - IF v_anchor_day > now() - date_trunc('MONTH', now()) THEN - v_start_date := (date_trunc('MONTH', now() - interval '1 MONTH') + v_anchor_day)::date; - ELSE - v_start_date := (date_trunc('MONTH', now()) + v_anchor_day)::date; - END IF; - v_end_date := (v_start_date + interval '1 MONTH')::date; - - IF v_tx_read_only THEN - -- User-facing RPCs must stay read-only so they work from the hardened - -- read-only test harness and replica paths. Internal cache refreshes still - -- happen through get_total_metrics()/get_plan_usage_and_fit(). - SELECT * - INTO total_stats - FROM public.calculate_org_metrics_cache_entry(orgid, v_start_date, v_end_date); - ELSE - SELECT * - INTO total_stats - FROM public.get_total_metrics(orgid, v_start_date, v_end_date); - END IF; - - percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); - - RETURN QUERY - SELECT - GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, - percent_bandwidth, - percent_storage, - percent_build_time; -END; -$$; - -ALTER FUNCTION public.get_plan_usage_percent_detailed(uuid) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.get_plan_usage_percent_detailed(uuid) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_plan_usage_percent_detailed(uuid) TO service_role; -COMMENT ON FUNCTION public.get_plan_usage_percent_detailed(uuid) IS - 'Return current-cycle plan usage percentages (total and per metric) for the supplied organization while respecting read permissions and delegating to cached metrics when running in read-only transactions.'; - -CREATE OR REPLACE FUNCTION public.get_plan_usage_percent_detailed( - orgid uuid, - cycle_start date, - cycle_end date -) -RETURNS TABLE ( - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_plan_mau bigint; - v_plan_bandwidth bigint; - v_plan_storage bigint; - v_plan_build_time bigint; - total_stats record; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; - percent_build_time double precision; - v_request_user uuid; - v_is_service_role boolean; - v_tx_read_only boolean := current_setting('transaction_read_only') = 'on'; -BEGIN - v_is_service_role := ( - ((SELECT auth.jwt() ->> 'role') = 'service_role') - OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') - ); - - IF NOT v_is_service_role THEN - v_request_user := public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - get_plan_usage_percent_detailed.orgid - ); - - IF v_request_user IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - v_request_user, - get_plan_usage_percent_detailed.orgid, - NULL::varchar, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - SELECT p.mau, p.bandwidth, p.storage, p.build_time_unit - INTO v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; - - IF v_tx_read_only THEN - -- Keep this RPC read-only for authenticated callers. Cache refreshes are - -- handled by the internal metrics helpers instead of this public entrypoint. - SELECT * - INTO total_stats - FROM public.calculate_org_metrics_cache_entry(orgid, cycle_start, cycle_end); - ELSE - SELECT * - INTO total_stats - FROM public.get_total_metrics(orgid, cycle_start, cycle_end); - END IF; - - percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); - - RETURN QUERY - SELECT - GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, - percent_bandwidth, - percent_storage, - percent_build_time; -END; -$$; - -ALTER FUNCTION public.get_plan_usage_percent_detailed(uuid, date, date) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid, date, date) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid, date, date) FROM anon; -GRANT EXECUTE ON FUNCTION public.get_plan_usage_percent_detailed(uuid, date, date) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_plan_usage_percent_detailed(uuid, date, date) TO service_role; -COMMENT ON FUNCTION public.get_plan_usage_percent_detailed(uuid, date, date) IS - 'Return plan usage percentages for the supplied date range after verifying read access; read-only callers stay read-only by using the cached metrics helper.'; diff --git a/supabase/migrations/20260317020451_secure_remaining_helper_rpcs.sql b/supabase/migrations/20260317020451_secure_remaining_helper_rpcs.sql deleted file mode 100644 index e46fb36472..0000000000 --- a/supabase/migrations/20260317020451_secure_remaining_helper_rpcs.sql +++ /dev/null @@ -1,567 +0,0 @@ --- Harden remaining helper RPCs from GHSA-hc74 by adding caller-aware authz --- checks and revoking unnecessary anonymous access on self-only helpers. - -REVOKE ALL ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_canceled_org"("orgid" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_canceled_org.orgid) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - is_canceled_org.orgid, - NULL::character varying, - NULL::bigint - ) THEN - RETURN false; - END IF; - END IF; - - RETURN ( - SELECT EXISTS ( - SELECT 1 - FROM public.stripe_info - WHERE customer_id = (SELECT customer_id FROM public.orgs WHERE id = orgid) - AND status = 'canceled' - ) - ); -END; -$$; - -REVOKE ALL ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") RETURNS boolean - LANGUAGE "plpgsql" STABLE SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_product_id text; - v_start_date date; - v_end_date date; - v_plan_name text; - total_metrics RECORD; - v_anchor_day interval; - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_good_plan_v5_org.orgid) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - is_good_plan_v5_org.orgid, - NULL::character varying, - NULL::bigint - ) THEN - RETURN false; - END IF; - END IF; - - SELECT - si.product_id, - COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::interval) - INTO v_product_id, v_anchor_day - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE o.id = orgid; - - IF v_anchor_day > now() - date_trunc('MONTH', now()) THEN - v_start_date := (date_trunc('MONTH', now() - interval '1 MONTH') + v_anchor_day)::date; - ELSE - v_start_date := (date_trunc('MONTH', now()) + v_anchor_day)::date; - END IF; - v_end_date := (v_start_date + interval '1 MONTH')::date; - - SELECT p.name INTO v_plan_name - FROM public.plans p - WHERE p.stripe_id = v_product_id; - - IF v_plan_name = 'Enterprise' THEN - RETURN true; - END IF; - - SELECT * INTO total_metrics - FROM public.get_total_metrics(orgid, v_start_date, v_end_date); - - RETURN EXISTS ( - SELECT 1 - FROM public.plans p - WHERE p.name = v_plan_name - AND p.mau >= total_metrics.mau - AND p.bandwidth >= total_metrics.bandwidth - AND p.storage >= total_metrics.storage - AND p.build_time_unit >= COALESCE(total_metrics.build_time_unit, 0) - ); -END; -$$; - -REVOKE ALL ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_onboarded_org"("orgid" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_onboarded_org.orgid) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - is_onboarded_org.orgid, - NULL::character varying, - NULL::bigint - ) THEN - RETURN false; - END IF; - END IF; - - RETURN ( - SELECT EXISTS (SELECT 1 FROM public.apps WHERE owner_org = orgid) - ) AND ( - SELECT EXISTS (SELECT 1 FROM public.app_versions WHERE owner_org = orgid) - ); -END; -$$; - -REVOKE ALL ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" = '' - AS $$ -DECLARE - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_onboarding_needed_org.orgid) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - is_onboarding_needed_org.orgid, - NULL::character varying, - NULL::bigint - ) THEN - RETURN false; - END IF; - END IF; - - RETURN ( - EXISTS ( - SELECT 1 FROM public.orgs - WHERE id = is_onboarding_needed_org.orgid - ) - AND - NOT public.is_onboarded_org(is_onboarding_needed_org.orgid) - AND public.is_trial_org(is_onboarding_needed_org.orgid) = 0 - ); -END; -$$; - -REVOKE ALL ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_org_yearly"("orgid" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - is_yearly boolean; - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_org_yearly.orgid) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - is_org_yearly.orgid, - NULL::character varying, - NULL::bigint - ) THEN - RETURN false; - END IF; - END IF; - - SELECT - CASE - WHEN si.price_id = p.price_y_id THEN true - ELSE false - END INTO is_yearly - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid - LIMIT 1; - - RETURN COALESCE(is_yearly, false); -END; -$$; - -REVOKE ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_paying_and_good_plan_org.orgid) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - is_paying_and_good_plan_org.orgid, - NULL::character varying, - NULL::bigint - ) THEN - RETURN false; - END IF; - END IF; - - RETURN ( - SELECT - EXISTS ( - SELECT 1 - FROM public.usage_credit_balances ucb - WHERE ucb.org_id = orgid - AND COALESCE(ucb.available_credits, 0) > 0 - ) - OR EXISTS ( - SELECT 1 - FROM public.stripe_info - WHERE customer_id = (SELECT customer_id FROM public.orgs WHERE id = orgid) - AND ( - (status = 'succeeded' AND is_good_plan = true) - OR (trial_at::date - now()::date > 0) - ) - ) - ); -END; -$$; - -REVOKE ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") RETURNS double precision - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - total_size double precision := 0; - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_total_storage_size_org.org_id) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - get_total_storage_size_org.org_id, - NULL::character varying, - NULL::bigint - ) THEN - RETURN 0; - END IF; - END IF; - - SELECT COALESCE(SUM(app_versions_meta.size), 0) INTO total_size - FROM public.app_versions - INNER JOIN public.app_versions_meta ON app_versions.id = app_versions_meta.id - WHERE app_versions.owner_org = org_id - AND app_versions.deleted = false; - - RETURN total_size; -END; -$$; - -REVOKE ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) RETURNS double precision - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - total_size double precision := 0; - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode[], - get_total_app_storage_size_orgs.org_id, - get_total_app_storage_size_orgs.app_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - get_total_app_storage_size_orgs.org_id, - get_total_app_storage_size_orgs.app_id, - NULL::bigint - ) THEN - RETURN 0; - END IF; - END IF; - - SELECT COALESCE(SUM(app_versions_meta.size), 0) INTO total_size - FROM public.app_versions - INNER JOIN public.app_versions_meta ON app_versions.id = app_versions_meta.id - WHERE app_versions.owner_org = org_id - AND app_versions.app_id = get_total_app_storage_size_orgs.app_id - AND app_versions.deleted = false; - - RETURN total_size; -END; -$$; - -REVOKE ALL ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") RETURNS "uuid" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - org_id uuid; - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT auth.uid() INTO caller_id; - IF caller_id IS NULL OR caller_id <> get_user_main_org_id.user_id THEN - RETURN NULL; - END IF; - END IF; - - SELECT orgs.id - INTO org_id - FROM public.orgs - WHERE orgs.created_by = get_user_main_org_id.user_id - LIMIT 1; - - RETURN org_id; -END; -$$; - -REVOKE ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - is_found integer; - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF NOT ( - caller_role IN ('service_role', 'postgres', 'supabase_admin') - OR ( - caller_role IN ('', 'none') - AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') - ) - ) THEN - SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_member_of_org.org_id) - INTO caller_id; - - IF caller_id IS NULL OR caller_id <> is_member_of_org.user_id OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - is_member_of_org.org_id, - NULL::character varying, - NULL::bigint - ) THEN - RETURN false; - END IF; - END IF; - - SELECT count(*) - INTO is_found - FROM public.orgs - JOIN public.org_users ON org_users.org_id = orgs.id - WHERE org_users.user_id = is_member_of_org.user_id - AND orgs.id = is_member_of_org.org_id; - - RETURN is_found != 0; -END; -$$; - -REVOKE ALL ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_account_disabled"("user_id" "uuid") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE(current_setting('role', true), '') INTO caller_role; - - IF caller_role NOT IN ('service_role', 'postgres', 'supabase_admin') - AND COALESCE(session_user, current_user) NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - SELECT auth.uid() INTO caller_id; - IF caller_id IS NULL OR caller_id <> is_account_disabled.user_id THEN - RETURN false; - END IF; - END IF; - - RETURN EXISTS ( - SELECT 1 - FROM public.to_delete_accounts - WHERE account_id = user_id - ); -END; -$$; diff --git a/supabase/migrations/20260317020500_revoke_cleanup_expired_demo_apps_public_exec.sql b/supabase/migrations/20260317020500_revoke_cleanup_expired_demo_apps_public_exec.sql deleted file mode 100644 index c2d0a08e07..0000000000 --- a/supabase/migrations/20260317020500_revoke_cleanup_expired_demo_apps_public_exec.sql +++ /dev/null @@ -1,6 +0,0 @@ -REVOKE ALL ON FUNCTION public.cleanup_expired_demo_apps() FROM PUBLIC; - -REVOKE ALL ON FUNCTION public.cleanup_expired_demo_apps() FROM ANON; - -REVOKE ALL ON FUNCTION public.cleanup_expired_demo_apps() -FROM AUTHENTICATED; diff --git a/supabase/migrations/20260317021715_fix_get_user_org_ids_apikey_expiry.sql b/supabase/migrations/20260317021715_fix_get_user_org_ids_apikey_expiry.sql deleted file mode 100644 index bc792fb3fa..0000000000 --- a/supabase/migrations/20260317021715_fix_get_user_org_ids_apikey_expiry.sql +++ /dev/null @@ -1,121 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."get_user_org_ids"() RETURNS TABLE ( - "org_id" "uuid" -) LANGUAGE "plpgsql" -SET search_path = '' SECURITY DEFINER AS $$ -DECLARE - api_key_text text; - api_key record; - v_user_id uuid; - limited_orgs uuid[]; - has_limited_orgs boolean := false; -BEGIN - SELECT "public"."get_apikey_header"() INTO api_key_text; - v_user_id := NULL; - - -- Check for API key first, supporting both plain-text and hashed keys. - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - v_user_id := api_key.user_id; - limited_orgs := api_key.limited_to_orgs; - has_limited_orgs := COALESCE(array_length(limited_orgs, 1), 0) > 0; - END IF; - - -- If no valid API key v_user_id yet, try to get from public.identity. - IF v_user_id IS NULL THEN - SELECT public.get_identity() INTO v_user_id; - - IF v_user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY - WITH role_orgs AS ( - SELECT rb.org_id AS org_uuid - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_user_id - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - SELECT rb.org_id AS org_uuid - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = v_user_id - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.apps ON apps.id = rb.app_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_user_id - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.apps ON apps.id = rb.app_id - JOIN public.group_members gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = v_user_id - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.channels ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_user_id - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.channels ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - JOIN public.group_members gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = v_user_id - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - legacy_orgs AS ( - SELECT org_users.org_id AS org_uuid - FROM public.org_users - WHERE org_users.user_id = v_user_id - ), - all_orgs AS ( - SELECT org_uuid FROM legacy_orgs - UNION - SELECT org_uuid FROM role_orgs - ) - SELECT ao.org_uuid AS org_id - FROM all_orgs ao - WHERE ao.org_uuid IS NOT NULL - AND ( - NOT has_limited_orgs - OR ao.org_uuid = ANY(limited_orgs) - ); -END; -$$; - -COMMENT ON FUNCTION "public"."get_user_org_ids"() IS - 'RBAC/legacy-aware org id list for authenticated user or API key (includes org_users and role_bindings membership).'; - -REVOKE ALL ON FUNCTION "public"."get_user_org_ids"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_user_org_ids"() TO "authenticated"; diff --git a/supabase/migrations/20260317040310_restrict_manifest_read_access.sql b/supabase/migrations/20260317040310_restrict_manifest_read_access.sql deleted file mode 100644 index de1917cb0e..0000000000 --- a/supabase/migrations/20260317040310_restrict_manifest_read_access.sql +++ /dev/null @@ -1,24 +0,0 @@ -DROP POLICY IF EXISTS "Allow users to read any manifest entry" ON "public"."manifest"; - -CREATE POLICY "Allow users to read manifest entries for accessible apps" -ON "public"."manifest" -FOR SELECT -TO "authenticated", "anon" -USING ( - EXISTS ( - SELECT 1 - FROM "public"."app_versions" AS "av" - WHERE "av"."id" = "manifest"."app_version_id" - AND "public"."check_min_rights"( - 'read'::"public"."user_min_right", - "public"."get_identity_org_appid"( - '{read,upload,write,all}'::"public"."key_mode"[], - "av"."owner_org", - "av"."app_id" - ), - "av"."owner_org", - "av"."app_id", - NULL::bigint - ) - ) -); diff --git a/supabase/migrations/20260317090000_fix_get_app_versions_rbac.sql b/supabase/migrations/20260317090000_fix_get_app_versions_rbac.sql deleted file mode 100644 index dfb17818f3..0000000000 --- a/supabase/migrations/20260317090000_fix_get_app_versions_rbac.sql +++ /dev/null @@ -1,68 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."get_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) RETURNS integer -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_org_id uuid; - v_user_id uuid; -BEGIN - SELECT owner_org - INTO v_org_id - FROM public.apps - WHERE app_id = get_app_versions.appid - LIMIT 1; - - IF v_org_id IS NULL THEN - RETURN NULL; - END IF; - - SELECT public.get_user_id(get_app_versions.apikey) - INTO v_user_id; - - IF public.rbac_check_permission_direct( - public.rbac_perm_app_read_bundles(), - v_user_id, - v_org_id, - get_app_versions.appid, - NULL::bigint, - get_app_versions.apikey - ) IS NOT TRUE THEN - RETURN NULL; - END IF; - - RETURN ( - SELECT id - FROM public.app_versions - WHERE app_id = get_app_versions.appid - AND name = get_app_versions.name_version - AND owner_org = v_org_id - LIMIT 1 - ); -END; -$$; - -REVOKE ALL ON FUNCTION "public"."get_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "service_role"; diff --git a/supabase/migrations/20260317100429_fix_encrypted_bundle_update_enforcement.sql b/supabase/migrations/20260317100429_fix_encrypted_bundle_update_enforcement.sql deleted file mode 100644 index 32065cd5cf..0000000000 --- a/supabase/migrations/20260317100429_fix_encrypted_bundle_update_enforcement.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Prevent direct PostgREST downgrades of encrypted bundles after insert. -DROP TRIGGER IF EXISTS enforce_encrypted_bundle_trigger ON public.app_versions; - -CREATE TRIGGER enforce_encrypted_bundle_trigger - -- app_id changes are already blocked and owner_org is auto-derived from app_id. - -- Limit UPDATE enforcement to encryption fields so regular metadata updates keep working. - BEFORE INSERT OR UPDATE OF session_key, key_id ON public.app_versions - FOR EACH ROW - EXECUTE FUNCTION public.check_encrypted_bundle_on_insert(); diff --git a/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql b/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql deleted file mode 100644 index bda5e9770a..0000000000 --- a/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql +++ /dev/null @@ -1,46 +0,0 @@ --- Fix: prevent auto-org creation for users whose email domain has an active SSO provider. --- When a new SSO user logs in, auth.ts lazily creates a public.users row which fires --- generate_org_on_user_create. For SSO domains, provision-user.ts assigns the correct org, --- so this auto-created personal org is unwanted. Skip it when an active SSO provider exists --- for the user's domain. --- --- Only skips org creation when: --- 1. The user authenticated via SSO (provider != 'email') — prevents email/password signups --- with a corporate domain from being left in a broken no-org state. --- 2. The domain has an active SSO provider AND the owning org has sso_enabled = true — --- consistent with check_domain_sso and all other SSO lookups in the system. --- 3. btrim applied to the domain component — matches the normalization contract from --- migration 20260312183000 which enforces lower(btrim(domain)). - -CREATE OR REPLACE FUNCTION "public"."generate_org_on_user_create" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_record record; - has_sso boolean; - user_provider text; -BEGIN - SELECT raw_app_meta_data->>'provider' - INTO user_provider - FROM auth.users - WHERE id = NEW.id; - - -- Compute has_sso first so it can be combined with the provider check below. - -- Mirror the sso_enabled guard from check_domain_sso to stay consistent. - SELECT EXISTS ( - SELECT 1 FROM public.sso_providers sp - JOIN public.orgs o ON o.id = sp.org_id AND o.sso_enabled = true - WHERE sp.domain = lower(btrim(split_part(NEW.email, '@', 2))) - AND sp.status = 'active' - ) INTO has_sso; - - -- Skip org creation only for genuine SAML SSO logins on SSO-managed domains. - -- Supabase sets app_metadata.provider to 'sso:' for SAML sessions. - -- Email, phone, and OAuth providers (e.g. google, github) always get a personal org, - -- even when their email domain matches an active SSO provider. - IF NOT (user_provider ~ '^sso:' AND has_sso) THEN - INSERT INTO public.orgs (created_by, name, management_email) values (NEW.id, format('%s organization', NEW.first_name), NEW.email) RETURNING * INTO org_record; - END IF; - - RETURN NEW; -END $$; diff --git a/supabase/migrations/20260318210857_fix_get_orgs_v7_private_overload_grants.sql b/supabase/migrations/20260318210857_fix_get_orgs_v7_private_overload_grants.sql deleted file mode 100644 index 7d97993c8c..0000000000 --- a/supabase/migrations/20260318210857_fix_get_orgs_v7_private_overload_grants.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Security hardening for get_orgs_v7(userid) --- The parameterized overload accepts arbitrary user IDs, so it must not be callable --- via anon/authenticated roles directly. - -REVOKE ALL ON FUNCTION public.get_orgs_v7(userid uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v7(userid uuid) FROM ANON; -REVOKE ALL ON FUNCTION public.get_orgs_v7(userid uuid) FROM AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(userid uuid) TO POSTGRES; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(userid uuid) TO SERVICE_ROLE; diff --git a/supabase/migrations/20260318220337_optimize-org-metrics-cache-read-only.sql b/supabase/migrations/20260318220337_optimize-org-metrics-cache-read-only.sql deleted file mode 100644 index de57df4cae..0000000000 --- a/supabase/migrations/20260318220337_optimize-org-metrics-cache-read-only.sql +++ /dev/null @@ -1,309 +0,0 @@ --- Harden the org metrics cache helpers so they can be used inside read-only transactions. - -CREATE OR REPLACE FUNCTION public.calculate_org_metrics_cache_entry( - p_org_id uuid, - p_start_date date, - p_end_date date -) RETURNS public.org_metrics_cache LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_mau bigint; - v_storage bigint; - v_bandwidth bigint; - v_build_time bigint; - v_get bigint; - v_fail bigint; - v_install bigint; - v_uninstall bigint; - cache_record public.org_metrics_cache%ROWTYPE; -BEGIN - WITH app_ids AS ( - SELECT apps.app_id - FROM public.apps - WHERE apps.owner_org = p_org_id - UNION - SELECT deleted_apps.app_id - FROM public.deleted_apps - WHERE deleted_apps.owner_org = p_org_id - ), - mau AS ( - SELECT COALESCE(SUM(dm.mau), 0)::bigint AS value - FROM public.daily_mau dm - JOIN app_ids a ON a.app_id = dm.app_id - WHERE dm.date BETWEEN p_start_date AND p_end_date - ), - bandwidth AS ( - SELECT COALESCE(SUM(db.bandwidth), 0)::bigint AS value - FROM public.daily_bandwidth db - JOIN app_ids a ON a.app_id = db.app_id - WHERE db.date BETWEEN p_start_date AND p_end_date - ), - build_time AS ( - SELECT COALESCE(SUM(dbt.build_time_unit), 0)::bigint AS value - FROM public.daily_build_time dbt - JOIN app_ids a ON a.app_id = dbt.app_id - WHERE dbt.date BETWEEN p_start_date AND p_end_date - ), - version_stats AS ( - SELECT - COALESCE(SUM(dv.get), 0)::bigint AS get, - COALESCE(SUM(dv.fail), 0)::bigint AS fail, - COALESCE(SUM(dv.install), 0)::bigint AS install, - COALESCE(SUM(dv.uninstall), 0)::bigint AS uninstall - FROM public.daily_version dv - JOIN app_ids a ON a.app_id = dv.app_id - WHERE dv.date BETWEEN p_start_date AND p_end_date - ), - storage AS ( - SELECT COALESCE(SUM(avm.size), 0)::bigint AS value - FROM public.app_versions av - INNER JOIN public.app_versions_meta avm ON av.id = avm.id - WHERE av.owner_org = p_org_id AND av.deleted = false - ) - SELECT - mau.value, - storage.value, - bandwidth.value, - build_time.value, - version_stats.get, - version_stats.fail, - version_stats.install, - version_stats.uninstall - INTO v_mau, v_storage, v_bandwidth, v_build_time, v_get, v_fail, v_install, v_uninstall - FROM mau, storage, bandwidth, build_time, version_stats; - - cache_record.org_id := p_org_id; - cache_record.start_date := p_start_date; - cache_record.end_date := p_end_date; - cache_record.mau := v_mau; - cache_record.storage := v_storage; - cache_record.bandwidth := v_bandwidth; - cache_record.build_time_unit := v_build_time; - cache_record.get := v_get; - cache_record.fail := v_fail; - cache_record.install := v_install; - cache_record.uninstall := v_uninstall; - cache_record.cached_at := clock_timestamp(); - - RETURN cache_record; -END; -$function$; - -ALTER FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) FROM anon; -REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) FROM authenticated; -REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid, date, date) FROM service_role; - -CREATE OR REPLACE FUNCTION public.seed_org_metrics_cache( - p_org_id uuid, - p_start_date date, - p_end_date date -) RETURNS public.org_metrics_cache LANGUAGE plpgsql SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - cache_record public.org_metrics_cache%ROWTYPE; -BEGIN - INSERT INTO public.org_metrics_cache ( - org_id, - start_date, - end_date, - mau, - storage, - bandwidth, - build_time_unit, - get, - fail, - install, - uninstall, - cached_at - ) - SELECT - org_id, - start_date, - end_date, - mau, - storage, - bandwidth, - build_time_unit, - get, - fail, - install, - uninstall, - cached_at - FROM public.calculate_org_metrics_cache_entry(p_org_id, p_start_date, p_end_date) - ON CONFLICT (org_id) DO UPDATE - SET start_date = EXCLUDED.start_date, - end_date = EXCLUDED.end_date, - mau = EXCLUDED.mau, - storage = EXCLUDED.storage, - bandwidth = EXCLUDED.bandwidth, - build_time_unit = EXCLUDED.build_time_unit, - get = EXCLUDED.get, - fail = EXCLUDED.fail, - install = EXCLUDED.install, - uninstall = EXCLUDED.uninstall, - cached_at = EXCLUDED.cached_at - RETURNING * INTO cache_record; - - RETURN cache_record; -END; -$function$; - -ALTER FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) FROM public; -REVOKE ALL ON FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) FROM anon; -REVOKE ALL ON FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) FROM authenticated; -REVOKE ALL ON FUNCTION public.seed_org_metrics_cache( - uuid, date, date -) FROM service_role; - -CREATE OR REPLACE FUNCTION public.get_total_metrics( - org_id uuid, - start_date date, - end_date date -) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - cache_entry public.org_metrics_cache%ROWTYPE; - cache_ttl interval := '5 minutes'::interval; - tx_read_only boolean := current_setting('transaction_read_only') = 'on'; -BEGIN - IF start_date IS NULL OR end_date IS NULL THEN - RETURN; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE orgs.id = get_total_metrics.org_id - ) THEN - RETURN; - END IF; - - IF EXISTS ( - SELECT 1 - FROM pg_catalog.pg_stat_xact_user_tables - WHERE relname IN ( - 'apps', - 'deleted_apps', - 'daily_mau', - 'daily_bandwidth', - 'daily_build_time', - 'daily_version', - 'app_versions', - 'app_versions_meta' - ) - AND (n_tup_ins > 0 OR n_tup_upd > 0 OR n_tup_del > 0) - ) THEN - IF tx_read_only THEN - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.calculate_org_metrics_cache_entry(org_id, start_date, end_date) AS metrics; - RETURN; - END IF; - - cache_entry := public.seed_org_metrics_cache(org_id, start_date, end_date); - - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; - RETURN; - END IF; - - SELECT * INTO cache_entry - FROM public.org_metrics_cache - WHERE org_metrics_cache.org_id = get_total_metrics.org_id; - - IF FOUND - AND cache_entry.start_date = start_date - AND cache_entry.end_date = end_date - AND cache_entry.cached_at > clock_timestamp() - cache_ttl - THEN - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; - RETURN; - END IF; - - IF tx_read_only THEN - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.calculate_org_metrics_cache_entry(org_id, start_date, end_date) AS metrics; - RETURN; - END IF; - - cache_entry := public.seed_org_metrics_cache(org_id, start_date, end_date); - - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; -END; -$function$; - -ALTER FUNCTION public.get_total_metrics(uuid, date, date) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid, date, date) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid, date, date) FROM anon; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid, date, date) FROM authenticated; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid, date, date) FROM service_role; -GRANT ALL ON FUNCTION public.get_total_metrics( - uuid, date, date -) TO service_role; - -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached(uuid) FROM PUBLIC; diff --git a/supabase/migrations/20260319090430_password_policy_max_length_72.sql b/supabase/migrations/20260319090430_password_policy_max_length_72.sql deleted file mode 100644 index 9a5048de02..0000000000 --- a/supabase/migrations/20260319090430_password_policy_max_length_72.sql +++ /dev/null @@ -1,102 +0,0 @@ --- Align organization password policy limits with Supabase Auth's bcrypt-backed max password length. --- Supabase Auth rejects passwords longer than 72 characters, so policy min_length must never exceed 72. - -WITH "normalized_password_policy_min_lengths" AS ( - SELECT - "id", - LEAST( - 72::numeric, - GREATEST( - 6::numeric, - CEIL( - CASE - WHEN jsonb_typeof("password_policy_config"->'min_length') = 'number' - THEN ("password_policy_config"->>'min_length')::numeric - WHEN jsonb_typeof("password_policy_config"->'min_length') = 'string' - AND btrim("password_policy_config"->>'min_length') ~ '^-?\d+(\.\d+)?$' - THEN (btrim("password_policy_config"->>'min_length'))::numeric - ELSE 6::numeric - END - ) - ) - )::integer AS "normalized_min_length" - FROM "public"."orgs" - WHERE "password_policy_config" IS NOT NULL - AND jsonb_typeof("password_policy_config") = 'object' - AND ("password_policy_config" ? 'min_length') -) -UPDATE "public"."orgs" AS "orgs" -SET "password_policy_config" = jsonb_set( - "orgs"."password_policy_config", - '{min_length}', - to_jsonb("normalized_password_policy_min_lengths"."normalized_min_length"), - false -) -FROM "normalized_password_policy_min_lengths" -WHERE "orgs"."id" = "normalized_password_policy_min_lengths"."id" - AND ( - jsonb_typeof("orgs"."password_policy_config"->'min_length') <> 'number' - OR ("orgs"."password_policy_config"->>'min_length') IS DISTINCT FROM "normalized_password_policy_min_lengths"."normalized_min_length"::text - ); - -ALTER TABLE "public"."orgs" -DROP CONSTRAINT IF EXISTS "orgs_password_policy_config_min_length_check"; - -ALTER TABLE "public"."orgs" -ADD CONSTRAINT "orgs_password_policy_config_min_length_check" -CHECK ( - "password_policy_config" IS NULL - OR ( - jsonb_typeof("password_policy_config") = 'object' - AND ( - NOT ("password_policy_config" ? 'min_length') - OR ( - jsonb_typeof("password_policy_config"->'min_length') = 'number' - AND ("password_policy_config"->>'min_length')::numeric = trunc(("password_policy_config"->>'min_length')::numeric) - AND ("password_policy_config"->>'min_length')::numeric BETWEEN 6::numeric AND 72::numeric - ) - ) - ) -); - -DROP POLICY IF EXISTS "Allow update for auth (admin+)" ON "public"."orgs"; - -CREATE POLICY "Allow update for auth (admin+)" ON "public"."orgs" -FOR UPDATE -TO "authenticated", "anon" -USING ( - "public"."check_min_rights"( - 'admin'::"public"."user_min_right", - "public"."get_identity_org_allowed"('{all,write}'::"public"."key_mode"[], "id"), - "id", - NULL::character varying, - NULL::bigint - ) -) -WITH CHECK ( - "public"."check_min_rights"( - 'admin'::"public"."user_min_right", - "public"."get_identity_org_allowed"('{all,write}'::"public"."key_mode"[], "id"), - "id", - NULL::character varying, - NULL::bigint - ) - AND ( - "enforcing_2fa" IS NOT TRUE - OR "public"."has_2fa_enabled"() - ) - AND ( - "password_policy_config" IS NULL - OR ( - jsonb_typeof("password_policy_config") = 'object' - AND ( - NOT ("password_policy_config" ? 'min_length') - OR ( - jsonb_typeof("password_policy_config"->'min_length') = 'number' - AND ("password_policy_config"->>'min_length')::numeric = trunc(("password_policy_config"->>'min_length')::numeric) - AND ("password_policy_config"->>'min_length')::numeric BETWEEN 6::numeric AND 72::numeric - ) - ) - ) - ) -); diff --git a/supabase/migrations/20260319094649_add_build_minutes_to_global_stats.sql b/supabase/migrations/20260319094649_add_build_minutes_to_global_stats.sql deleted file mode 100644 index 0d7312b512..0000000000 --- a/supabase/migrations/20260319094649_add_build_minutes_to_global_stats.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE public.global_stats -ADD COLUMN build_minutes_day_ios double precision DEFAULT 0 NOT NULL, -ADD COLUMN build_minutes_day_android double precision DEFAULT 0 NOT NULL, -ADD COLUMN builds_day_ios integer DEFAULT 0 NOT NULL, -ADD COLUMN builds_day_android integer DEFAULT 0 NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_build_logs_created_at_platform -ON public.build_logs (created_at, platform); - -COMMENT ON COLUMN public.global_stats.build_minutes_day_ios IS 'Total iOS build minutes recorded for the day'; -COMMENT ON COLUMN public.global_stats.build_minutes_day_android IS 'Total Android build minutes recorded for the day'; -COMMENT ON COLUMN public.global_stats.builds_day_ios IS 'Total iOS builds counted for the day'; -COMMENT ON COLUMN public.global_stats.builds_day_android IS 'Total Android builds counted for the day'; diff --git a/supabase/migrations/20260319103952_fix_subkey_header_and_plan_usage_rpcs.sql b/supabase/migrations/20260319103952_fix_subkey_header_and_plan_usage_rpcs.sql deleted file mode 100644 index c6cb14d13f..0000000000 --- a/supabase/migrations/20260319103952_fix_subkey_header_and_plan_usage_rpcs.sql +++ /dev/null @@ -1,362 +0,0 @@ --- Preserve read-only-safe metrics helpers and apply explicit PUBLIC revokes on --- helper RPCs introduced before the current migration guardrails. - -CREATE OR REPLACE FUNCTION public.get_total_metrics( - org_id uuid, - start_date date, - end_date date -) RETURNS TABLE ( - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - cache_entry public.org_metrics_cache%ROWTYPE; - cache_ttl interval := '5 minutes'::interval; - tx_read_only boolean := COALESCE(current_setting('transaction_read_only', true), 'off') = 'on'; -BEGIN - IF start_date IS NULL OR end_date IS NULL THEN - RETURN; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE orgs.id = get_total_metrics.org_id - ) THEN - RETURN; - END IF; - - IF EXISTS ( - SELECT 1 - FROM pg_catalog.pg_stat_xact_user_tables - WHERE relname IN ( - 'apps', - 'deleted_apps', - 'daily_mau', - 'daily_bandwidth', - 'daily_build_time', - 'daily_version', - 'app_versions', - 'app_versions_meta' - ) - AND (n_tup_ins > 0 OR n_tup_upd > 0 OR n_tup_del > 0) - ) THEN - IF tx_read_only THEN - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.calculate_org_metrics_cache_entry(org_id, start_date, end_date) AS metrics; - RETURN; - END IF; - - cache_entry := public.seed_org_metrics_cache(get_total_metrics.org_id, start_date, end_date); - - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; - RETURN; - END IF; - - SELECT * INTO cache_entry - FROM public.org_metrics_cache - WHERE org_metrics_cache.org_id = get_total_metrics.org_id; - - IF FOUND - AND cache_entry.start_date = start_date - AND cache_entry.end_date = end_date - AND cache_entry.cached_at > clock_timestamp() - cache_ttl - THEN - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; - RETURN; - END IF; - - IF tx_read_only THEN - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.calculate_org_metrics_cache_entry(org_id, start_date, end_date) AS metrics; - RETURN; - END IF; - - cache_entry := public.seed_org_metrics_cache(get_total_metrics.org_id, start_date, end_date); - - RETURN QUERY SELECT - cache_entry.mau, - cache_entry.storage, - cache_entry.bandwidth, - cache_entry.build_time_unit, - cache_entry.get, - cache_entry.fail, - cache_entry.install, - cache_entry.uninstall; -END; -$function$; - -ALTER FUNCTION public.get_total_metrics(uuid, date, date) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid, date, date) FROM public; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid, date, date) FROM anon; -REVOKE ALL ON FUNCTION public.get_total_metrics( - uuid, date, date -) FROM authenticated; -GRANT ALL ON FUNCTION public.get_total_metrics( - uuid, date, date -) TO service_role; - -ALTER FUNCTION public.get_total_metrics(uuid) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM public; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM authenticated; -GRANT ALL ON FUNCTION public.get_total_metrics(uuid) TO service_role; - -CREATE OR REPLACE FUNCTION public.get_plan_usage_and_fit(orgid uuid) -RETURNS TABLE ( - is_good_plan boolean, - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_start_date date; - v_end_date date; - v_plan_mau bigint; - v_plan_bandwidth bigint; - v_plan_storage bigint; - v_plan_build_time bigint; - v_anchor_day integer; - v_current_month_start date; - v_current_month_anchor date; - v_target_month_start date; - v_target_month_last_day date; - v_next_target_month_start date; - v_next_target_month_last_day date; - v_plan_name text; - total_stats RECORD; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; - percent_build_time double precision; - v_is_good_plan boolean; -BEGIN - SELECT - COALESCE(EXTRACT(DAY FROM si.subscription_anchor_start)::integer, 1), - p.mau, - p.bandwidth, - p.storage, - p.build_time_unit, - p.name - INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time, v_plan_name - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; - - v_current_month_start := date_trunc('MONTH', NOW())::date; - v_current_month_anchor := v_current_month_start + ( - LEAST( - v_anchor_day, - EXTRACT(DAY FROM (v_current_month_start + INTERVAL '1 MONTH - 1 day'))::integer - ) - 1 - ); - - IF NOW()::date < v_current_month_anchor THEN - v_target_month_start := (v_current_month_start - INTERVAL '1 MONTH')::date; - ELSE - v_target_month_start := v_current_month_start; - END IF; - - v_target_month_last_day := (v_target_month_start + INTERVAL '1 MONTH - 1 day')::date; - v_start_date := v_target_month_start + ( - LEAST(v_anchor_day, EXTRACT(DAY FROM v_target_month_last_day)::integer) - 1 - ); - - v_next_target_month_start := (v_target_month_start + INTERVAL '1 MONTH')::date; - v_next_target_month_last_day := (v_next_target_month_start + INTERVAL '1 MONTH - 1 day')::date; - v_end_date := v_next_target_month_start + ( - LEAST(v_anchor_day, EXTRACT(DAY FROM v_next_target_month_last_day)::integer) - 1 - ); - - SELECT * INTO total_stats - FROM public.get_total_metrics(orgid, v_start_date, v_end_date); - - percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); - - IF v_plan_name = 'Enterprise' THEN - v_is_good_plan := TRUE; - ELSIF v_plan_name IS NULL THEN - v_is_good_plan := FALSE; - ELSE - v_is_good_plan := v_plan_mau >= total_stats.mau - AND v_plan_bandwidth >= total_stats.bandwidth - AND v_plan_storage >= total_stats.storage - AND v_plan_build_time >= COALESCE(total_stats.build_time_unit, 0); - END IF; - - RETURN QUERY SELECT - v_is_good_plan, - GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, - percent_bandwidth, - percent_storage, - percent_build_time; -END; -$function$; - -ALTER FUNCTION public.get_plan_usage_and_fit(uuid) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM public; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM authenticated; -GRANT ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) TO service_role; - -CREATE OR REPLACE FUNCTION public.get_plan_usage_and_fit_uncached(orgid uuid) -RETURNS TABLE ( - is_good_plan boolean, - total_percent double precision, - mau_percent double precision, - bandwidth_percent double precision, - storage_percent double precision, - build_time_percent double precision -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_start_date date; - v_end_date date; - v_plan_mau bigint; - v_plan_bandwidth bigint; - v_plan_storage bigint; - v_plan_build_time bigint; - v_anchor_day integer; - v_current_month_start date; - v_current_month_anchor date; - v_target_month_start date; - v_target_month_last_day date; - v_next_target_month_start date; - v_next_target_month_last_day date; - v_plan_name text; - total_stats RECORD; - percent_mau double precision; - percent_bandwidth double precision; - percent_storage double precision; - percent_build_time double precision; - v_is_good_plan boolean; -BEGIN - SELECT - COALESCE(EXTRACT(DAY FROM si.subscription_anchor_start)::integer, 1), - p.mau, - p.bandwidth, - p.storage, - p.build_time_unit, - p.name - INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time, v_plan_name - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; - - v_current_month_start := date_trunc('MONTH', NOW())::date; - v_current_month_anchor := v_current_month_start + ( - LEAST( - v_anchor_day, - EXTRACT(DAY FROM (v_current_month_start + INTERVAL '1 MONTH - 1 day'))::integer - ) - 1 - ); - - IF NOW()::date < v_current_month_anchor THEN - v_target_month_start := (v_current_month_start - INTERVAL '1 MONTH')::date; - ELSE - v_target_month_start := v_current_month_start; - END IF; - - v_target_month_last_day := (v_target_month_start + INTERVAL '1 MONTH - 1 day')::date; - v_start_date := v_target_month_start + ( - LEAST(v_anchor_day, EXTRACT(DAY FROM v_target_month_last_day)::integer) - 1 - ); - - v_next_target_month_start := (v_target_month_start + INTERVAL '1 MONTH')::date; - v_next_target_month_last_day := (v_next_target_month_start + INTERVAL '1 MONTH - 1 day')::date; - v_end_date := v_next_target_month_start + ( - LEAST(v_anchor_day, EXTRACT(DAY FROM v_next_target_month_last_day)::integer) - 1 - ); - - SELECT * INTO total_stats - FROM public.seed_org_metrics_cache(orgid, v_start_date, v_end_date); - - percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); - percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); - percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); - percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); - - IF v_plan_name = 'Enterprise' THEN - v_is_good_plan := TRUE; - ELSIF v_plan_name IS NULL THEN - v_is_good_plan := FALSE; - ELSE - v_is_good_plan := v_plan_mau >= total_stats.mau - AND v_plan_bandwidth >= total_stats.bandwidth - AND v_plan_storage >= total_stats.storage - AND v_plan_build_time >= COALESCE(total_stats.build_time_unit, 0); - END IF; - - RETURN QUERY SELECT - v_is_good_plan, - GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), - percent_mau, - percent_bandwidth, - percent_storage, - percent_build_time; -END; -$function$; - -ALTER FUNCTION public.get_plan_usage_and_fit_uncached(uuid) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached(uuid) FROM public; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached( - uuid -) FROM authenticated; -GRANT ALL ON FUNCTION public.get_plan_usage_and_fit_uncached( - uuid -) TO service_role; diff --git a/supabase/migrations/20260319155734_fix_global_stats_build_seconds_and_conversion_rate.sql b/supabase/migrations/20260319155734_fix_global_stats_build_seconds_and_conversion_rate.sql deleted file mode 100644 index ec99b49902..0000000000 --- a/supabase/migrations/20260319155734_fix_global_stats_build_seconds_and_conversion_rate.sql +++ /dev/null @@ -1,116 +0,0 @@ -ALTER TABLE public.global_stats -RENAME COLUMN build_minutes_day_ios TO build_total_seconds_day_ios; - -ALTER TABLE public.global_stats -RENAME COLUMN build_minutes_day_android TO build_total_seconds_day_android; - -ALTER TABLE public.global_stats -RENAME COLUMN builds_day_ios TO build_count_day_ios; - -ALTER TABLE public.global_stats -RENAME COLUMN builds_day_android TO build_count_day_android; - -ALTER TABLE public.global_stats -ADD COLUMN build_avg_seconds_day_ios double precision DEFAULT 0 NOT NULL, -ADD COLUMN build_avg_seconds_day_android double precision DEFAULT 0 NOT NULL; - -ALTER TABLE public.global_stats -ALTER COLUMN build_total_seconds_day_ios TYPE bigint - USING COALESCE(ROUND(build_total_seconds_day_ios::numeric * 60), 0)::bigint, -ALTER COLUMN build_total_seconds_day_android TYPE bigint - USING COALESCE(ROUND(build_total_seconds_day_android::numeric * 60), 0)::bigint, -ALTER COLUMN build_count_day_ios TYPE integer USING COALESCE(build_count_day_ios, 0), -ALTER COLUMN build_count_day_android TYPE integer USING COALESCE(build_count_day_android, 0); - -ALTER TABLE public.global_stats -ALTER COLUMN build_total_seconds_day_ios SET DEFAULT 0, -ALTER COLUMN build_total_seconds_day_android SET DEFAULT 0, -ALTER COLUMN build_count_day_ios SET DEFAULT 0, -ALTER COLUMN build_count_day_android SET DEFAULT 0; - -COMMENT ON COLUMN public.global_stats.build_total_seconds_day_ios IS 'Total iOS build seconds recorded for the UTC day'; -COMMENT ON COLUMN public.global_stats.build_total_seconds_day_android IS 'Total Android build seconds recorded for the UTC day'; -COMMENT ON COLUMN public.global_stats.build_count_day_ios IS 'Total iOS builds recorded for the UTC day'; -COMMENT ON COLUMN public.global_stats.build_count_day_android IS 'Total Android builds recorded for the UTC day'; -COMMENT ON COLUMN public.global_stats.build_avg_seconds_day_ios IS 'Average iOS build duration in seconds for the UTC day'; -COMMENT ON COLUMN public.global_stats.build_avg_seconds_day_android IS 'Average Android build duration in seconds for the UTC day'; - -UPDATE public.global_stats -SET org_conversion_rate = ROUND(COALESCE(org_conversion_rate, 0)::numeric, 1)::double precision; - -UPDATE public.global_stats -SET - build_avg_seconds_day_ios = CASE - WHEN build_count_day_ios > 0 - THEN ROUND((build_total_seconds_day_ios::numeric / build_count_day_ios), 1)::double precision - ELSE 0 - END, - build_avg_seconds_day_android = CASE - WHEN build_count_day_android > 0 - THEN ROUND((build_total_seconds_day_android::numeric / build_count_day_android), 1)::double precision - ELSE 0 - END; - -CREATE TEMP TABLE temp_daily_build_stats ON COMMIT DROP AS -SELECT - to_char(DATE(timezone('UTC', created_at)), 'YYYY-MM-DD') AS date_id, - SUM(build_time_unit) FILTER (WHERE platform = 'ios')::bigint AS build_total_seconds_day_ios, - SUM(build_time_unit) FILTER (WHERE platform = 'android')::bigint AS build_total_seconds_day_android, - COUNT(*) FILTER (WHERE platform = 'ios')::integer AS build_count_day_ios, - COUNT(*) FILTER (WHERE platform = 'android')::integer AS build_count_day_android, - ROUND((AVG(build_time_unit) FILTER (WHERE platform = 'ios'))::numeric, 1)::double precision AS build_avg_seconds_day_ios, - ROUND((AVG(build_time_unit) FILTER (WHERE platform = 'android'))::numeric, 1)::double precision AS build_avg_seconds_day_android -FROM public.build_logs -WHERE platform IN ('ios', 'android') -GROUP BY DATE(timezone('UTC', created_at)); - -UPDATE public.global_stats AS gs -SET - build_total_seconds_day_ios = COALESCE(temp_daily_build_stats.build_total_seconds_day_ios, gs.build_total_seconds_day_ios), - build_total_seconds_day_android = COALESCE(temp_daily_build_stats.build_total_seconds_day_android, gs.build_total_seconds_day_android), - build_count_day_ios = COALESCE(temp_daily_build_stats.build_count_day_ios, gs.build_count_day_ios), - build_count_day_android = COALESCE(temp_daily_build_stats.build_count_day_android, gs.build_count_day_android), - build_avg_seconds_day_ios = COALESCE(temp_daily_build_stats.build_avg_seconds_day_ios, gs.build_avg_seconds_day_ios), - build_avg_seconds_day_android = COALESCE(temp_daily_build_stats.build_avg_seconds_day_android, gs.build_avg_seconds_day_android) -FROM temp_daily_build_stats -WHERE gs.date_id = temp_daily_build_stats.date_id; - -INSERT INTO public.global_stats ( - date_id, - apps, - updates, - stars, - build_total_seconds_day_ios, - build_total_seconds_day_android, - build_count_day_ios, - build_count_day_android, - build_avg_seconds_day_ios, - build_avg_seconds_day_android -) -SELECT - temp_daily_build_stats.date_id, - COALESCE(prev_snapshot.apps, 0)::bigint AS apps, - COALESCE(prev_snapshot.updates, 0)::bigint AS updates, - COALESCE(prev_snapshot.stars, 0)::bigint AS stars, - COALESCE(temp_daily_build_stats.build_total_seconds_day_ios, 0)::bigint, - COALESCE(temp_daily_build_stats.build_total_seconds_day_android, 0)::bigint, - COALESCE(temp_daily_build_stats.build_count_day_ios, 0)::integer, - COALESCE(temp_daily_build_stats.build_count_day_android, 0)::integer, - COALESCE(temp_daily_build_stats.build_avg_seconds_day_ios, 0)::double precision, - COALESCE(temp_daily_build_stats.build_avg_seconds_day_android, 0)::double precision -FROM temp_daily_build_stats -LEFT JOIN LATERAL ( - SELECT - gs.apps, - gs.updates, - gs.stars - FROM public.global_stats gs - WHERE gs.date_id < temp_daily_build_stats.date_id - ORDER BY gs.date_id DESC - LIMIT 1 -) AS prev_snapshot ON true -WHERE NOT EXISTS ( - SELECT 1 - FROM public.global_stats gs - WHERE gs.date_id = temp_daily_build_stats.date_id -); diff --git a/supabase/migrations/20260319164053_fix_manifest_select_rls.sql b/supabase/migrations/20260319164053_fix_manifest_select_rls.sql deleted file mode 100644 index 301d14ec39..0000000000 --- a/supabase/migrations/20260319164053_fix_manifest_select_rls.sql +++ /dev/null @@ -1,26 +0,0 @@ -DROP POLICY IF EXISTS "Allow users to read any manifest entry" ON "public"."manifest"; -DROP POLICY IF EXISTS "Allow users to read manifest entries for accessible apps" ON "public"."manifest"; -DROP POLICY IF EXISTS "Allow select for auth, api keys (read+)" ON "public"."manifest"; - -CREATE POLICY "Allow select for auth, api keys (read+)" ON "public"."manifest" -FOR SELECT -TO "anon", "authenticated" -USING ( - EXISTS ( - SELECT 1 - FROM "public"."app_versions" AS "av" - WHERE - "av"."id" = "manifest"."app_version_id" - AND "public"."check_min_rights"( - 'read'::"public"."user_min_right", - "public"."get_identity_org_appid"( - '{read,upload,write,all}'::"public"."key_mode"[], - "av"."owner_org", - "av"."app_id" - ), - "av"."owner_org", - "av"."app_id", - NULL::bigint - ) - ) -); diff --git a/supabase/migrations/20260319221428_onboarding_app_flags.sql b/supabase/migrations/20260319221428_onboarding_app_flags.sql deleted file mode 100644 index 0eb5dd2f0d..0000000000 --- a/supabase/migrations/20260319221428_onboarding_app_flags.sql +++ /dev/null @@ -1,108 +0,0 @@ -ALTER TABLE "public"."apps" - ADD COLUMN "need_onboarding" boolean NOT NULL DEFAULT false, - ADD COLUMN "existing_app" boolean NOT NULL DEFAULT false, - ADD COLUMN "ios_store_url" text, - ADD COLUMN "android_store_url" text; - -COMMENT ON COLUMN "public"."apps"."need_onboarding" IS 'True while the app is in the guided onboarding flow and may contain temporary onboarding/demo data.'; -COMMENT ON COLUMN "public"."apps"."existing_app" IS 'True when the customer already has an existing mobile app and the CLI should not scaffold a fresh Capacitor app during onboarding.'; -COMMENT ON COLUMN "public"."apps"."ios_store_url" IS 'Optional App Store URL collected during onboarding to prefill metadata for existing apps.'; -COMMENT ON COLUMN "public"."apps"."android_store_url" IS 'Optional Google Play URL collected during onboarding to prefill metadata for existing apps.'; - -CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_app_id text; - v_owner_org uuid; -BEGIN - SELECT app_id, owner_org - INTO v_app_id, v_owner_org - FROM public.apps - WHERE id = p_app_uuid; - - IF v_app_id IS NULL THEN - RETURN; - END IF; - - DELETE FROM public.channel_devices - WHERE app_id = v_app_id; - - DELETE FROM public.deploy_history - WHERE app_id = v_app_id; - - DELETE FROM public.channels - WHERE app_id = v_app_id; - - DELETE FROM public.devices - WHERE app_id = v_app_id; - - DELETE FROM public.app_versions_meta - WHERE app_id = v_app_id; - - DELETE FROM public.app_versions - WHERE app_id = v_app_id; - - DELETE FROM public.daily_version - WHERE app_id = v_app_id; - - DELETE FROM public.daily_bandwidth - WHERE app_id = v_app_id; - - DELETE FROM public.daily_storage - WHERE app_id = v_app_id; - - DELETE FROM public.daily_mau - WHERE app_id = v_app_id; - - DELETE FROM public.daily_build_time - WHERE app_id = v_app_id; - - DELETE FROM public.build_requests - WHERE app_id = v_app_id; - - UPDATE public.apps - SET - channel_device_count = 0, - manifest_bundle_count = 0, - last_version = NULL - WHERE id = p_app_uuid; - - IF v_owner_org IS NOT NULL THEN - DELETE FROM public.app_metrics_cache - WHERE org_id = v_owner_org; - END IF; -END; -$$; - -ALTER FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() -RETURNS trigger -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - IF OLD.need_onboarding IS TRUE AND NEW.need_onboarding IS FALSE THEN - PERFORM public.clear_onboarding_app_data(NEW.id); - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() OWNER TO "postgres"; - -DROP TRIGGER IF EXISTS "cleanup_onboarding_app_data_on_complete" ON "public"."apps"; - -CREATE TRIGGER "cleanup_onboarding_app_data_on_complete" -AFTER UPDATE OF "need_onboarding" ON "public"."apps" -FOR EACH ROW -WHEN (OLD.need_onboarding IS TRUE AND NEW.need_onboarding IS FALSE) -EXECUTE FUNCTION "public"."cleanup_onboarding_app_data_on_complete"(); diff --git a/supabase/migrations/20260319235626_disable_auto_org_on_user_create.sql b/supabase/migrations/20260319235626_disable_auto_org_on_user_create.sql deleted file mode 100644 index 01dd4f665b..0000000000 --- a/supabase/migrations/20260319235626_disable_auto_org_on_user_create.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Stop creating personal organizations as soon as a public.users row is inserted. --- Organization creation now happens explicitly through the onboarding flow. -DROP TRIGGER IF EXISTS "generate_org_on_user_create" ON "public"."users"; -DROP FUNCTION IF EXISTS "public"."generate_org_on_user_create"(); diff --git a/supabase/migrations/20260320044548_add_org_website.sql b/supabase/migrations/20260320044548_add_org_website.sql deleted file mode 100644 index dcce721776..0000000000 --- a/supabase/migrations/20260320044548_add_org_website.sql +++ /dev/null @@ -1,258 +0,0 @@ -ALTER TABLE "public"."orgs" -ADD COLUMN IF NOT EXISTS "website" "text"; - -DROP FUNCTION IF EXISTS "public"."get_orgs_v7"(); -DROP FUNCTION IF EXISTS "public"."get_orgs_v7"("userid" "uuid"); - -CREATE FUNCTION "public"."get_orgs_v7"() RETURNS TABLE("gid" "uuid", "created_by" "uuid", "created_at" timestamp with time zone, "logo" "text", "website" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "stats_updated_at" timestamp without time zone, "next_stats_update_at" timestamp with time zone, "credit_available" numeric, "credit_total" numeric, "credit_next_expiration" timestamp with time zone, "enforcing_2fa" boolean, "2fa_has_access" boolean, "enforce_hashed_api_keys" boolean, "password_policy_config" "jsonb", "password_has_access" boolean, "require_apikey_expiration" boolean, "max_apikey_expiration_days" integer, "enforce_encrypted_bundles" boolean, "required_encryption_key" character varying, "use_new_rbac" boolean, "sso_enabled" boolean) - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v7"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_orgs_v7"() FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."get_orgs_v7"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_orgs_v7"() FROM "authenticated"; -GRANT ALL ON FUNCTION "public"."get_orgs_v7"() TO "anon"; -GRANT ALL ON FUNCTION "public"."get_orgs_v7"() TO "authenticated"; -GRANT ALL ON FUNCTION "public"."get_orgs_v7"() TO "service_role"; - -CREATE FUNCTION "public"."get_orgs_v7"("userid" "uuid") RETURNS TABLE("gid" "uuid", "created_by" "uuid", "created_at" timestamp with time zone, "logo" "text", "website" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "stats_updated_at" timestamp without time zone, "next_stats_update_at" timestamp with time zone, "credit_available" numeric, "credit_total" numeric, "credit_next_expiration" timestamp with time zone, "enforcing_2fa" boolean, "2fa_has_access" boolean, "enforce_hashed_api_keys" boolean, "password_policy_config" "jsonb", "password_has_access" boolean, "require_apikey_expiration" boolean, "max_apikey_expiration_days" integer, "enforce_encrypted_bundles" boolean, "required_encryption_key" character varying, "use_new_rbac" boolean, "sso_enabled" boolean) - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) as cnt - FROM public.apps - GROUP BY owner_org - ), - rbac_roles AS ( - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION ALL - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - rbac_org_roles AS ( - SELECT org_id, (ARRAY_AGG(rbac_roles.name ORDER BY rbac_roles.priority_rank DESC))[1] AS role_name - FROM rbac_roles - GROUP BY org_id - ), - user_orgs AS ( - SELECT ou.org_id - FROM public.org_users ou - WHERE ou.user_id = userid - UNION - SELECT rbac_org_roles.org_id - FROM rbac_org_roles - ), - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) - AND si.subscription_anchor_end > NOW()) - OR si.trial_at > NOW() - ) - ), - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - > NOW() - date_trunc('MONTH', NOW()) - THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - ELSE date_trunc('MONTH', NOW()) - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) - END AS cycle_start - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ), - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - public.user_meets_password_policy(userid, o.id) AS password_has_access, - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ) - SELECT - o.id AS gid, - o.created_by, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE o.created_at - END AS created_at, - o.logo, - o.website, - o.name, - CASE - WHEN o.use_new_rbac AND ou.user_right::text LIKE 'invite_%' THEN ou.user_right::varchar - WHEN o.use_new_rbac THEN COALESCE(ror.role_name, ou.rbac_role_name, ou.user_right::varchar) - ELSE COALESCE(ou.user_right::varchar, ror.role_name) - END AS role, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'succeeded', false) - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE((si.status = 'succeeded' AND si.is_good_plan = true) - OR (si.trial_at::date - NOW()::date > 0) - OR COALESCE(ucb.available_credits, 0) > 0, false) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'canceled', false) - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.available_credits, 0) - END AS credit_available, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.total_credits, 0) - END AS credit_total, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE ucb.next_expiration - END AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key, - o.use_new_rbac, - o.sso_enabled - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - LEFT JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id - LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v7"("userid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_orgs_v7"("userid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."get_orgs_v7"("userid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_orgs_v7"("userid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_orgs_v7"("userid" "uuid") TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."get_orgs_v7"("userid" "uuid") TO "service_role"; diff --git a/supabase/migrations/20260320133752_app_demo_flag_cleanup.sql b/supabase/migrations/20260320133752_app_demo_flag_cleanup.sql deleted file mode 100644 index 891b6b3792..0000000000 --- a/supabase/migrations/20260320133752_app_demo_flag_cleanup.sql +++ /dev/null @@ -1,195 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_app_id text; - v_owner_org uuid; -BEGIN - SELECT app_id, owner_org - INTO v_app_id, v_owner_org - FROM public.apps - WHERE id = p_app_uuid; - - IF v_app_id IS NULL THEN - RETURN; - END IF; - - DELETE FROM public.channel_devices - WHERE app_id = v_app_id; - - DELETE FROM public.deploy_history - WHERE app_id = v_app_id; - - DELETE FROM public.channels - WHERE app_id = v_app_id; - - DELETE FROM public.devices - WHERE app_id = v_app_id; - - DELETE FROM public.app_versions_meta - WHERE app_id = v_app_id; - - DELETE FROM public.daily_version - WHERE app_id = v_app_id; - - DELETE FROM public.daily_bandwidth - WHERE app_id = v_app_id; - - DELETE FROM public.daily_storage - WHERE app_id = v_app_id; - - DELETE FROM public.daily_mau - WHERE app_id = v_app_id; - - DELETE FROM public.daily_build_time - WHERE app_id = v_app_id; - - DELETE FROM public.build_requests - WHERE app_id = v_app_id; - - DELETE FROM public.app_versions - WHERE app_id = v_app_id - AND name NOT IN ('builtin', 'unknown'); - - INSERT INTO public.app_versions ( - owner_org, - deleted, - name, - app_id, - created_at - ) - VALUES - (v_owner_org, true, 'builtin', v_app_id, now()), - (v_owner_org, true, 'unknown', v_app_id, now()) - ON CONFLICT (name, app_id) DO UPDATE - SET - owner_org = EXCLUDED.owner_org, - deleted = true, - deleted_at = NULL, - checksum = NULL, - session_key = NULL, - r2_path = NULL, - link = NULL, - comment = NULL, - updated_at = now(); - - UPDATE public.apps - SET - channel_device_count = 0, - manifest_bundle_count = 0, - last_version = NULL - WHERE id = p_app_uuid; - - IF v_owner_org IS NOT NULL THEN - DELETE FROM public.app_metrics_cache - WHERE org_id = v_owner_org; - END IF; -END; -$$; - -ALTER FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() -RETURNS trigger -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - IF OLD.need_onboarding IS TRUE AND NEW.need_onboarding IS FALSE THEN - PERFORM public.clear_onboarding_app_data(NEW.id); - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() OWNER TO "postgres"; - -DROP TRIGGER IF EXISTS "cleanup_onboarding_app_data_on_complete" ON "public"."apps"; - -CREATE TRIGGER "cleanup_onboarding_app_data_on_complete" -AFTER UPDATE OF "need_onboarding" ON "public"."apps" -FOR EACH ROW -WHEN (OLD.need_onboarding IS TRUE AND NEW.need_onboarding IS FALSE) -EXECUTE FUNCTION "public"."cleanup_onboarding_app_data_on_complete"(); - -CREATE OR REPLACE FUNCTION "public"."has_seeded_demo_data"("p_app_id" text) -RETURNS boolean -LANGUAGE "sql" -SECURITY DEFINER -SET search_path = '' -AS $$ - SELECT EXISTS ( - SELECT 1 - FROM public.app_versions - INNER JOIN public.manifest - ON public.manifest.app_version_id = public.app_versions.id - WHERE public.app_versions.app_id = p_app_id - AND public.manifest.s3_path LIKE ('demo/' || p_app_id || '/%') - ); -$$; - -ALTER FUNCTION "public"."has_seeded_demo_data"(text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."has_seeded_demo_data"(text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."has_seeded_demo_data"(text) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."cleanup_expired_demo_apps"() -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - deleted_count integer; -BEGIN - WITH deleted_apps AS ( - DELETE FROM public.apps - WHERE need_onboarding IS TRUE - AND created_at < now() - interval '14 days' - AND public.has_seeded_demo_data(app_id) - RETURNING owner_org - ), - evicted_cache AS ( - DELETE FROM public.app_metrics_cache - WHERE org_id IN ( - SELECT DISTINCT owner_org - FROM deleted_apps - WHERE owner_org IS NOT NULL - ) - ) - SELECT COUNT(*)::integer - INTO deleted_count - FROM deleted_apps; - - RAISE NOTICE 'cleanup_expired_demo_apps: Deleted % expired demo apps', deleted_count; -END; -$$; - -ALTER FUNCTION "public"."cleanup_expired_demo_apps"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."cleanup_expired_demo_apps"() FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."cleanup_expired_demo_apps"() FROM ANON; -REVOKE ALL ON FUNCTION "public"."cleanup_expired_demo_apps"() FROM AUTHENTICATED; -GRANT EXECUTE ON FUNCTION "public"."cleanup_expired_demo_apps"() TO "service_role"; - -DROP FUNCTION IF EXISTS "public"."create_demo_app_with_limits"( - "p_owner_org" "uuid", - "p_user_id" "uuid", - "p_app_id" "text", - "p_name" "text", - "p_icon_url" "text", - "p_retention" bigint, - "p_default_upload_channel" "text", - "p_last_version" "text", - "p_active_window_days" integer, - "p_user_per_hour" integer, - "p_org_per_hour" integer, - "p_user_per_24h" integer, - "p_org_per_24h" integer, - "p_max_active_per_org" integer -); diff --git a/supabase/migrations/20260323075628_fix_rbac_admin_rpc_execute_grants.sql b/supabase/migrations/20260323075628_fix_rbac_admin_rpc_execute_grants.sql deleted file mode 100644 index 8d63ed8dda..0000000000 --- a/supabase/migrations/20260323075628_fix_rbac_admin_rpc_execute_grants.sql +++ /dev/null @@ -1,51 +0,0 @@ --- Restrict RBAC migration/rollback RPCs to service_role only. --- These helpers are operational/admin functions and must not be callable by --- regular authenticated users through PostgREST. - -REVOKE ALL -ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid, uuid) -FROM PUBLIC; - -REVOKE ALL -ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid, uuid) -FROM anon; -- noqa: CP02 - -REVOKE ALL -ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid, uuid) -FROM authenticated; -- noqa: CP02 - -GRANT EXECUTE -ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid, uuid) -TO service_role; -- noqa: CP02 - -REVOKE ALL -ON FUNCTION public.rbac_enable_for_org(uuid, uuid) -FROM PUBLIC; - -REVOKE ALL -ON FUNCTION public.rbac_enable_for_org(uuid, uuid) -FROM anon; -- noqa: CP02 - -REVOKE ALL -ON FUNCTION public.rbac_enable_for_org(uuid, uuid) -FROM authenticated; -- noqa: CP02 - -GRANT EXECUTE -ON FUNCTION public.rbac_enable_for_org(uuid, uuid) -TO service_role; -- noqa: CP02 - -REVOKE ALL -ON FUNCTION public.rbac_rollback_org(uuid) -FROM PUBLIC; - -REVOKE ALL -ON FUNCTION public.rbac_rollback_org(uuid) -FROM anon; -- noqa: CP02 - -REVOKE ALL -ON FUNCTION public.rbac_rollback_org(uuid) -FROM authenticated; -- noqa: CP02 - -GRANT EXECUTE -ON FUNCTION public.rbac_rollback_org(uuid) -TO service_role; -- noqa: CP02 diff --git a/supabase/migrations/20260324181219_fix_process_cron_stats_activity.sql b/supabase/migrations/20260324181219_fix_process_cron_stats_activity.sql deleted file mode 100644 index ca7724fcf3..0000000000 --- a/supabase/migrations/20260324181219_fix_process_cron_stats_activity.sql +++ /dev/null @@ -1,116 +0,0 @@ --- Keep cron_stat_app refreshes alive for active apps after MAU switched to --- "first seen in billing period" semantics. --- --- Root cause: --- process_cron_stats_jobs() only re-enqueued apps with a recent daily_mau row --- or a recently created version. Once MAU stopped emitting daily rows for --- already-known devices, active apps stopped being reprocessed, which also --- froze daily_bandwidth and dashboard usage charts. - -CREATE OR REPLACE FUNCTION public.queue_cron_stat_app_for_app( - p_app_id character varying, - p_org_id uuid DEFAULT NULL -) RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_org_id uuid; -BEGIN - IF p_app_id IS NULL OR p_app_id = '' THEN - RETURN; - END IF; - - v_org_id := p_org_id; - - IF v_org_id IS NULL THEN - SELECT COALESCE(a.owner_org, da.owner_org) - INTO v_org_id - FROM ( - SELECT p_app_id AS app_id - ) AS requested_app - LEFT JOIN public.apps a ON a.app_id = requested_app.app_id - LEFT JOIN public.deleted_apps da ON da.app_id = requested_app.app_id - LIMIT 1; - END IF; - - IF v_org_id IS NULL THEN - RETURN; - END IF; - - PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_app_id)); - - IF EXISTS ( - SELECT 1 - FROM pgmq.q_cron_stat_app AS queued_job - WHERE queued_job.message->'payload'->>'appId' = p_app_id - ) THEN - RETURN; - END IF; - - PERFORM pgmq.send('cron_stat_app', - jsonb_build_object( - 'function_name', 'cron_stat_app', - 'function_type', 'cloudflare', - 'payload', jsonb_build_object( - 'appId', p_app_id, - 'orgId', v_org_id, - 'todayOnly', false - ) - ) - ); -END; -$function$; - -ALTER FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) FROM anon; -REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) FROM authenticated; -GRANT ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) TO service_role; - -CREATE OR REPLACE FUNCTION public.process_cron_stats_jobs() RETURNS void -LANGUAGE plpgsql -SET search_path = '' AS $function$ -DECLARE - app_record RECORD; -BEGIN - FOR app_record IN ( - WITH active_apps AS ( - SELECT DISTINCT av.app_id - FROM public.app_versions av - WHERE av.created_at >= NOW() - INTERVAL '30 days' - - UNION - - SELECT DISTINCT dm.app_id - FROM public.daily_mau dm - WHERE dm.date >= NOW() - INTERVAL '30 days' AND dm.mau > 0 - - UNION - - SELECT DISTINCT du.app_id - FROM public.device_usage du - WHERE du.timestamp >= NOW() - INTERVAL '30 days' - - UNION - - SELECT DISTINCT bu.app_id - FROM public.bandwidth_usage bu - WHERE bu.timestamp >= NOW() - INTERVAL '30 days' - ) - SELECT DISTINCT - active_apps.app_id, - COALESCE(a.owner_org, da.owner_org) AS owner_org - FROM active_apps - LEFT JOIN public.apps a ON a.app_id = active_apps.app_id - LEFT JOIN public.deleted_apps da ON da.app_id = active_apps.app_id - WHERE COALESCE(a.owner_org, da.owner_org) IS NOT NULL - ) - LOOP - PERFORM public.queue_cron_stat_app_for_app(app_record.app_id, app_record.owner_org); - END LOOP; -END; -$function$; - -ALTER FUNCTION public.process_cron_stats_jobs() OWNER TO postgres; diff --git a/supabase/migrations/20260324181246_add_paid_at_for_admin_revenue_metrics.sql b/supabase/migrations/20260324181246_add_paid_at_for_admin_revenue_metrics.sql deleted file mode 100644 index 3d094a0d14..0000000000 --- a/supabase/migrations/20260324181246_add_paid_at_for_admin_revenue_metrics.sql +++ /dev/null @@ -1,20 +0,0 @@ -ALTER TABLE public.stripe_info -ADD COLUMN IF NOT EXISTS paid_at timestamp with time zone; - -COMMENT ON COLUMN public.stripe_info.paid_at IS 'Timestamp when the org first became a paying customer'; - -UPDATE public.stripe_info -SET paid_at = created_at -WHERE paid_at IS NULL - AND status = 'succeeded'; - -UPDATE public.stripe_info -SET paid_at = COALESCE(subscription_anchor_start, created_at) -WHERE paid_at IS NULL - AND status IN ('canceled', 'failed', 'deleted') - AND subscription_id IS NOT NULL - AND canceled_at IS NOT NULL; - -CREATE INDEX IF NOT EXISTS stripe_info_paid_at_idx -ON public.stripe_info (paid_at) -WHERE paid_at IS NOT NULL; diff --git a/supabase/migrations/20260325032835_optimize_webhooks_rls_auth_eval.sql b/supabase/migrations/20260325032835_optimize_webhooks_rls_auth_eval.sql deleted file mode 100644 index e303b0f234..0000000000 --- a/supabase/migrations/20260325032835_optimize_webhooks_rls_auth_eval.sql +++ /dev/null @@ -1,180 +0,0 @@ --- ============================================================================= --- Migration: Optimize webhook RLS auth/header evaluation --- --- Webhook RLS policies branch on the request API key header and the current --- authenticated user. When those lookups are referenced directly in a policy, --- Postgres may re-evaluate them for each row. Wrap the row-independent calls in --- SELECT so they are planned once per statement. --- ============================================================================= - -DROP POLICY IF EXISTS "Allow admin to select webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to insert webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to update webhooks" ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to delete webhooks" ON public.webhooks; - -CREATE POLICY "Allow admin to select webhooks" -ON public.webhooks -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode [], - org_id - ) - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhooks" -ON public.webhooks -FOR INSERT -TO authenticated, anon -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode [], - org_id - ) - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to update webhooks" -ON public.webhooks -FOR UPDATE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode [], - org_id - ) - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -) -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode [], - org_id - ) - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to delete webhooks" -ON public.webhooks -FOR DELETE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode [], - org_id - ) - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -DROP POLICY IF EXISTS "Allow org members to select webhook_deliveries" ON public.webhook_deliveries; -DROP POLICY IF EXISTS "Allow admin to insert webhook_deliveries" ON public.webhook_deliveries; -DROP POLICY IF EXISTS "Allow admin to update webhook_deliveries" ON public.webhook_deliveries; - -CREATE POLICY "Allow org members to select webhook_deliveries" -ON public.webhook_deliveries -FOR SELECT -TO authenticated, anon -USING ( - public.check_min_rights( - 'read'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{read,write,upload,all}'::public.key_mode [], - org_id - ) - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhook_deliveries" -ON public.webhook_deliveries -FOR INSERT -TO authenticated, anon -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode [], - org_id - ) - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to update webhook_deliveries" -ON public.webhook_deliveries -FOR UPDATE -TO authenticated, anon -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL - THEN public.get_identity_org_allowed_apikey_only( - '{all,write,upload}'::public.key_mode [], - org_id - ) - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); diff --git a/supabase/migrations/20260325043000_harden_cron_stats_queue_followup.sql b/supabase/migrations/20260325043000_harden_cron_stats_queue_followup.sql deleted file mode 100644 index 396774ebf0..0000000000 --- a/supabase/migrations/20260325043000_harden_cron_stats_queue_followup.sql +++ /dev/null @@ -1,139 +0,0 @@ --- Follow up the initial cron stats activity fix without rewriting the --- already-pushed migration on main. --- --- This keeps live dashboard refresh scheduling off the request path while --- tightening queue dedupe/locking behavior in SQL. - -CREATE OR REPLACE FUNCTION public.queue_cron_stat_app_for_app( - p_app_id character varying, - p_org_id uuid DEFAULT NULL -) RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_org_id uuid; - v_lock_key integer; - v_lock_acquired boolean := false; -BEGIN - IF p_app_id IS NULL OR p_app_id = '' THEN - RETURN; - END IF; - - v_org_id := p_org_id; - - IF v_org_id IS NULL THEN - SELECT CASE - WHEN a.owner_org IS NOT NULL THEN a.owner_org - ELSE da.owner_org - END - INTO v_org_id - FROM ( - SELECT p_app_id AS app_id - ) AS requested_app - LEFT JOIN public.apps a ON a.app_id = requested_app.app_id - LEFT JOIN public.deleted_apps da ON da.app_id = requested_app.app_id - LIMIT 1; - END IF; - - IF v_org_id IS NULL THEN - RETURN; - END IF; - - -- Use a session lock so dedupe stays atomic without accumulating xact locks - -- across the whole cron sweep. - v_lock_key := pg_catalog.hashtext(p_app_id); - BEGIN - PERFORM pg_catalog.pg_advisory_lock(v_lock_key); - v_lock_acquired := true; - - IF NOT EXISTS ( - SELECT 1 - FROM pgmq.q_cron_stat_app AS queued_job - WHERE queued_job.message->'payload'->>'appId' = p_app_id - ) THEN - PERFORM pgmq.send('cron_stat_app', - pg_catalog.jsonb_build_object( - 'function_name', 'cron_stat_app', - 'function_type', 'cloudflare', - 'payload', pg_catalog.jsonb_build_object( - 'appId', p_app_id, - 'orgId', v_org_id, - 'todayOnly', false - ) - ) - ); - END IF; - - PERFORM pg_catalog.pg_advisory_unlock(v_lock_key); - v_lock_acquired := false; - EXCEPTION - WHEN query_canceled THEN - IF v_lock_acquired THEN - PERFORM pg_catalog.pg_advisory_unlock(v_lock_key); - END IF; - RAISE; - WHEN OTHERS THEN - IF v_lock_acquired THEN - PERFORM pg_catalog.pg_advisory_unlock(v_lock_key); - END IF; - RAISE; - END; -END; -$function$; - -ALTER FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) FROM anon; -REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) FROM authenticated; -GRANT ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) TO service_role; - -CREATE OR REPLACE FUNCTION public.process_cron_stats_jobs() RETURNS void -LANGUAGE plpgsql -SET search_path = '' AS $function$ -DECLARE - app_record RECORD; -BEGIN - FOR app_record IN ( - WITH active_apps AS ( - SELECT DISTINCT av.app_id - FROM public.app_versions av - WHERE av.created_at >= pg_catalog.now() - INTERVAL '30 days' - - UNION - - SELECT DISTINCT dm.app_id - FROM public.daily_mau dm - WHERE dm.date >= pg_catalog.now() - INTERVAL '30 days' AND dm.mau > 0 - - UNION - - SELECT DISTINCT du.app_id - FROM public.device_usage du - WHERE du.timestamp >= pg_catalog.now() - INTERVAL '30 days' - - UNION - - SELECT DISTINCT bu.app_id - FROM public.bandwidth_usage bu - WHERE bu.timestamp >= pg_catalog.now() - INTERVAL '30 days' - ) - SELECT DISTINCT - active_apps.app_id, - a.owner_org - FROM active_apps - INNER JOIN public.apps a ON a.app_id = active_apps.app_id - ) - LOOP - PERFORM public.queue_cron_stat_app_for_app(app_record.app_id, app_record.owner_org); - END LOOP; -END; -$function$; - -ALTER FUNCTION public.process_cron_stats_jobs() OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.process_cron_stats_jobs() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.process_cron_stats_jobs() FROM anon; -REVOKE ALL ON FUNCTION public.process_cron_stats_jobs() FROM authenticated; -GRANT ALL ON FUNCTION public.process_cron_stats_jobs() TO service_role; diff --git a/supabase/migrations/20260325045835_split_channel_permission_overrides_write_policies.sql b/supabase/migrations/20260325045835_split_channel_permission_overrides_write_policies.sql deleted file mode 100644 index 7541e91a6c..0000000000 --- a/supabase/migrations/20260325045835_split_channel_permission_overrides_write_policies.sql +++ /dev/null @@ -1,98 +0,0 @@ --- Fix Supabase linter warning: channel_permission_overrides had two permissive --- SELECT paths for authenticated because the write policy used FOR ALL. --- Split write access into INSERT / UPDATE / DELETE so SELECT remains a single --- policy and query planning stays cheaper. - -DROP POLICY IF EXISTS channel_permission_overrides_admin_write -ON public.channel_permission_overrides; - -CREATE POLICY channel_permission_overrides_admin_insert -ON public.channel_permission_overrides -FOR INSERT -TO authenticated -WITH CHECK ( - EXISTS ( - SELECT 1 - FROM public.channels - INNER JOIN public.apps ON public.channels.app_id = public.apps.app_id - WHERE - public.channels.id = channel_permission_overrides.channel_id - AND public.rbac_check_permission( - public.rbac_perm_app_update_user_roles(), - public.apps.owner_org, - public.apps.app_id, - NULL::bigint - ) - ) -); - -CREATE POLICY channel_permission_overrides_admin_update -ON public.channel_permission_overrides -FOR UPDATE -TO authenticated -USING ( - EXISTS ( - SELECT 1 - FROM public.channels - INNER JOIN public.apps ON public.channels.app_id = public.apps.app_id - WHERE - public.channels.id = channel_permission_overrides.channel_id - AND public.rbac_check_permission( - public.rbac_perm_app_update_user_roles(), - public.apps.owner_org, - public.apps.app_id, - NULL::bigint - ) - ) -) -WITH CHECK ( - EXISTS ( - SELECT 1 - FROM public.channels - INNER JOIN public.apps ON public.channels.app_id = public.apps.app_id - WHERE - public.channels.id = channel_permission_overrides.channel_id - AND public.rbac_check_permission( - public.rbac_perm_app_update_user_roles(), - public.apps.owner_org, - public.apps.app_id, - NULL::bigint - ) - ) -); - -CREATE POLICY channel_permission_overrides_admin_delete -ON public.channel_permission_overrides -FOR DELETE -TO authenticated -USING ( - EXISTS ( - SELECT 1 - FROM public.channels - INNER JOIN public.apps ON public.channels.app_id = public.apps.app_id - WHERE - public.channels.id = channel_permission_overrides.channel_id - AND public.rbac_check_permission( - public.rbac_perm_app_update_user_roles(), - public.apps.owner_org, - public.apps.app_id, - NULL::bigint - ) - ) -); - -COMMENT ON POLICY channel_permission_overrides_admin_select -ON public.channel_permission_overrides IS -'Authenticated app admins can read channel permission overrides. Single SELECT policy to avoid multiple permissive policies.'; - -COMMENT ON POLICY channel_permission_overrides_admin_insert -ON public.channel_permission_overrides IS -'Authenticated app admins can insert channel permission overrides.'; - -COMMENT ON POLICY channel_permission_overrides_admin_update -ON public.channel_permission_overrides IS -'Authenticated app admins can update channel permission overrides.'; - -COMMENT ON POLICY channel_permission_overrides_admin_delete -ON public.channel_permission_overrides IS -'Authenticated app admins can delete channel permission overrides.'; diff --git a/supabase/migrations/20260327044102_fix_cron_sync_sub_queue_payload.sql b/supabase/migrations/20260327044102_fix_cron_sync_sub_queue_payload.sql deleted file mode 100644 index e1fb469402..0000000000 --- a/supabase/migrations/20260327044102_fix_cron_sync_sub_queue_payload.sql +++ /dev/null @@ -1,40 +0,0 @@ --- Standardize cron_sync_sub queue messages with the shared payload envelope --- consumed by queue_consumer while preserving the legacy Supabase routing. -CREATE OR REPLACE FUNCTION public.process_cron_sync_sub_jobs() RETURNS void -LANGUAGE plpgsql -SET search_path = '' AS $function$ -DECLARE - org_record RECORD; -BEGIN - FOR org_record IN - SELECT DISTINCT - o.id, - si.customer_id - FROM public.orgs AS o - INNER JOIN public.stripe_info AS si ON o.customer_id = si.customer_id - WHERE o.customer_id IS NOT NULL - AND si.customer_id IS NOT NULL - LOOP - PERFORM pgmq.send( - 'cron_sync_sub', - pg_catalog.jsonb_build_object( - 'function_name', 'cron_sync_sub', - 'function_type', NULL, - 'payload', pg_catalog.jsonb_build_object( - 'orgId', org_record.id, - 'customerId', org_record.customer_id - ) - ) - ); - END LOOP; -END; -$function$; - -ALTER FUNCTION public.process_cron_sync_sub_jobs() OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs() FROM anon; -REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs() FROM authenticated; -REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs() FROM service_role; - -GRANT EXECUTE ON FUNCTION public.process_cron_sync_sub_jobs() TO service_role; diff --git a/supabase/migrations/20260327210500_app_scoped_metrics_rbac.sql b/supabase/migrations/20260327210500_app_scoped_metrics_rbac.sql deleted file mode 100644 index 44c2ce505e..0000000000 --- a/supabase/migrations/20260327210500_app_scoped_metrics_rbac.sql +++ /dev/null @@ -1,444 +0,0 @@ --- Restore app-scoped chart access after org-scoped hardening on get_app_metrics. --- The app statistics endpoint already enforces app.read, so it must not depend on --- an org-only RPC that silently returns no rows for app-limited callers. - -CREATE OR REPLACE FUNCTION public.get_app_metrics( - "p_org_id" uuid, - "p_app_id" character varying, - "p_start_date" date, - "p_end_date" date -) -RETURNS TABLE( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $function$ -DECLARE - cache_entry public.app_metrics_cache%ROWTYPE; - caller_role text; - caller_id uuid; - app_exists boolean; -BEGIN - SELECT COALESCE( - NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), - NULLIF(pg_catalog.current_setting('role', true), ''), - NULLIF(COALESCE(session_user, current_user), '') - ) INTO caller_role; - - IF caller_role NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - SELECT public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode[], - get_app_metrics.p_org_id, - get_app_metrics.p_app_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - get_app_metrics.p_org_id, - get_app_metrics.p_app_id, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - SELECT EXISTS ( - SELECT 1 - FROM public.apps - WHERE apps.app_id = get_app_metrics.p_app_id - AND apps.owner_org = get_app_metrics.p_org_id - ) INTO app_exists; - - IF NOT app_exists THEN - RETURN; - END IF; - - SELECT * - INTO cache_entry - FROM public.app_metrics_cache - WHERE app_metrics_cache.org_id = get_app_metrics.p_org_id; - - IF cache_entry.id IS NULL - OR cache_entry.start_date IS DISTINCT FROM get_app_metrics.p_start_date - OR cache_entry.end_date IS DISTINCT FROM get_app_metrics.p_end_date - OR cache_entry.cached_at IS NULL - OR cache_entry.cached_at < (pg_catalog.now() - interval '5 minutes') THEN - cache_entry := public.seed_get_app_metrics_caches( - get_app_metrics.p_org_id, - get_app_metrics.p_start_date, - get_app_metrics.p_end_date - ); - END IF; - - IF cache_entry.response IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT - metrics.app_id, - metrics.date, - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM pg_catalog.jsonb_to_recordset(cache_entry.response) AS metrics( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint - ) - WHERE metrics.app_id = get_app_metrics.p_app_id - ORDER BY metrics.date; -END; -$function$; - -ALTER FUNCTION public.get_app_metrics(uuid, character varying, date, date) - OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) FROM anon; -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) FROM authenticated; -GRANT ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) TO anon; -GRANT ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) TO authenticated; -GRANT ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) TO service_role; - -CREATE OR REPLACE FUNCTION public.get_app_metrics( - "org_id" uuid, - "start_date" date, - "end_date" date -) -RETURNS TABLE( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $function$ -DECLARE - cache_entry public.app_metrics_cache%ROWTYPE; - caller_role text; - caller_id uuid; - org_exists boolean; -BEGIN - SELECT COALESCE( - NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), - NULLIF(pg_catalog.current_setting('role', true), ''), - NULLIF(COALESCE(session_user, current_user), '') - ) INTO caller_role; - - IF caller_role NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - SELECT public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - get_app_metrics.org_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - get_app_metrics.org_id, - NULL::character varying, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - SELECT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE orgs.id = get_app_metrics.org_id - ) INTO org_exists; - - IF NOT org_exists THEN - RETURN; - END IF; - - SELECT * - INTO cache_entry - FROM public.app_metrics_cache - WHERE app_metrics_cache.org_id = get_app_metrics.org_id; - - IF cache_entry.id IS NULL - OR cache_entry.start_date IS DISTINCT FROM get_app_metrics.start_date - OR cache_entry.end_date IS DISTINCT FROM get_app_metrics.end_date - OR cache_entry.cached_at IS NULL - OR cache_entry.cached_at < (pg_catalog.now() - interval '5 minutes') THEN - cache_entry := public.seed_get_app_metrics_caches( - get_app_metrics.org_id, - get_app_metrics.start_date, - get_app_metrics.end_date - ); - END IF; - - IF cache_entry.response IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT - metrics.app_id, - metrics.date, - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM pg_catalog.jsonb_to_recordset(cache_entry.response) AS metrics( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint - ) - ORDER BY metrics.app_id, metrics.date; -END; -$function$; - -ALTER FUNCTION public.get_app_metrics(uuid, date, date) - OWNER TO postgres; - -CREATE OR REPLACE FUNCTION public.get_app_metrics("org_id" uuid) -RETURNS TABLE( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $function$ -DECLARE - caller_role text; - caller_id uuid; - cycle_start timestamptz; - cycle_end timestamptz; - org_exists boolean; -BEGIN - SELECT COALESCE( - NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), - NULLIF(pg_catalog.current_setting('role', true), ''), - NULLIF(COALESCE(session_user, current_user), '') - ) INTO caller_role; - - IF caller_role NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - SELECT public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - get_app_metrics.org_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - get_app_metrics.org_id, - NULL::character varying, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - SELECT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE orgs.id = get_app_metrics.org_id - ) INTO org_exists; - - IF NOT org_exists THEN - RETURN; - END IF; - - SELECT subscription_anchor_start, subscription_anchor_end - INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - - RETURN QUERY - SELECT * - FROM public.get_app_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$function$; - -ALTER FUNCTION public.get_app_metrics(uuid) - OWNER TO postgres; - -CREATE OR REPLACE FUNCTION public.get_global_metrics( - "org_id" uuid, - "start_date" date, - "end_date" date -) -RETURNS TABLE( - date date, - mau bigint, - storage bigint, - bandwidth bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) -LANGUAGE plpgsql -SET search_path TO '' -AS $function$ -DECLARE - caller_role text; - caller_id uuid; -BEGIN - SELECT COALESCE( - NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), - NULLIF(pg_catalog.current_setting('role', true), ''), - NULLIF(COALESCE(session_user, current_user), '') - ) INTO caller_role; - - IF caller_role NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - SELECT public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - get_global_metrics.org_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - get_global_metrics.org_id, - NULL::character varying, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - RETURN QUERY - SELECT - metrics.date, - SUM(metrics.mau)::bigint AS mau, - SUM(metrics.storage)::bigint AS storage, - SUM(metrics.bandwidth)::bigint AS bandwidth, - SUM(metrics.get)::bigint AS get, - SUM(metrics.fail)::bigint AS fail, - SUM(metrics.install)::bigint AS install, - SUM(metrics.uninstall)::bigint AS uninstall - FROM public.get_app_metrics(org_id, start_date, end_date) AS metrics - GROUP BY metrics.date - ORDER BY metrics.date; -END; -$function$; - -ALTER FUNCTION public.get_global_metrics(uuid, date, date) - OWNER TO postgres; - -CREATE OR REPLACE FUNCTION public.get_global_metrics("org_id" uuid) -RETURNS TABLE( - date date, - mau bigint, - storage bigint, - bandwidth bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) -LANGUAGE plpgsql -SET search_path TO '' -AS $function$ -DECLARE - caller_role text; - caller_id uuid; - cycle_start timestamptz; - cycle_end timestamptz; - org_exists boolean; -BEGIN - SELECT COALESCE( - NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), - NULLIF(pg_catalog.current_setting('role', true), ''), - NULLIF(COALESCE(session_user, current_user), '') - ) INTO caller_role; - - IF caller_role NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - SELECT public.get_identity_org_allowed( - '{read,upload,write,all}'::public.key_mode[], - get_global_metrics.org_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - get_global_metrics.org_id, - NULL::character varying, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - SELECT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE orgs.id = get_global_metrics.org_id - ) INTO org_exists; - - IF NOT org_exists THEN - RETURN; - END IF; - - SELECT subscription_anchor_start, subscription_anchor_end - INTO cycle_start, cycle_end - FROM public.get_cycle_info_org(org_id); - - RETURN QUERY - SELECT * - FROM public.get_global_metrics(org_id, cycle_start::date, cycle_end::date); -END; -$function$; - -ALTER FUNCTION public.get_global_metrics(uuid) - OWNER TO postgres; diff --git a/supabase/migrations/20260327220305_add_webhook_queues_to_cron_tasks.sql b/supabase/migrations/20260327220305_add_webhook_queues_to_cron_tasks.sql deleted file mode 100644 index 5c5dd0acae..0000000000 --- a/supabase/migrations/20260327220305_add_webhook_queues_to_cron_tasks.sql +++ /dev/null @@ -1,48 +0,0 @@ --- Ensure webhook queues are drained by the table-driven cron scheduler. --- --- Webhooks were originally added to the legacy hard-coded process_all_cron_tasks --- implementation, but the later cron_tasks migration rebuilt the high-frequency --- queue list without carrying webhook_dispatcher/webhook_delivery forward. --- Update the active cron_tasks row in place so existing environments start --- processing webhook queues again. - -WITH updated_target AS ( - SELECT - ct.name, - ( - WITH current_target AS ( - SELECT COALESCE(ct.target::jsonb, '[]'::jsonb) AS target - ), - ordered_items AS ( - SELECT value, ordinality - FROM current_target, - jsonb_array_elements_text(current_target.target) WITH ORDINALITY AS existing_items(value, ordinality) - - UNION ALL - - SELECT 'webhook_dispatcher', 1000000 - FROM current_target - WHERE NOT current_target.target ? 'webhook_dispatcher' - - UNION ALL - - SELECT 'webhook_delivery', 1000001 - FROM current_target - WHERE NOT current_target.target ? 'webhook_delivery' - ) - SELECT - COALESCE( - jsonb_agg(value ORDER BY ordinality), - '["webhook_dispatcher","webhook_delivery"]'::jsonb - )::text - FROM ordered_items - ) AS normalized_target - FROM public.cron_tasks AS ct - WHERE ct.name = 'high_frequency_queues' -) -UPDATE public.cron_tasks AS ct -SET - target = updated_target.normalized_target, - updated_at = now() -FROM updated_target -WHERE ct.name = updated_target.name; diff --git a/supabase/migrations/20260330141128_stripe_customer_country.sql b/supabase/migrations/20260330141128_stripe_customer_country.sql deleted file mode 100644 index 8a1aaa827c..0000000000 --- a/supabase/migrations/20260330141128_stripe_customer_country.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE public.stripe_info -ADD COLUMN IF NOT EXISTS customer_country character varying(2); - -COMMENT ON COLUMN public.stripe_info.customer_country IS 'Latest ISO 3166-1 alpha-2 billing country code synced from the Stripe customer profile.'; diff --git a/supabase/migrations/20260408134842_adjust_build_time_credit_pricing.sql b/supabase/migrations/20260408134842_adjust_build_time_credit_pricing.sql deleted file mode 100644 index 131845a61a..0000000000 --- a/supabase/migrations/20260408134842_adjust_build_time_credit_pricing.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Move build minutes onto the same shared usage-credit ladder used for the other --- overage metrics while lowering the effective build-minute pricing. --- Keep the existing ranges and update rows in place so historical --- usage_overage_events.credit_step_id links remain attached to their original --- pricing tiers. - -WITH desired_steps (step_min, step_max, price_per_unit, unit_factor) AS ( - VALUES - (0::bigint, 6000::bigint, 0.16::double precision, 60::bigint), - (6000::bigint, 30000::bigint, 0.14::double precision, 60::bigint), - (30000::bigint, 60000::bigint, 0.12::double precision, 60::bigint), - (60000::bigint, 300000::bigint, 0.10::double precision, 60::bigint), - (300000::bigint, 600000::bigint, 0.09::double precision, 60::bigint), - (600000::bigint, 9223372036854775807::bigint, 0.08::double precision, 60::bigint) -), -updated_steps AS ( - UPDATE public.capgo_credits_steps AS existing - SET - price_per_unit = desired_steps.price_per_unit, - unit_factor = desired_steps.unit_factor - FROM desired_steps - WHERE existing.type = 'build_time' - AND existing.org_id IS NULL - AND existing.step_min = desired_steps.step_min - AND existing.step_max = desired_steps.step_max - RETURNING existing.step_min, existing.step_max -) -INSERT INTO public.capgo_credits_steps ( - type, - step_min, - step_max, - price_per_unit, - unit_factor, - org_id -) -SELECT - 'build_time', - desired_steps.step_min, - desired_steps.step_max, - desired_steps.price_per_unit, - desired_steps.unit_factor, - NULL -FROM desired_steps -WHERE NOT EXISTS ( - SELECT 1 - FROM updated_steps - WHERE updated_steps.step_min = desired_steps.step_min - AND updated_steps.step_max = desired_steps.step_max -); diff --git a/supabase/migrations/20260408140215_fix_org_metrics_cache_delete_cascade.sql b/supabase/migrations/20260408140215_fix_org_metrics_cache_delete_cascade.sql deleted file mode 100644 index 633a70a67f..0000000000 --- a/supabase/migrations/20260408140215_fix_org_metrics_cache_delete_cascade.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Ensure org metrics cache rows never block organization deletion. -ALTER TABLE public.org_metrics_cache -DROP CONSTRAINT IF EXISTS org_metrics_cache_org_id_fkey; - -ALTER TABLE public.org_metrics_cache -ADD CONSTRAINT org_metrics_cache_org_id_fkey FOREIGN KEY ( - org_id -) REFERENCES public.orgs (id) ON DELETE CASCADE; diff --git a/supabase/migrations/20260422104849_stale_chart_refresh_state.sql b/supabase/migrations/20260422104849_stale_chart_refresh_state.sql deleted file mode 100644 index f039316b3f..0000000000 --- a/supabase/migrations/20260422104849_stale_chart_refresh_state.sql +++ /dev/null @@ -1,924 +0,0 @@ -ALTER TABLE public.apps -ADD COLUMN IF NOT EXISTS stats_updated_at timestamp without time zone; - -ALTER TABLE public.apps -ADD COLUMN IF NOT EXISTS stats_refresh_requested_at timestamp without time zone; - -ALTER TABLE public.orgs -ADD COLUMN IF NOT EXISTS stats_refresh_requested_at timestamp without time zone; - -UPDATE public.apps AS apps -SET stats_updated_at = orgs.stats_updated_at -FROM public.orgs AS orgs -WHERE apps.owner_org = orgs.id - AND apps.stats_updated_at IS NULL - AND orgs.stats_updated_at IS NOT NULL; - -DROP FUNCTION IF EXISTS public.get_orgs_v7(); -DROP FUNCTION IF EXISTS public.get_orgs_v7(userid uuid); - -CREATE FUNCTION public.get_orgs_v7() RETURNS TABLE( - gid uuid, - created_by uuid, - created_at timestamp with time zone, - logo text, - website text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamp with time zone, - subscription_end timestamp with time zone, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - stats_refresh_requested_at timestamp without time zone, - next_stats_update_at timestamp with time zone, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamp with time zone, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean, - sso_enabled boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO postgres; -REVOKE ALL ON FUNCTION public.get_orgs_v7() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v7() FROM anon; -REVOKE ALL ON FUNCTION public.get_orgs_v7() FROM authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO service_role; - -CREATE FUNCTION public.get_orgs_v7(userid uuid) RETURNS TABLE( - gid uuid, - created_by uuid, - created_at timestamp with time zone, - logo text, - website text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamp with time zone, - subscription_end timestamp with time zone, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - stats_refresh_requested_at timestamp without time zone, - next_stats_update_at timestamp with time zone, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamp with time zone, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean, - sso_enabled boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) AS cnt - FROM public.apps - GROUP BY owner_org - ), - rbac_roles AS ( - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION ALL - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - rbac_org_roles AS ( - SELECT org_id, (ARRAY_AGG(rbac_roles.name ORDER BY rbac_roles.priority_rank DESC))[1] AS role_name - FROM rbac_roles - GROUP BY org_id - ), - user_orgs AS ( - SELECT ou.org_id - FROM public.org_users ou - WHERE ou.user_id = userid - UNION - SELECT rbac_org_roles.org_id - FROM rbac_org_roles - ), - time_constants AS ( - SELECT - NOW() AS current_time, - date_trunc('MONTH', NOW()) AS current_month_start, -- NOSONAR: migration-local billing anchor - '0 DAYS'::INTERVAL AS zero_day_interval - ), - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 AS preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - CROSS JOIN time_constants tc - WHERE ( - (si.status = 'succeeded' -- NOSONAR: existing stripe_info status contract - AND (si.canceled_at IS NULL OR si.canceled_at > tc.current_time) - AND si.subscription_anchor_end > tc.current_time) - OR si.trial_at > tc.current_time - ) - ), - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) - > tc.current_time - tc.current_month_start - THEN date_trunc('MONTH', tc.current_time - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) - ELSE tc.current_month_start - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) - END AS cycle_start - FROM public.orgs o - CROSS JOIN time_constants tc - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ), - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - public.user_meets_password_policy(userid, o.id) AS password_has_access, - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ) - SELECT - o.id AS gid, - o.created_by, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE o.created_at - END AS created_at, - o.logo, - o.website, - o.name, - CASE - WHEN o.use_new_rbac AND ou.user_right::text LIKE 'invite_%' THEN ou.user_right::varchar - WHEN o.use_new_rbac THEN COALESCE(ror.role_name, ou.rbac_role_name, ou.user_right::varchar) - ELSE COALESCE(ou.user_right::varchar, ror.role_name) - END AS role, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'succeeded', false) -- NOSONAR: existing stripe_info status contract - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE((si.status = 'succeeded' AND si.is_good_plan = true) -- NOSONAR: existing stripe_info status contract - OR (si.trial_at::date - NOW()::date > 0) - OR COALESCE(ucb.available_credits, 0) > 0, false) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'canceled', false) - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - o.stats_refresh_requested_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.available_credits, 0) - END AS credit_available, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.total_credits, 0) - END AS credit_total, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE ucb.next_expiration - END AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key, - o.use_new_rbac, - o.sso_enabled - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - LEFT JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id - LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(userid uuid) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.get_orgs_v7(userid uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v7(userid uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_orgs_v7(userid uuid) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(userid uuid) TO postgres; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(userid uuid) TO service_role; - -CREATE OR REPLACE FUNCTION public.queue_cron_stat_app_for_app( - p_app_id character varying, - p_org_id uuid DEFAULT NULL -) RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_org_id uuid; - v_now_utc timestamp without time zone; - v_refresh_ttl CONSTANT interval := INTERVAL '5 minutes'; -- NOSONAR: function-local refresh TTL -BEGIN - IF p_app_id IS NULL OR p_app_id = '' THEN - RETURN; - END IF; - - v_now_utc := pg_catalog.timezone('UTC', pg_catalog.clock_timestamp()); - - UPDATE public.apps AS a - SET stats_refresh_requested_at = v_now_utc - WHERE a.app_id = p_app_id - AND (p_org_id IS NULL OR a.owner_org = p_org_id) - AND (a.stats_updated_at IS NULL OR a.stats_updated_at < v_now_utc - v_refresh_ttl) - AND (a.stats_refresh_requested_at IS NULL OR a.stats_refresh_requested_at < v_now_utc - v_refresh_ttl) - RETURNING a.owner_org - INTO v_org_id; - - IF v_org_id IS NULL THEN - RETURN; - END IF; - - IF EXISTS ( - SELECT 1 - FROM pgmq.q_cron_stat_app AS queued_job - WHERE queued_job.message->'payload'->>'appId' = p_app_id - ) THEN - RETURN; - END IF; - - PERFORM pgmq.send('cron_stat_app', - pg_catalog.jsonb_build_object( - 'function_name', 'cron_stat_app', - 'function_type', 'cloudflare', - 'payload', pg_catalog.jsonb_build_object( - 'appId', p_app_id, - 'orgId', v_org_id, - 'todayOnly', false - ) - ) - ); -END; -$function$; - -ALTER FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) FROM anon; -REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) FROM authenticated; -GRANT ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying, uuid) TO service_role; - -CREATE OR REPLACE FUNCTION public.mark_app_stats_refreshed( - p_app_id character varying -) RETURNS timestamp without time zone -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $function$ -DECLARE - v_now_utc timestamp without time zone := pg_catalog.timezone('UTC', pg_catalog.clock_timestamp()); -BEGIN - IF p_app_id IS NULL OR p_app_id = '' THEN -- NOSONAR: explicit empty-string guard - RETURN NULL; - END IF; - - UPDATE public.apps - SET stats_updated_at = v_now_utc - WHERE app_id = p_app_id; - - IF NOT FOUND THEN - RETURN NULL; - END IF; - - RETURN v_now_utc; -END; -$function$; - -ALTER FUNCTION public.mark_app_stats_refreshed(character varying) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.mark_app_stats_refreshed(character varying) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.mark_app_stats_refreshed(character varying) FROM anon; -REVOKE ALL ON FUNCTION public.mark_app_stats_refreshed(character varying) FROM authenticated; -GRANT ALL ON FUNCTION public.mark_app_stats_refreshed(character varying) TO service_role; - -CREATE OR REPLACE FUNCTION public.request_app_chart_refresh(app_id character varying) -RETURNS TABLE( - requested_at timestamp without time zone, - queued_app_ids character varying[], - queued_count integer, - skipped_count integer -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $function$ -DECLARE - caller_role text; - caller_id uuid; - v_org_id uuid; - v_before_requested_at timestamp without time zone; - v_after_requested_at timestamp without time zone; - v_request_started_at timestamp without time zone := pg_catalog.timezone('UTC', pg_catalog.clock_timestamp()); - v_queued boolean := false; - v_privileged_roles CONSTANT text[] := ARRAY['service_role', 'postgres', 'supabase_admin']; -- NOSONAR: function-local privileged role set - v_read_key_modes CONSTANT public.key_mode[] := '{read,upload,write,all}'::public.key_mode[]; -- NOSONAR: function-local key mode set - v_read_min_right CONSTANT public.user_min_right := 'read'::public.user_min_right; -BEGIN - IF request_app_chart_refresh.app_id IS NULL OR request_app_chart_refresh.app_id = '' THEN - RAISE EXCEPTION 'App ID is required'; - END IF; - - SELECT COALESCE( - NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), -- NOSONAR: request role lookup reused across overloads - NULLIF(pg_catalog.current_setting('role', true), ''), - NULLIF(COALESCE(session_user, current_user), '') - ) INTO caller_role; - - SELECT a.owner_org, a.stats_refresh_requested_at - INTO v_org_id, v_before_requested_at - FROM public.apps a - WHERE a.app_id = request_app_chart_refresh.app_id - LIMIT 1; - - IF caller_role = ANY(v_privileged_roles) AND v_org_id IS NULL THEN - RAISE EXCEPTION 'App not found'; - END IF; - - IF caller_role <> ALL(v_privileged_roles) THEN - IF v_org_id IS NULL THEN - RAISE EXCEPTION 'App access denied'; - END IF; - - SELECT public.get_identity_org_appid( - v_read_key_modes, - v_org_id, - request_app_chart_refresh.app_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - v_read_min_right, - caller_id, - v_org_id, - request_app_chart_refresh.app_id, - NULL::bigint - ) THEN - RAISE EXCEPTION 'App access denied'; - END IF; - END IF; - - PERFORM public.queue_cron_stat_app_for_app(request_app_chart_refresh.app_id, v_org_id); - - SELECT a.stats_refresh_requested_at - INTO v_after_requested_at - FROM public.apps a - WHERE a.app_id = request_app_chart_refresh.app_id - LIMIT 1; - - v_queued := v_after_requested_at IS NOT NULL - AND v_after_requested_at >= v_request_started_at - AND (v_before_requested_at IS NULL OR v_after_requested_at IS DISTINCT FROM v_before_requested_at); - - RETURN QUERY - SELECT - v_after_requested_at, - CASE - WHEN v_queued THEN ARRAY[request_app_chart_refresh.app_id]::character varying[] - ELSE ARRAY[]::character varying[] - END, - CASE WHEN v_queued THEN 1 ELSE 0 END, - CASE WHEN v_queued THEN 0 ELSE 1 END; -END; -$function$; - -ALTER FUNCTION public.request_app_chart_refresh(character varying) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.request_app_chart_refresh(character varying) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.request_app_chart_refresh(character varying) FROM anon; -REVOKE ALL ON FUNCTION public.request_app_chart_refresh(character varying) FROM authenticated; -GRANT ALL ON FUNCTION public.request_app_chart_refresh(character varying) TO authenticated; -GRANT ALL ON FUNCTION public.request_app_chart_refresh(character varying) TO service_role; - -CREATE OR REPLACE FUNCTION public.request_org_chart_refresh(org_id uuid) -RETURNS TABLE( - requested_at timestamp without time zone, - queued_app_ids character varying[], - queued_count integer, - skipped_count integer -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $function$ -DECLARE - caller_role text; - caller_id uuid; - v_request_started_at timestamp without time zone := pg_catalog.timezone('UTC', pg_catalog.clock_timestamp()); - v_queued_app_ids character varying[] := ARRAY[]::character varying[]; - v_queued_count integer := 0; - v_total_count integer := 0; - v_org_exists boolean := false; - v_org_requested_at_before timestamp without time zone; - v_return_requested_at timestamp without time zone; - v_before_requested_at timestamp without time zone; - v_after_requested_at timestamp without time zone; - app_record record; - v_privileged_roles CONSTANT text[] := ARRAY['service_role', 'postgres', 'supabase_admin']; -- NOSONAR: function-local privileged role set - v_read_key_modes CONSTANT public.key_mode[] := '{read,upload,write,all}'::public.key_mode[]; -- NOSONAR: function-local key mode set - v_read_min_right CONSTANT public.user_min_right := 'read'::public.user_min_right; -BEGIN - IF request_org_chart_refresh.org_id IS NULL THEN - RAISE EXCEPTION 'Org ID is required'; - END IF; - - SELECT COALESCE( - NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), -- NOSONAR: request role lookup reused across overloads - NULLIF(pg_catalog.current_setting('role', true), ''), - NULLIF(COALESCE(session_user, current_user), '') - ) INTO caller_role; - - SELECT o.stats_refresh_requested_at - INTO v_org_requested_at_before - FROM public.orgs o - WHERE o.id = request_org_chart_refresh.org_id - LIMIT 1; - - v_org_exists := FOUND; - - IF caller_role = ANY(v_privileged_roles) AND NOT v_org_exists THEN - RAISE EXCEPTION 'Organization not found'; - END IF; - - IF caller_role <> ALL(v_privileged_roles) THEN - IF NOT v_org_exists THEN - RAISE EXCEPTION 'Organization access denied'; - END IF; - - SELECT public.get_identity_org_allowed( - v_read_key_modes, - request_org_chart_refresh.org_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - v_read_min_right, - caller_id, - request_org_chart_refresh.org_id, - NULL::character varying, - NULL::bigint - ) THEN - RAISE EXCEPTION 'Organization access denied'; - END IF; - END IF; - - FOR app_record IN - SELECT a.app_id, a.stats_refresh_requested_at - FROM public.apps a - WHERE a.owner_org = request_org_chart_refresh.org_id - ORDER BY a.app_id - LOOP - v_total_count := v_total_count + 1; - v_before_requested_at := app_record.stats_refresh_requested_at; - - PERFORM public.queue_cron_stat_app_for_app(app_record.app_id, request_org_chart_refresh.org_id); - - SELECT a.stats_refresh_requested_at - INTO v_after_requested_at - FROM public.apps a - WHERE a.app_id = app_record.app_id - LIMIT 1; - - IF v_after_requested_at IS NOT NULL - AND v_after_requested_at >= v_request_started_at - AND (v_before_requested_at IS NULL OR v_after_requested_at IS DISTINCT FROM v_before_requested_at) THEN - v_queued_count := v_queued_count + 1; - v_queued_app_ids := array_append(v_queued_app_ids, app_record.app_id); - END IF; - END LOOP; - - IF v_queued_count > 0 THEN - UPDATE public.orgs - SET stats_refresh_requested_at = v_request_started_at - WHERE id = request_org_chart_refresh.org_id; - - v_return_requested_at := v_request_started_at; - ELSE - v_return_requested_at := v_org_requested_at_before; - END IF; - - RETURN QUERY - SELECT - v_return_requested_at, - COALESCE(v_queued_app_ids, ARRAY[]::character varying[]), - v_queued_count, - GREATEST(v_total_count - v_queued_count, 0); -END; -$function$; - -ALTER FUNCTION public.request_org_chart_refresh(uuid) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.request_org_chart_refresh(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.request_org_chart_refresh(uuid) FROM anon; -REVOKE ALL ON FUNCTION public.request_org_chart_refresh(uuid) FROM authenticated; -GRANT ALL ON FUNCTION public.request_org_chart_refresh(uuid) TO authenticated; -GRANT ALL ON FUNCTION public.request_org_chart_refresh(uuid) TO service_role; - -CREATE OR REPLACE FUNCTION public.get_app_metrics( - "p_org_id" uuid, - "p_app_id" character varying, - "p_start_date" date, - "p_end_date" date -) -RETURNS TABLE( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $function$ -DECLARE - cache_entry public.app_metrics_cache%ROWTYPE; - caller_role text; - caller_id uuid; - app_exists boolean; - org_stats_updated_at timestamp without time zone; - v_cache_ttl CONSTANT interval := INTERVAL '5 minutes'; -- NOSONAR: function-local cache TTL - v_privileged_roles CONSTANT text[] := ARRAY['service_role', 'postgres', 'supabase_admin']; -- NOSONAR: function-local privileged role set - v_read_key_modes CONSTANT public.key_mode[] := '{read,upload,write,all}'::public.key_mode[]; -- NOSONAR: function-local key mode set - v_read_min_right CONSTANT public.user_min_right := 'read'::public.user_min_right; -BEGIN - SELECT COALESCE( - NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), -- NOSONAR: request role lookup reused across overloads - NULLIF(pg_catalog.current_setting('role', true), ''), - NULLIF(COALESCE(session_user, current_user), '') - ) INTO caller_role; - - IF caller_role <> ALL(v_privileged_roles) THEN - SELECT public.get_identity_org_appid( - v_read_key_modes, - get_app_metrics.p_org_id, - get_app_metrics.p_app_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - v_read_min_right, - caller_id, - get_app_metrics.p_org_id, - get_app_metrics.p_app_id, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - SELECT EXISTS ( - SELECT 1 - FROM public.apps - WHERE apps.app_id = get_app_metrics.p_app_id - AND apps.owner_org = get_app_metrics.p_org_id - ) INTO app_exists; - - IF NOT app_exists THEN - RETURN; - END IF; - - SELECT o.stats_updated_at - INTO org_stats_updated_at - FROM public.orgs o - WHERE o.id = get_app_metrics.p_org_id - LIMIT 1; - - SELECT * - INTO cache_entry - FROM public.app_metrics_cache - WHERE app_metrics_cache.org_id = get_app_metrics.p_org_id; - - IF cache_entry.id IS NULL - OR cache_entry.start_date IS DISTINCT FROM get_app_metrics.p_start_date - OR cache_entry.end_date IS DISTINCT FROM get_app_metrics.p_end_date - OR cache_entry.cached_at IS NULL - OR cache_entry.cached_at < (pg_catalog.now() - v_cache_ttl) - OR ( - org_stats_updated_at IS NOT NULL - AND pg_catalog.timezone('UTC', cache_entry.cached_at) < org_stats_updated_at - ) THEN - cache_entry := public.seed_get_app_metrics_caches( - get_app_metrics.p_org_id, - get_app_metrics.p_start_date, - get_app_metrics.p_end_date - ); - END IF; - - IF cache_entry.response IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT - metrics.app_id, - metrics.date, - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM pg_catalog.jsonb_to_recordset(cache_entry.response) AS metrics( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint - ) - WHERE metrics.app_id = get_app_metrics.p_app_id - ORDER BY metrics.date; -END; -$function$; - -ALTER FUNCTION public.get_app_metrics(uuid, character varying, date, date) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) FROM anon; -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) FROM authenticated; -GRANT ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) TO anon; -GRANT ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) TO authenticated; -GRANT ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) TO service_role; - -CREATE OR REPLACE FUNCTION public.get_app_metrics( - "org_id" uuid, - "start_date" date, - "end_date" date -) -RETURNS TABLE( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $function$ -DECLARE - cache_entry public.app_metrics_cache%ROWTYPE; - caller_role text; - caller_id uuid; - org_exists boolean; - org_stats_updated_at timestamp without time zone; - v_cache_ttl CONSTANT interval := INTERVAL '5 minutes'; -- NOSONAR: function-local cache TTL - v_privileged_roles CONSTANT text[] := ARRAY['service_role', 'postgres', 'supabase_admin']; -- NOSONAR: function-local privileged role set - v_read_key_modes CONSTANT public.key_mode[] := '{read,upload,write,all}'::public.key_mode[]; -- NOSONAR: function-local key mode set - v_read_min_right CONSTANT public.user_min_right := 'read'::public.user_min_right; -BEGIN - SELECT COALESCE( - NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), -- NOSONAR: request role lookup reused across overloads - NULLIF(pg_catalog.current_setting('role', true), ''), - NULLIF(COALESCE(session_user, current_user), '') - ) INTO caller_role; - - IF caller_role <> ALL(v_privileged_roles) THEN - SELECT public.get_identity_org_allowed( - v_read_key_modes, - get_app_metrics.org_id - ) - INTO caller_id; - - IF caller_id IS NULL OR NOT public.check_min_rights( - v_read_min_right, - caller_id, - get_app_metrics.org_id, - NULL::character varying, - NULL::bigint - ) THEN - RETURN; - END IF; - END IF; - - SELECT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE orgs.id = get_app_metrics.org_id - ) INTO org_exists; - - IF NOT org_exists THEN - RETURN; - END IF; - - SELECT o.stats_updated_at - INTO org_stats_updated_at - FROM public.orgs o - WHERE o.id = get_app_metrics.org_id - LIMIT 1; - - SELECT * - INTO cache_entry - FROM public.app_metrics_cache - WHERE app_metrics_cache.org_id = get_app_metrics.org_id; - - IF cache_entry.id IS NULL - OR cache_entry.start_date IS DISTINCT FROM get_app_metrics.start_date - OR cache_entry.end_date IS DISTINCT FROM get_app_metrics.end_date - OR cache_entry.cached_at IS NULL - OR cache_entry.cached_at < (pg_catalog.now() - v_cache_ttl) - OR ( - org_stats_updated_at IS NOT NULL - AND pg_catalog.timezone('UTC', cache_entry.cached_at) < org_stats_updated_at - ) THEN - cache_entry := public.seed_get_app_metrics_caches( - get_app_metrics.org_id, - get_app_metrics.start_date, - get_app_metrics.end_date - ); - END IF; - - IF cache_entry.response IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT - metrics.app_id, - metrics.date, - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM pg_catalog.jsonb_to_recordset(cache_entry.response) AS metrics( - app_id character varying, - date date, - mau bigint, - storage bigint, - bandwidth bigint, - build_time_unit bigint, - get bigint, - fail bigint, - install bigint, - uninstall bigint - ) - ORDER BY metrics.app_id, metrics.date; -END; -$function$; - -ALTER FUNCTION public.get_app_metrics(uuid, date, date) OWNER TO postgres; diff --git a/supabase/migrations/20260422203355_add_admin_retention_metrics.sql b/supabase/migrations/20260422203355_add_admin_retention_metrics.sql deleted file mode 100644 index 3cbcd2c906..0000000000 --- a/supabase/migrations/20260422203355_add_admin_retention_metrics.sql +++ /dev/null @@ -1,69 +0,0 @@ -CREATE TABLE IF NOT EXISTS public.daily_revenue_metrics ( - date_id character varying NOT NULL, - customer_id character varying NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - opening_mrr double precision DEFAULT 0 NOT NULL, - new_business_mrr double precision DEFAULT 0 NOT NULL, - expansion_mrr double precision DEFAULT 0 NOT NULL, - contraction_mrr double precision DEFAULT 0 NOT NULL, - churn_mrr double precision DEFAULT 0 NOT NULL, - CONSTRAINT daily_revenue_metrics_pkey PRIMARY KEY (date_id, customer_id) -); - -ALTER TABLE public.daily_revenue_metrics OWNER TO postgres; - -COMMENT ON TABLE public.daily_revenue_metrics IS 'Daily MRR movement rollup per customer, fed by Stripe webhook events for admin retention analytics.'; -COMMENT ON COLUMN public.daily_revenue_metrics.opening_mrr IS 'Customer monthly recurring revenue at the start of the UTC day, before any tracked movement.'; -COMMENT ON COLUMN public.daily_revenue_metrics.new_business_mrr IS 'New monthly recurring revenue created on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.expansion_mrr IS 'Expansion monthly recurring revenue added on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.contraction_mrr IS 'Monthly recurring revenue lost to downgrades on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.churn_mrr IS 'Monthly recurring revenue fully lost to churn on the day.'; - -CREATE INDEX IF NOT EXISTS daily_revenue_metrics_date_id_idx -ON public.daily_revenue_metrics (date_id); - -REVOKE ALL ON TABLE public.daily_revenue_metrics FROM PUBLIC; -REVOKE ALL ON TABLE public.daily_revenue_metrics FROM anon; -REVOKE ALL ON TABLE public.daily_revenue_metrics FROM authenticated; -GRANT ALL ON TABLE public.daily_revenue_metrics TO service_role; - -CREATE TABLE IF NOT EXISTS public.processed_stripe_events ( - event_id text NOT NULL, - customer_id character varying NOT NULL, - date_id character varying NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT processed_stripe_events_pkey PRIMARY KEY (event_id) -); - -ALTER TABLE public.processed_stripe_events OWNER TO postgres; - -COMMENT ON TABLE public.processed_stripe_events IS 'Idempotency ledger for Stripe webhook events that have already updated retention revenue metrics.'; - -CREATE INDEX IF NOT EXISTS processed_stripe_events_customer_id_date_id_idx -ON public.processed_stripe_events (customer_id, date_id); - -REVOKE ALL ON TABLE public.processed_stripe_events FROM PUBLIC; -REVOKE ALL ON TABLE public.processed_stripe_events FROM anon; -REVOKE ALL ON TABLE public.processed_stripe_events FROM authenticated; -GRANT ALL ON TABLE public.processed_stripe_events TO service_role; - -ALTER TABLE public.global_stats -ADD COLUMN IF NOT EXISTS nrr double precision DEFAULT 100 NOT NULL, -ADD COLUMN IF NOT EXISTS churn_revenue double precision DEFAULT 0 NOT NULL; - -ALTER TABLE public.stripe_info -ADD COLUMN IF NOT EXISTS last_stripe_event_at timestamp with time zone; - -COMMENT ON COLUMN public.stripe_info.last_stripe_event_at IS 'Timestamp of the most recent Stripe event applied to this row, used for webhook ordering checks.'; - -UPDATE public.global_stats -SET nrr = 100 -WHERE nrr IS NULL; - -UPDATE public.global_stats -SET churn_revenue = 0 -WHERE churn_revenue IS NULL; - -COMMENT ON COLUMN public.global_stats.nrr IS 'Net Revenue Retention percentage for the day based on prior-day MRR, excluding new business.'; -COMMENT ON COLUMN public.global_stats.churn_revenue IS 'Total monthly recurring revenue lost to churn and downgrades on the day in dollars.'; diff --git a/supabase/migrations/20260424090111_fix_rbac_scope_mismatch_escalation.sql b/supabase/migrations/20260424090111_fix_rbac_scope_mismatch_escalation.sql deleted file mode 100644 index ac96834413..0000000000 --- a/supabase/migrations/20260424090111_fix_rbac_scope_mismatch_escalation.sql +++ /dev/null @@ -1,182 +0,0 @@ -CREATE OR REPLACE FUNCTION public.enforce_role_binding_role_scope() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' -AS $$ -DECLARE - v_role_scope_type text; -BEGIN - SELECT r.scope_type - INTO v_role_scope_type - FROM public.roles r - WHERE r.id = NEW.role_id - LIMIT 1; - - IF v_role_scope_type IS NULL THEN - RETURN NEW; - END IF; - - IF v_role_scope_type <> NEW.scope_type THEN - RAISE EXCEPTION USING - ERRCODE = '23514', - MESSAGE = 'ROLE_SCOPE_MISMATCH'; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.enforce_role_binding_role_scope() OWNER TO "postgres"; - -COMMENT ON FUNCTION public.enforce_role_binding_role_scope() IS - 'Rejects role_bindings writes where the bound role family does not match the binding scope_type.'; - -DROP TRIGGER IF EXISTS enforce_role_binding_role_scope ON public.role_bindings; - -CREATE TRIGGER enforce_role_binding_role_scope -BEFORE INSERT OR UPDATE OF role_id, scope_type -ON public.role_bindings -FOR EACH ROW -EXECUTE FUNCTION public.enforce_role_binding_role_scope(); - -COMMENT ON TRIGGER enforce_role_binding_role_scope ON public.role_bindings IS - 'Prevents mixed-scope RBAC bindings such as org roles attached to app scope rows.'; - -DELETE FROM public.role_bindings rb -USING public.roles r -WHERE rb.role_id = r.id - AND rb.scope_type <> r.scope_type; - -CREATE OR REPLACE FUNCTION public.rbac_has_permission( - p_principal_type text, - p_principal_id uuid, - p_permission_key text, - p_org_id uuid, - p_app_id character varying, - p_channel_id bigint -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $function$ -DECLARE - v_org_id uuid := p_org_id; - v_app_uuid uuid; - v_app_owner_org uuid; - v_channel_uuid uuid; - v_channel_app_id text; - v_channel_org_id uuid; - v_has boolean := false; -BEGIN - IF p_permission_key IS NULL THEN - RETURN false; - END IF; - - -- Resolve scope identifiers to UUIDs. Preserve the caller org when the app does not exist yet. - IF p_app_id IS NOT NULL THEN - SELECT id, owner_org INTO v_app_uuid, v_app_owner_org - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - - IF v_app_owner_org IS NOT NULL THEN - v_org_id := v_app_owner_org; - END IF; - END IF; - - IF p_channel_id IS NOT NULL THEN - SELECT rbac_id, app_id, owner_org INTO v_channel_uuid, v_channel_app_id, v_channel_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_uuid IS NOT NULL THEN - IF p_app_id IS NOT NULL AND p_app_id IS DISTINCT FROM v_channel_app_id THEN - RETURN false; - END IF; - - IF p_org_id IS NOT NULL AND p_org_id IS DISTINCT FROM v_channel_org_id THEN - RETURN false; - END IF; - - SELECT id INTO v_app_uuid - FROM public.apps - WHERE app_id = v_channel_app_id - LIMIT 1; - - v_org_id := v_channel_org_id; - END IF; - END IF; - - WITH RECURSIVE scope_catalog AS ( - SELECT public.rbac_scope_org()::text AS scope_type, v_org_id AS org_id, NULL::uuid AS app_id, NULL::uuid AS channel_id WHERE v_org_id IS NOT NULL - UNION ALL - SELECT public.rbac_scope_app(), v_org_id, v_app_uuid, NULL::uuid WHERE v_app_uuid IS NOT NULL - UNION ALL - SELECT public.rbac_scope_channel(), v_org_id, v_app_uuid, v_channel_uuid WHERE v_channel_uuid IS NOT NULL - ), - direct_roles AS ( - SELECT rb.role_id, rb.scope_type - FROM scope_catalog s - JOIN public.role_bindings rb ON rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.channel_id = s.channel_id) - ) - JOIN public.roles r ON r.id = rb.role_id - AND r.scope_type = rb.scope_type - WHERE rb.principal_type = p_principal_type - AND rb.principal_id = p_principal_id - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - group_roles AS ( - SELECT rb.role_id, rb.scope_type - FROM scope_catalog s - JOIN public.group_members gm ON gm.user_id = p_principal_id - JOIN public.groups g ON g.id = gm.group_id - JOIN public.role_bindings rb ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = gm.group_id - JOIN public.roles r ON r.id = rb.role_id - AND r.scope_type = rb.scope_type - WHERE p_principal_type = public.rbac_principal_user() - AND rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.channel_id = s.channel_id) - ) - AND (v_org_id IS NULL OR g.org_id = v_org_id) - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - combined_roles AS ( - SELECT role_id, scope_type FROM direct_roles - UNION - SELECT role_id, scope_type FROM group_roles - ), - role_closure AS ( - SELECT role_id, scope_type FROM combined_roles - UNION - SELECT rh.child_role_id, rc.scope_type - FROM public.role_hierarchy rh - JOIN role_closure rc ON rc.role_id = rh.parent_role_id - JOIN public.roles child_role ON child_role.id = rh.child_role_id - AND child_role.scope_type = rc.scope_type - ), - perm_set AS ( - SELECT DISTINCT p.key - FROM role_closure rc - JOIN public.role_permissions rp ON rp.role_id = rc.role_id - JOIN public.permissions p ON p.id = rp.permission_id - ) - SELECT EXISTS (SELECT 1 FROM perm_set WHERE key = p_permission_key) INTO v_has; - - RETURN v_has; -END; -$function$; - -ALTER FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) FROM anon; -REVOKE ALL ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) TO service_role; diff --git a/supabase/migrations/20260424090125_protect_owner_org_transfer_path.sql b/supabase/migrations/20260424090125_protect_owner_org_transfer_path.sql deleted file mode 100644 index f319486aa7..0000000000 --- a/supabase/migrations/20260424090125_protect_owner_org_transfer_path.sql +++ /dev/null @@ -1,168 +0,0 @@ -CREATE OR REPLACE FUNCTION public.guard_owner_org_reassignment() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' -AS $$ -BEGIN - IF NEW.owner_org IS DISTINCT FROM OLD.owner_org - AND current_setting('capgo.allow_owner_org_transfer', true) IS DISTINCT FROM 'true' THEN - RAISE EXCEPTION 'owner_org must be changed through public.transfer_app()'; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.guard_owner_org_reassignment() OWNER TO "postgres"; - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_apps ON public.apps; -CREATE TRIGGER guard_owner_org_reassignment_apps -BEFORE UPDATE OF owner_org ON public.apps -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_app_versions ON public.app_versions; -CREATE TRIGGER guard_owner_org_reassignment_app_versions -BEFORE UPDATE OF owner_org ON public.app_versions -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_app_versions_meta ON public.app_versions_meta; -CREATE TRIGGER guard_owner_org_reassignment_app_versions_meta -BEFORE UPDATE OF owner_org ON public.app_versions_meta -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_channel_devices ON public.channel_devices; -CREATE TRIGGER guard_owner_org_reassignment_channel_devices -BEFORE UPDATE OF owner_org ON public.channel_devices -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_channels ON public.channels; -CREATE TRIGGER guard_owner_org_reassignment_channels -BEFORE UPDATE OF owner_org ON public.channels -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -CREATE OR REPLACE FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) RETURNS void -LANGUAGE plpgsql SECURITY DEFINER -SET search_path TO '' -AS $$ -DECLARE - v_old_org_id uuid; - v_user_id uuid; - v_last_transfer jsonb; - v_last_transfer_date timestamp; - v_transfer_error constant text := 'Unable to process transfer request.'; - v_app_id_key constant text := 'app_id'; - v_old_org_id_key constant text := 'old_org_id'; - v_new_org_id_key constant text := 'new_org_id'; - v_uid_key constant text := 'uid'; -BEGIN - SELECT owner_org, transfer_history[array_length(transfer_history, 1)] - INTO v_old_org_id, v_last_transfer - FROM public.apps - WHERE app_id = p_app_id; - - IF v_old_org_id IS NULL THEN - RAISE EXCEPTION '%', v_transfer_error; - END IF; - - v_user_id := (SELECT auth.uid()); - - IF v_user_id IS NULL THEN - PERFORM public.pg_log( - 'deny: TRANSFER_NO_AUTH', - jsonb_build_object(v_app_id_key, p_app_id, v_new_org_id_key, p_new_org_id) - ); - RAISE EXCEPTION '%', v_transfer_error; - END IF; - - IF NOT public.rbac_check_permission( - public.rbac_perm_app_transfer(), - v_old_org_id, - p_app_id, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: TRANSFER_OLD_ORG_RIGHTS', - jsonb_build_object( - v_app_id_key, p_app_id, - v_old_org_id_key, v_old_org_id, - v_new_org_id_key, p_new_org_id, - v_uid_key, v_user_id - ) - ); - RAISE EXCEPTION '%', v_transfer_error; - END IF; - - IF NOT public.rbac_check_permission( - public.rbac_perm_app_transfer(), - p_new_org_id, - NULL::character varying, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: TRANSFER_NEW_ORG_RIGHTS', - jsonb_build_object( - v_app_id_key, p_app_id, - v_old_org_id_key, v_old_org_id, - v_new_org_id_key, p_new_org_id, - v_uid_key, v_user_id - ) - ); - RAISE EXCEPTION '%', v_transfer_error; - END IF; - - IF v_last_transfer IS NOT NULL THEN - v_last_transfer_date := (v_last_transfer->>'transferred_at')::timestamp; - IF v_last_transfer_date + interval '32 days' > now() THEN - RAISE EXCEPTION - 'Cannot transfer app. Must wait at least 32 days ' - 'between transfers. Last transfer was on %', - v_last_transfer_date; - END IF; - END IF; - - -- Allow the guarded owner_org cascade only inside the approved transfer path. - PERFORM set_config('capgo.allow_owner_org_transfer', 'true', true); - - UPDATE public.apps - SET - owner_org = p_new_org_id, - updated_at = now(), - transfer_history = COALESCE(transfer_history, '{}') || jsonb_build_object( - 'transferred_at', now(), - 'transferred_from', v_old_org_id, - 'transferred_to', p_new_org_id, - 'initiated_by', v_user_id - )::jsonb - WHERE app_id = p_app_id; - - UPDATE public.app_versions - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.app_versions_meta - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channel_devices - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channels - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - -END; -$$; - -ALTER FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) OWNER TO "postgres"; diff --git a/supabase/migrations/20260424090727_block_apikey_channel_updates.sql b/supabase/migrations/20260424090727_block_apikey_channel_updates.sql deleted file mode 100644 index c561eb90e4..0000000000 --- a/supabase/migrations/20260424090727_block_apikey_channel_updates.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Block direct PostgREST channel updates for write-scoped API keys. --- Authenticated users keep their existing write access, and all-scoped API keys --- still retain the direct channel update behavior expected by the CLI. - -DROP POLICY IF EXISTS "Allow update for auth, api keys (write, all) (write+)" ON public.channels; - -CREATE POLICY "Allow update for auth, api keys (write, all) (write+)" ON public.channels -FOR UPDATE -TO anon, authenticated -USING ( - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid('{all}'::public.key_mode[], owner_org, app_id), - owner_org, - app_id, - NULL::bigint - ) -) -WITH CHECK ( - public.check_min_rights( - 'write'::public.user_min_right, - public.get_identity_org_appid('{all}'::public.key_mode[], owner_org, app_id), - owner_org, - app_id, - NULL::bigint - ) -); diff --git a/supabase/migrations/20260424090854_enforce_public_channel_uniqueness.sql b/supabase/migrations/20260424090854_enforce_public_channel_uniqueness.sql deleted file mode 100644 index d4b6d5abee..0000000000 --- a/supabase/migrations/20260424090854_enforce_public_channel_uniqueness.sql +++ /dev/null @@ -1,81 +0,0 @@ --- Enforce one public channel winner per platform at write time. --- This closes the race where overlapping public channels can coexist briefly --- and unnamed /updates requests silently pick an implicit winner. - -CREATE OR REPLACE FUNCTION public.normalize_public_channel_overlap() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- Serialize public-channel changes per app so concurrent writers cannot - -- reintroduce overlapping public state between the normalization update and - -- the row write itself. Taking this lock before the cross-row UPDATE also - -- makes same-app writers wait here instead of deadlocking on channel rows. - PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(NEW.app_id)); - - IF NEW.public IS DISTINCT FROM true THEN - RETURN NEW; - END IF; - - UPDATE public.channels AS existing - SET public = false - WHERE existing.app_id = NEW.app_id - AND existing.public = true - AND existing.id IS DISTINCT FROM NEW.id - AND ( - (NEW.ios = true AND existing.ios = true) - OR (NEW.android = true AND existing.android = true) - OR (NEW.electron = true AND existing.electron = true) - ); - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.normalize_public_channel_overlap() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION public.normalize_public_channel_overlap() FROM PUBLIC; - -DROP TRIGGER IF EXISTS normalize_public_channel_overlap_before_upsert ON public.channels; -CREATE TRIGGER normalize_public_channel_overlap_before_upsert -BEFORE INSERT OR UPDATE OF public, ios, android, electron, app_id -ON public.channels -FOR EACH ROW -EXECUTE FUNCTION public.normalize_public_channel_overlap(); - --- Normalize any pre-existing conflicting public rows so the unique indexes can --- be added safely. Keep the newest overlapping row and demote older ones, --- matching the intended "last public write wins" behavior. -UPDATE public.channels AS older -SET public = false -WHERE older.public = true - AND EXISTS ( - SELECT 1 - FROM public.channels AS newer - WHERE newer.app_id = older.app_id - AND newer.public = true - AND newer.id <> older.id - AND ( - (older.ios = true AND newer.ios = true) - OR (older.android = true AND newer.android = true) - OR (older.electron = true AND newer.electron = true) - ) - AND ( - newer.updated_at > older.updated_at - OR (newer.updated_at = older.updated_at AND newer.created_at > older.created_at) - OR (newer.updated_at = older.updated_at AND newer.created_at = older.created_at AND newer.id > older.id) - ) - ); - -CREATE UNIQUE INDEX IF NOT EXISTS channels_one_public_ios_per_app_key -ON public.channels (app_id) -WHERE public = true AND ios = true; - -CREATE UNIQUE INDEX IF NOT EXISTS channels_one_public_android_per_app_key -ON public.channels (app_id) -WHERE public = true AND android = true; - -CREATE UNIQUE INDEX IF NOT EXISTS channels_one_public_electron_per_app_key -ON public.channels (app_id) -WHERE public = true AND electron = true; diff --git a/supabase/migrations/20260424090941_fix_transfer_app_deploy_history_owner_org.sql b/supabase/migrations/20260424090941_fix_transfer_app_deploy_history_owner_org.sql deleted file mode 100644 index eafcab48ff..0000000000 --- a/supabase/migrations/20260424090941_fix_transfer_app_deploy_history_owner_org.sql +++ /dev/null @@ -1,181 +0,0 @@ -CREATE OR REPLACE FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) RETURNS void -LANGUAGE plpgsql SECURITY DEFINER -SET search_path TO '' -AS $$ -DECLARE - v_old_org_id uuid; - v_user_id uuid; - v_last_transfer jsonb; - v_last_transfer_date timestamp; - v_transfer_request_error constant text := 'Unable to process transfer request.'; -BEGIN - SELECT owner_org, transfer_history[pg_catalog.array_length(transfer_history, 1)] - INTO v_old_org_id, v_last_transfer - FROM public.apps - WHERE app_id = p_app_id - FOR UPDATE; - - IF v_old_org_id IS NULL THEN - RAISE EXCEPTION '%', v_transfer_request_error; - END IF; - - v_user_id := (SELECT auth.uid()); - - IF v_user_id IS NULL THEN - PERFORM public.pg_log( - 'deny: TRANSFER_NO_AUTH', - pg_catalog.jsonb_build_object('app_id', p_app_id, 'new_org_id', p_new_org_id) - ); - RAISE EXCEPTION '%', v_transfer_request_error; - END IF; - - IF v_old_org_id = p_new_org_id THEN - PERFORM public.pg_log( - 'deny: TRANSFER_SAME_ORG', - pg_catalog.jsonb_build_object( - 'app_id', p_app_id, - 'old_org_id', v_old_org_id, - 'new_org_id', p_new_org_id, - 'uid', v_user_id - ) - ); - RAISE EXCEPTION '%', v_transfer_request_error; - END IF; - - IF NOT public.rbac_check_permission( - public.rbac_perm_app_transfer(), - v_old_org_id, - p_app_id, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: TRANSFER_OLD_ORG_RIGHTS', - pg_catalog.jsonb_build_object( - 'app_id', p_app_id, - 'old_org_id', v_old_org_id, - 'new_org_id', p_new_org_id, - 'uid', v_user_id - ) - ); - RAISE EXCEPTION '%', v_transfer_request_error; - END IF; - - IF NOT public.rbac_check_permission( - public.rbac_perm_app_transfer(), - p_new_org_id, - NULL::character varying, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: TRANSFER_NEW_ORG_RIGHTS', - pg_catalog.jsonb_build_object( - 'app_id', p_app_id, - 'old_org_id', v_old_org_id, - 'new_org_id', p_new_org_id, - 'uid', v_user_id - ) - ); - RAISE EXCEPTION '%', v_transfer_request_error; - END IF; - - IF v_last_transfer IS NOT NULL THEN - v_last_transfer_date := (v_last_transfer->>'transferred_at')::timestamp; - IF v_last_transfer_date + interval '32 days' > pg_catalog.now() THEN - RAISE EXCEPTION - 'Cannot transfer app. Must wait at least 32 days ' - 'between transfers. Last transfer was on %', - v_last_transfer_date; - END IF; - END IF; - - -- Allow the guarded owner_org cascade only inside the approved transfer path. - PERFORM pg_catalog.set_config('capgo.allow_owner_org_transfer', 'true', true); - - UPDATE public.apps - SET - owner_org = p_new_org_id, - updated_at = pg_catalog.now(), - transfer_history = ( - CASE - WHEN transfer_history IS NULL THEN '{}'::jsonb[] - ELSE transfer_history - END - ) || pg_catalog.jsonb_build_object( - 'transferred_at', pg_catalog.now(), - 'transferred_from', v_old_org_id, - 'transferred_to', p_new_org_id, - 'initiated_by', v_user_id - ) - WHERE app_id = p_app_id; - - UPDATE public.app_versions - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.app_versions_meta - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channel_devices - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channels - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.deploy_history - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - PERFORM pg_catalog.set_config('capgo.allow_owner_org_transfer', 'false', true); - -END; -$$; - -ALTER FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) FROM anon; -REVOKE ALL ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) FROM authenticated; -REVOKE ALL ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) FROM service_role; -GRANT EXECUTE ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) TO service_role; - -COMMENT ON FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) IS 'Transfers an app and all its related data to a new ' -'organization. Requires app.transfer permission on both ' -'source and destination organizations.'; - --- Repair stale deploy_history ownership left behind by previous app transfers. -UPDATE public.deploy_history AS deploy_history -SET owner_org = apps.owner_org -FROM public.apps AS apps -WHERE apps.app_id = deploy_history.app_id - AND deploy_history.owner_org IS DISTINCT FROM apps.owner_org; diff --git a/supabase/migrations/20260424091645_enforce_hashed_api_keys_on_rls_identity_path.sql b/supabase/migrations/20260424091645_enforce_hashed_api_keys_on_rls_identity_path.sql deleted file mode 100644 index d98350e62a..0000000000 --- a/supabase/migrations/20260424091645_enforce_hashed_api_keys_on_rls_identity_path.sql +++ /dev/null @@ -1,219 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") -RETURNS boolean -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - user_has_hashed_key_enforced_org boolean; -BEGIN - IF apikey_row.key IS NULL AND apikey_row.key_hash IS NOT NULL THEN - RETURN true; - END IF; - - -- API keys are user-scoped and can reach org-agnostic RLS helpers such as - -- apikey listing. Once any org for the user enforces hashed keys, reject - -- legacy plain-text keys on the shared lookup path to keep both auth planes aligned. - SELECT EXISTS ( - SELECT 1 - FROM public.orgs AS org - WHERE org.enforce_hashed_api_keys = true - AND org.id IN ( - SELECT org_uuid - FROM ( - SELECT created_org.id AS org_uuid - FROM public.orgs AS created_org - WHERE created_org.created_by = apikey_row.user_id - - UNION - - SELECT org_user.org_id AS org_uuid - FROM public.org_users AS org_user - WHERE org_user.user_id = apikey_row.user_id - AND org_user.user_right::text NOT LIKE 'invite_%' - AND org_user.app_id IS NULL - AND org_user.channel_id IS NULL - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.org_users AS org_user - JOIN public.apps ON apps.app_id = org_user.app_id - WHERE org_user.user_id = apikey_row.user_id - AND org_user.user_right::text NOT LIKE 'invite_%' - AND org_user.app_id IS NOT NULL - - UNION - - SELECT ch.owner_org AS org_uuid - FROM public.org_users AS org_user - JOIN public.channels AS ch ON ch.id = org_user.channel_id - WHERE org_user.user_id = apikey_row.user_id - AND org_user.user_right::text NOT LIKE 'invite_%' - AND org_user.channel_id IS NOT NULL - - UNION - - SELECT rb.org_id AS org_uuid - FROM public.role_bindings AS rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = apikey_row.user_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - AND NOT EXISTS ( - SELECT 1 - FROM public.org_users AS invited_org_user - WHERE invited_org_user.org_id = rb.org_id - AND invited_org_user.user_id = apikey_row.user_id - AND invited_org_user.user_right::text LIKE 'invite_%' - ) - - UNION - - SELECT rb.org_id AS org_uuid - FROM public.role_bindings AS rb - JOIN public.group_members AS gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = apikey_row.user_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT rb.org_id AS org_uuid - FROM public.role_bindings AS rb - WHERE apikey_row.rbac_id IS NOT NULL - AND rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = apikey_row.rbac_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings AS rb - JOIN public.apps ON apps.id = rb.app_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = apikey_row.user_id - AND rb.scope_type = public.rbac_scope_app() - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - AND NOT EXISTS ( - SELECT 1 - FROM public.org_users AS invited_org_user - WHERE invited_org_user.org_id = apps.owner_org - AND invited_org_user.user_id = apikey_row.user_id - AND invited_org_user.user_right::text LIKE 'invite_%' - ) - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings AS rb - JOIN public.apps ON apps.id = rb.app_id - JOIN public.group_members AS gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = apikey_row.user_id - AND rb.scope_type = public.rbac_scope_app() - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings AS rb - JOIN public.apps ON apps.id = rb.app_id - WHERE apikey_row.rbac_id IS NOT NULL - AND rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = apikey_row.rbac_id - AND rb.scope_type = public.rbac_scope_app() - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings AS rb - JOIN public.channels AS ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = apikey_row.user_id - AND rb.scope_type = public.rbac_scope_channel() - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - AND NOT EXISTS ( - SELECT 1 - FROM public.org_users AS invited_org_user - WHERE invited_org_user.org_id = apps.owner_org - AND invited_org_user.user_id = apikey_row.user_id - AND invited_org_user.user_right::text LIKE 'invite_%' - ) - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings AS rb - JOIN public.channels AS ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - JOIN public.group_members AS gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = apikey_row.user_id - AND rb.scope_type = public.rbac_scope_channel() - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings AS rb - JOIN public.channels AS ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - WHERE apikey_row.rbac_id IS NOT NULL - AND rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = apikey_row.rbac_id - AND rb.scope_type = public.rbac_scope_channel() - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ) AS accessible_orgs - ) - ) - INTO user_has_hashed_key_enforced_org; - - IF user_has_hashed_key_enforced_org THEN - PERFORM public.pg_log( - 'deny: ORG_REQUIRES_HASHED_API_KEY', - jsonb_build_object('apikey_id', apikey_row.id, 'user_id', apikey_row.user_id) - ); - RETURN false; - END IF; - - RETURN true; -END; -$$; - -ALTER FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") FROM PUBLIC; - -CREATE OR REPLACE FUNCTION "public"."find_apikey_by_value"("key_value" "text") -RETURNS SETOF "public"."apikeys" -LANGUAGE "sql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ - SELECT apikey_row.* - FROM public.apikeys AS apikey_row - WHERE ( - apikey_row.key = key_value - OR apikey_row.key_hash = encode(extensions.digest(key_value, 'sha256'), 'hex') - ) - AND public.check_apikey_hashed_key_enforcement(apikey_row) - LIMIT 1; -$$; - -ALTER FUNCTION "public"."find_apikey_by_value"("key_value" "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") TO "service_role"; diff --git a/supabase/migrations/20260424094101_enforce_apikey_scope_in_rbac_check.sql b/supabase/migrations/20260424094101_enforce_apikey_scope_in_rbac_check.sql deleted file mode 100644 index 2a9cb74d63..0000000000 --- a/supabase/migrations/20260424094101_enforce_apikey_scope_in_rbac_check.sql +++ /dev/null @@ -1,436 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text" DEFAULT NULL::"text") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_effective_user_id uuid := p_user_id; - v_effective_app_id character varying := p_app_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; - v_override boolean; - v_channel_scope boolean := false; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; - v_api_key public.apikeys%ROWTYPE; - v_channel_org_id uuid; - v_channel_app_id character varying; -BEGIN - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - IF p_channel_id IS NOT NULL AND p_permission_key LIKE 'channel.%' THEN - v_channel_scope := true; - END IF; - - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF p_channel_id IS NOT NULL THEN - SELECT owner_org, app_id - INTO v_channel_org_id, v_channel_app_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_org_id IS NOT NULL THEN - v_effective_org_id := v_channel_org_id; - v_effective_app_id := v_channel_app_id; - END IF; - END IF; - - IF p_apikey IS NOT NULL THEN - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NOT_FOUND', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - IF public.is_apikey_expired(v_api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object( - 'key_id', v_api_key.id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - IF v_effective_user_id IS NULL THEN - v_effective_user_id := v_api_key.user_id; - END IF; - - IF v_effective_org_id IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NO_ORG', jsonb_build_object( - 'permission', p_permission_key, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - - IF COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 - AND NOT (v_effective_org_id = ANY(v_api_key.limited_to_orgs)) - THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_ORG_RESTRICT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - - IF COALESCE(array_length(v_api_key.limited_to_apps, 1), 0) > 0 THEN - IF v_effective_app_id IS NULL OR NOT (v_effective_app_id = ANY(v_api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_APP_RESTRICT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - END IF; - END IF; - - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(v_effective_user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - IF v_effective_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), v_effective_user_id, p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_user() - AND o.principal_id = v_effective_user_id - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - ELSE - IF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = false - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := false; - ELSIF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = true - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := true; - END IF; - END IF; - END IF; - END IF; - - IF NOT v_allowed AND v_api_key.id IS NOT NULL THEN - v_apikey_principal := v_api_key.rbac_id; - - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_apikey() - AND o.principal_id = v_apikey_principal - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - END IF; - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - ELSE - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', p_user_id - )); - RETURN false; - END IF; - - IF p_apikey IS NOT NULL AND v_effective_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(v_effective_app_id, v_legacy_right, v_effective_user_id, p_apikey); - ELSIF v_effective_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(v_effective_app_id, v_legacy_right, v_effective_user_id); - ELSE - RETURN public.check_min_rights_legacy(v_legacy_right, v_effective_user_id, v_effective_org_id, v_effective_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - -ALTER FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") TO "service_role"; - - -CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text" DEFAULT NULL::"text") RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; - v_org_enforcing_2fa boolean; - v_effective_user_id uuid := p_user_id; - v_effective_app_id character varying := p_app_id; - v_api_key public.apikeys%ROWTYPE; - v_channel_org_id uuid; - v_channel_app_id character varying; -BEGIN - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF p_channel_id IS NOT NULL THEN - SELECT owner_org, app_id - INTO v_channel_org_id, v_channel_app_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_org_id IS NOT NULL THEN - v_effective_org_id := v_channel_org_id; - v_effective_app_id := v_channel_app_id; - END IF; - END IF; - - IF p_apikey IS NOT NULL THEN - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NOT_FOUND', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - IF public.is_apikey_expired(v_api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object( - 'key_id', v_api_key.id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - IF v_effective_user_id IS NULL THEN - v_effective_user_id := v_api_key.user_id; - END IF; - - IF v_effective_org_id IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NO_ORG', jsonb_build_object( - 'permission', p_permission_key, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - - IF COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 - AND NOT (v_effective_org_id = ANY(v_api_key.limited_to_orgs)) - THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_ORG_RESTRICT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - - IF COALESCE(array_length(v_api_key.limited_to_apps, 1), 0) > 0 THEN - IF v_effective_app_id IS NULL OR NOT (v_effective_app_id = ANY(v_api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_APP_RESTRICT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - END IF; - END IF; - - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - IF v_effective_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), v_effective_user_id, p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id); - END IF; - - IF NOT v_allowed AND v_api_key.id IS NOT NULL THEN - v_apikey_principal := v_api_key.rbac_id; - - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id); - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - ELSE - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id - )); - RETURN false; - END IF; - - IF p_apikey IS NOT NULL AND v_effective_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(v_effective_app_id, v_legacy_right, v_effective_user_id, p_apikey); - ELSIF v_effective_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(v_effective_app_id, v_legacy_right, v_effective_user_id); - ELSE - RETURN public.check_min_rights_legacy_no_password_policy(v_legacy_right, v_effective_user_id, v_effective_org_id, v_effective_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - -ALTER FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") TO "service_role"; diff --git a/supabase/migrations/20260424094225_harden_role_bindings_cross_org_scope.sql b/supabase/migrations/20260424094225_harden_role_bindings_cross_org_scope.sql deleted file mode 100644 index b54093e485..0000000000 --- a/supabase/migrations/20260424094225_harden_role_bindings_cross_org_scope.sql +++ /dev/null @@ -1,167 +0,0 @@ --- Harden role bindings against cross-org scope forgery. --- Security fix for GHSA-5r52-m8r9-7f8x. - -DELETE FROM public.role_bindings AS rb -WHERE rb.scope_type = public.rbac_scope_app() - AND rb.org_id IS NOT NULL - AND rb.app_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM public.apps AS a - WHERE a.id = rb.app_id - AND a.owner_org = rb.org_id - ); - -DELETE FROM public.role_bindings AS rb -WHERE rb.scope_type = public.rbac_scope_channel() - AND rb.org_id IS NOT NULL - AND rb.app_id IS NOT NULL - AND rb.channel_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM public.channels AS ch - JOIN public.apps AS a - ON a.app_id = ch.app_id - WHERE ch.rbac_id = rb.channel_id - AND a.id = rb.app_id - AND ch.owner_org = rb.org_id - AND a.owner_org = rb.org_id - ); - -CREATE OR REPLACE FUNCTION public.rbac_has_permission( - p_principal_type text, - p_principal_id uuid, - p_permission_key text, - p_org_id uuid, - p_app_id character varying, - p_channel_id bigint -) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_org_id uuid := p_org_id; - v_app_uuid uuid; - v_app_owner_org uuid; - v_channel_uuid uuid; - v_channel_app_id text; - v_channel_org_id uuid; - v_has boolean := false; -BEGIN - IF p_permission_key IS NULL THEN - RETURN false; - END IF; - - -- Resolve scope identifiers to UUIDs. Preserve the caller org when the app does not exist yet. - IF p_app_id IS NOT NULL THEN - SELECT id, owner_org INTO v_app_uuid, v_app_owner_org - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - - IF v_app_owner_org IS NOT NULL THEN - v_org_id := v_app_owner_org; - END IF; - END IF; - - IF p_channel_id IS NOT NULL THEN - SELECT rbac_id, app_id, owner_org INTO v_channel_uuid, v_channel_app_id, v_channel_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_uuid IS NOT NULL THEN - IF p_app_id IS NOT NULL AND p_app_id IS DISTINCT FROM v_channel_app_id THEN - RETURN false; - END IF; - - IF p_org_id IS NOT NULL AND p_org_id IS DISTINCT FROM v_channel_org_id THEN - RETURN false; - END IF; - - SELECT id INTO v_app_uuid - FROM public.apps - WHERE app_id = v_channel_app_id - LIMIT 1; - - v_org_id := v_channel_org_id; - END IF; - END IF; - - WITH RECURSIVE scope_catalog AS ( - SELECT public.rbac_scope_org()::text AS scope_type, v_org_id AS org_id, NULL::uuid AS app_id, NULL::uuid AS channel_id WHERE v_org_id IS NOT NULL - UNION ALL - SELECT public.rbac_scope_app(), v_org_id, v_app_uuid, NULL::uuid WHERE v_app_uuid IS NOT NULL - UNION ALL - SELECT public.rbac_scope_channel(), v_org_id, v_app_uuid, v_channel_uuid WHERE v_channel_uuid IS NOT NULL - ), - direct_roles AS ( - SELECT rb.role_id, rb.scope_type - FROM scope_catalog s - JOIN public.role_bindings rb ON rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.org_id = s.org_id AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.org_id = s.org_id AND rb.app_id = s.app_id AND rb.channel_id = s.channel_id) - ) - JOIN public.roles r ON r.id = rb.role_id - AND r.scope_type = rb.scope_type - WHERE rb.principal_type = p_principal_type - AND rb.principal_id = p_principal_id - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - group_roles AS ( - SELECT rb.role_id, rb.scope_type - FROM scope_catalog s - JOIN public.group_members gm ON gm.user_id = p_principal_id - JOIN public.groups g ON g.id = gm.group_id - JOIN public.role_bindings rb ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = gm.group_id - JOIN public.roles r ON r.id = rb.role_id - AND r.scope_type = rb.scope_type - WHERE p_principal_type = public.rbac_principal_user() - AND rb.scope_type = s.scope_type - AND ( - (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR - (rb.scope_type = public.rbac_scope_app() AND rb.org_id = s.org_id AND rb.app_id = s.app_id) OR - (rb.scope_type = public.rbac_scope_channel() AND rb.org_id = s.org_id AND rb.app_id = s.app_id AND rb.channel_id = s.channel_id) - ) - AND (v_org_id IS NULL OR g.org_id = v_org_id) - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - combined_roles AS ( - SELECT role_id, scope_type FROM direct_roles - UNION - SELECT role_id, scope_type FROM group_roles - ), - role_closure AS ( - SELECT role_id, scope_type FROM combined_roles - UNION - SELECT rh.child_role_id, rc.scope_type - FROM public.role_hierarchy rh - JOIN role_closure rc ON rc.role_id = rh.parent_role_id - JOIN public.roles child_role ON child_role.id = rh.child_role_id - AND child_role.scope_type = rc.scope_type - ), - perm_set AS ( - SELECT DISTINCT p.key - FROM role_closure rc - JOIN public.role_permissions rp ON rp.role_id = rc.role_id - JOIN public.permissions p ON p.id = rp.permission_id - ) - SELECT EXISTS (SELECT 1 FROM perm_set WHERE key = p_permission_key) INTO v_has; - - RETURN v_has; -END; -$$; - -ALTER FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) FROM anon; -REVOKE ALL ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) FROM authenticated; -REVOKE ALL ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) FROM service_role; -GRANT EXECUTE ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) TO service_role; - -COMMENT ON FUNCTION public.rbac_has_permission(text, uuid, text, uuid, character varying, bigint) IS - 'Checks whether a principal has a permission at org/app/channel scope. App and channel bindings must match the resolved owning org so forged cross-org scope rows are ignored.'; diff --git a/supabase/migrations/20260427092702_fix_transfer_app_guard_allowlist.sql b/supabase/migrations/20260427092702_fix_transfer_app_guard_allowlist.sql deleted file mode 100644 index 8836cecc03..0000000000 --- a/supabase/migrations/20260427092702_fix_transfer_app_guard_allowlist.sql +++ /dev/null @@ -1,180 +0,0 @@ -CREATE OR REPLACE FUNCTION public.guard_owner_org_reassignment() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = '' -AS $$ -BEGIN - IF NEW.owner_org IS DISTINCT FROM OLD.owner_org - AND current_setting('capgo.allow_owner_org_transfer', true) IS DISTINCT FROM 'true' THEN - RAISE EXCEPTION 'owner_org must be changed through public.transfer_app()'; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.guard_owner_org_reassignment() OWNER TO "postgres"; - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_apps ON public.apps; -CREATE TRIGGER guard_owner_org_reassignment_apps -BEFORE UPDATE OF owner_org ON public.apps -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_app_versions ON public.app_versions; -CREATE TRIGGER guard_owner_org_reassignment_app_versions -BEFORE UPDATE OF owner_org ON public.app_versions -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_app_versions_meta ON public.app_versions_meta; -CREATE TRIGGER guard_owner_org_reassignment_app_versions_meta -BEFORE UPDATE OF owner_org ON public.app_versions_meta -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_channel_devices ON public.channel_devices; -CREATE TRIGGER guard_owner_org_reassignment_channel_devices -BEFORE UPDATE OF owner_org ON public.channel_devices -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -DROP TRIGGER IF EXISTS guard_owner_org_reassignment_channels ON public.channels; -CREATE TRIGGER guard_owner_org_reassignment_channels -BEFORE UPDATE OF owner_org ON public.channels -FOR EACH ROW -EXECUTE FUNCTION public.guard_owner_org_reassignment(); - -CREATE OR REPLACE FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) RETURNS void -LANGUAGE plpgsql SECURITY DEFINER -SET search_path TO '' -AS $$ -DECLARE - v_old_org_id uuid; - v_user_id uuid; - v_last_transfer jsonb; - v_last_transfer_date timestamp; - v_transfer_error constant text := 'Unable to process transfer request.'; - v_app_id_key constant text := 'app_id'; - v_old_org_id_key constant text := 'old_org_id'; - v_new_org_id_key constant text := 'new_org_id'; - v_uid_key constant text := 'uid'; -BEGIN - SELECT owner_org, transfer_history[array_length(transfer_history, 1)] - INTO v_old_org_id, v_last_transfer - FROM public.apps - WHERE app_id = p_app_id; - - IF v_old_org_id IS NULL THEN - RAISE EXCEPTION '%', v_transfer_error; - END IF; - - v_user_id := (SELECT auth.uid()); - - IF v_user_id IS NULL THEN - PERFORM public.pg_log( - 'deny: TRANSFER_NO_AUTH', - jsonb_build_object(v_app_id_key, p_app_id, v_new_org_id_key, p_new_org_id) - ); - RAISE EXCEPTION '%', v_transfer_error; - END IF; - - IF NOT public.rbac_check_permission( - public.rbac_perm_app_transfer(), - v_old_org_id, - p_app_id, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: TRANSFER_OLD_ORG_RIGHTS', - jsonb_build_object( - v_app_id_key, p_app_id, - v_old_org_id_key, v_old_org_id, - v_new_org_id_key, p_new_org_id, - v_uid_key, v_user_id - ) - ); - RAISE EXCEPTION '%', v_transfer_error; - END IF; - - IF NOT public.rbac_check_permission( - public.rbac_perm_app_transfer(), - p_new_org_id, - NULL::character varying, - NULL::bigint - ) THEN - PERFORM public.pg_log( - 'deny: TRANSFER_NEW_ORG_RIGHTS', - jsonb_build_object( - v_app_id_key, p_app_id, - v_old_org_id_key, v_old_org_id, - v_new_org_id_key, p_new_org_id, - v_uid_key, v_user_id - ) - ); - RAISE EXCEPTION '%', v_transfer_error; - END IF; - - IF v_last_transfer IS NOT NULL THEN - v_last_transfer_date := (v_last_transfer->>'transferred_at')::timestamp; - IF v_last_transfer_date + interval '32 days' > now() THEN - RAISE EXCEPTION - 'Cannot transfer app. Must wait at least 32 days ' - 'between transfers. Last transfer was on %', - v_last_transfer_date; - END IF; - END IF; - - BEGIN - -- Allow the guarded owner_org cascade only inside the approved transfer path. - PERFORM set_config('capgo.allow_owner_org_transfer', 'true', true); - - UPDATE public.apps - SET - owner_org = p_new_org_id, - updated_at = now(), - transfer_history = COALESCE(transfer_history, '{}') || jsonb_build_object( - 'transferred_at', now(), - 'transferred_from', v_old_org_id, - 'transferred_to', p_new_org_id, - 'initiated_by', v_user_id - )::jsonb - WHERE app_id = p_app_id; - - UPDATE public.app_versions - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.app_versions_meta - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channel_devices - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.channels - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - UPDATE public.deploy_history - SET owner_org = p_new_org_id - WHERE app_id = p_app_id; - - PERFORM set_config('capgo.allow_owner_org_transfer', 'false', true); - EXCEPTION - WHEN OTHERS THEN - PERFORM set_config('capgo.allow_owner_org_transfer', 'false', true); - RAISE; - END; - -END; -$$; - -ALTER FUNCTION public.transfer_app( - p_app_id character varying, - p_new_org_id uuid -) OWNER TO "postgres"; diff --git a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql deleted file mode 100644 index 6ae4a98e9a..0000000000 --- a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql +++ /dev/null @@ -1,569 +0,0 @@ --- Pure helpers do not need elevated privileges. -ALTER FUNCTION public.get_apikey_header() SECURITY INVOKER; -ALTER FUNCTION public.is_apikey_expired( - timestamp with time zone -) SECURITY INVOKER; -ALTER FUNCTION public.strip_html(text) SECURITY INVOKER; -ALTER FUNCTION public.transform_role_to_invite( - public.user_min_right -) SECURITY INVOKER; -ALTER FUNCTION public.transform_role_to_non_invite( - public.user_min_right -) SECURITY INVOKER; -ALTER FUNCTION public.verify_api_key_hash(text, text) SECURITY INVOKER; - --- Trigger-only internals should never be exposed as RPC entrypoints. -REVOKE ALL ON FUNCTION public.apikeys_force_server_key() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.apikeys_strip_plain_key_for_hashed() FROM PUBLIC; - -REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() FROM ANON; -REVOKE ALL -ON FUNCTION public.check_encrypted_bundle_on_insert() -FROM AUTHENTICATED; - -REVOKE ALL -ON FUNCTION public.cleanup_onboarding_app_data_on_complete() -FROM PUBLIC; - -DO $$ -BEGIN - IF to_regprocedure('public.generate_org_user_on_org_create()') IS NOT NULL THEN - EXECUTE 'REVOKE ALL ON FUNCTION public.generate_org_user_on_org_create() FROM PUBLIC'; - EXECUTE 'REVOKE ALL ON FUNCTION public.generate_org_user_on_org_create() FROM ANON'; - EXECUTE 'REVOKE ALL ON FUNCTION public.generate_org_user_on_org_create() FROM AUTHENTICATED'; - END IF; -END; -$$; - -REVOKE ALL -ON FUNCTION public.generate_org_user_stripe_info_on_org_create() -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.generate_org_user_stripe_info_on_org_create() -FROM ANON; -REVOKE ALL -ON FUNCTION public.generate_org_user_stripe_info_on_org_create() -FROM AUTHENTICATED; - -REVOKE ALL ON FUNCTION public.noupdate() FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.prevent_last_super_admin_binding_delete() -FROM PUBLIC; - -REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM ANON; -REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM AUTHENTICATED; - -REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM ANON; -REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM AUTHENTICATED; - -REVOKE ALL ON FUNCTION public.sanitize_tmp_users_text_fields() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.sanitize_tmp_users_text_fields() FROM ANON; -REVOKE ALL -ON FUNCTION public.sanitize_tmp_users_text_fields() -FROM AUTHENTICATED; - -REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM ANON; -REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM AUTHENTICATED; - -REVOKE ALL -ON FUNCTION public.sync_org_has_usage_credits_from_grants() -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.sync_org_user_role_binding_on_delete() -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.sync_org_user_role_binding_on_delete() -FROM ANON; -REVOKE ALL -ON FUNCTION public.sync_org_user_role_binding_on_delete() -FROM AUTHENTICATED; - -REVOKE ALL -ON FUNCTION public.sync_org_user_role_binding_on_update() -FROM PUBLIC; -REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_update() FROM ANON; -REVOKE ALL -ON FUNCTION public.sync_org_user_role_binding_on_update() -FROM AUTHENTICATED; - -REVOKE ALL ON FUNCTION public.sync_org_user_to_role_binding() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.sync_org_user_to_role_binding() FROM ANON; -REVOKE ALL -ON FUNCTION public.sync_org_user_to_role_binding() -FROM AUTHENTICATED; - --- Internal helpers and maintenance functions should stay service-role only. -REVOKE ALL -ON FUNCTION public.check_org_hashed_key_enforcement(uuid, public.apikeys) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.check_org_hashed_key_enforcement(uuid, public.apikeys) -FROM ANON; -REVOKE ALL -ON FUNCTION public.check_org_hashed_key_enforcement(uuid, public.apikeys) -FROM AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.check_org_hashed_key_enforcement(uuid, public.apikeys) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.delete_old_deleted_versions() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.delete_old_deleted_versions() FROM ANON; -REVOKE ALL -ON FUNCTION public.delete_old_deleted_versions() -FROM AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.delete_old_deleted_versions() TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_apikey() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_apikey() FROM ANON; -REVOKE ALL ON FUNCTION public.get_apikey() FROM AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_apikey() TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.get_user_main_org_id_by_app_id(text) -FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.get_user_main_org_id_by_app_id(text) TO ANON; -GRANT EXECUTE -ON FUNCTION public.get_user_main_org_id_by_app_id(text) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.get_user_main_org_id_by_app_id(text) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) -FROM PUBLIC; -GRANT EXECUTE -ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) -TO ANON; -GRANT EXECUTE -ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa_for_org( - uuid -) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) TO ANON; -GRANT EXECUTE -ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.resync_org_user_role_bindings(uuid, uuid) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.resync_org_user_role_bindings(uuid, uuid) -FROM ANON; -REVOKE ALL -ON FUNCTION public.resync_org_user_role_bindings(uuid, uuid) -FROM AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.resync_org_user_role_bindings(uuid, uuid) -TO SERVICE_ROLE; - --- These RPCs are intended for signed-in users only. -REVOKE ALL ON FUNCTION public.accept_invitation_to_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.accept_invitation_to_org(uuid) FROM ANON; -GRANT EXECUTE ON FUNCTION public.accept_invitation_to_org( - uuid -) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.accept_invitation_to_org(uuid) TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.check_org_members_2fa_enabled(uuid) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.check_org_members_2fa_enabled(uuid) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.check_org_members_2fa_enabled(uuid) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.check_org_members_2fa_enabled(uuid) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.check_org_members_password_policy(uuid) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.check_org_members_password_policy(uuid) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.check_org_members_password_policy(uuid) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.check_org_members_password_policy(uuid) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.count_non_compliant_bundles(uuid, text) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.count_non_compliant_bundles(uuid, text) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.count_non_compliant_bundles(uuid, text) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.count_non_compliant_bundles(uuid, text) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.delete_group_with_bindings(uuid) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.delete_group_with_bindings(uuid) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.delete_group_with_bindings(uuid) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.delete_group_with_bindings(uuid) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.delete_non_compliant_bundles(uuid, text) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.delete_non_compliant_bundles(uuid, text) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.delete_non_compliant_bundles(uuid, text) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.delete_non_compliant_bundles(uuid, text) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.delete_org_member_role(uuid, uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.delete_org_member_role(uuid, uuid) FROM ANON; -GRANT EXECUTE -ON FUNCTION public.delete_org_member_role(uuid, uuid) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.delete_org_member_role(uuid, uuid) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.delete_user() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.delete_user() FROM ANON; -GRANT EXECUTE ON FUNCTION public.delete_user() TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.delete_user() TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_account_removal_date() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_account_removal_date() FROM ANON; -GRANT EXECUTE -ON FUNCTION public.get_account_removal_date() -TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_account_removal_date() TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_app_access_rbac(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_app_access_rbac(uuid) FROM ANON; -GRANT EXECUTE ON FUNCTION public.get_app_access_rbac(uuid) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_app_access_rbac(uuid) TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) -FROM PUBLIC; -GRANT EXECUTE -ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) -TO ANON; -GRANT EXECUTE -ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, date, date) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid, date, date) TO ANON; -GRANT EXECUTE -ON FUNCTION public.get_app_metrics(uuid, date, date) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.get_app_metrics(uuid, date, date) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid) FROM ANON; -GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid) TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_org_members(uuid, uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.get_org_members(uuid, uuid) TO ANON; -GRANT EXECUTE -ON FUNCTION public.get_org_members(uuid, uuid) -TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_org_members(uuid, uuid) TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_org_members(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_org_members(uuid) FROM ANON; -GRANT EXECUTE ON FUNCTION public.get_org_members(uuid) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_org_members(uuid) TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_org_members_rbac(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_org_members_rbac(uuid) FROM ANON; -GRANT EXECUTE -ON FUNCTION public.get_org_members_rbac(uuid) -TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_org_members_rbac(uuid) TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.get_org_user_access_rbac(uuid, uuid) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.get_org_user_access_rbac(uuid, uuid) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.get_org_user_access_rbac(uuid, uuid) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.get_org_user_access_rbac(uuid, uuid) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) -FROM PUBLIC; -GRANT EXECUTE -ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) -TO ANON; -GRANT EXECUTE -ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_total_storage_size_org(uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.get_total_storage_size_org(uuid) TO ANON; -GRANT EXECUTE -ON FUNCTION public.get_total_storage_size_org(uuid) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.get_total_storage_size_org(uuid) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_user_org_ids() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO ANON; -GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.has_2fa_enabled() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.has_2fa_enabled() TO ANON; -GRANT EXECUTE ON FUNCTION public.has_2fa_enabled() TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.has_2fa_enabled() TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.invite_user_to_org( - character varying, uuid, public.user_min_right -) -FROM PUBLIC; -GRANT EXECUTE -ON FUNCTION public.invite_user_to_org( - character varying, uuid, public.user_min_right -) -TO ANON; -GRANT EXECUTE -ON FUNCTION public.invite_user_to_org( - character varying, uuid, public.user_min_right -) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.invite_user_to_org( - character varying, uuid, public.user_min_right -) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) -FROM PUBLIC; -GRANT EXECUTE -ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) -TO ANON; -GRANT EXECUTE -ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.is_allowed_action_org(uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.is_allowed_action_org(uuid) TO ANON; -GRANT EXECUTE -ON FUNCTION public.is_allowed_action_org(uuid) -TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.is_allowed_action_org(uuid) TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) -FROM PUBLIC; -GRANT EXECUTE -ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) -TO ANON; -GRANT EXECUTE -ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.is_canceled_org(uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.is_canceled_org(uuid) TO ANON; -GRANT EXECUTE ON FUNCTION public.is_canceled_org(uuid) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.is_canceled_org(uuid) TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.is_good_plan_v5_org(uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.is_good_plan_v5_org(uuid) TO ANON; -GRANT EXECUTE -ON FUNCTION public.is_good_plan_v5_org(uuid) -TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.is_good_plan_v5_org(uuid) TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.is_onboarded_org(uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.is_onboarded_org(uuid) TO ANON; -GRANT EXECUTE ON FUNCTION public.is_onboarded_org(uuid) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.is_onboarded_org(uuid) TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.is_onboarding_needed_org(uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.is_onboarding_needed_org(uuid) TO ANON; -GRANT EXECUTE -ON FUNCTION public.is_onboarding_needed_org(uuid) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.is_onboarding_needed_org(uuid) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.is_org_yearly(uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.is_org_yearly(uuid) TO ANON; -GRANT EXECUTE ON FUNCTION public.is_org_yearly(uuid) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.is_org_yearly(uuid) TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.is_paying_and_good_plan_org(uuid) -FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org(uuid) TO ANON; -GRANT EXECUTE -ON FUNCTION public.is_paying_and_good_plan_org(uuid) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.is_paying_and_good_plan_org(uuid) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.is_paying_and_good_plan_org_action( - uuid, public.action_type [] -) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.is_paying_and_good_plan_org_action( - uuid, public.action_type [] -) -FROM ANON; -REVOKE ALL -ON FUNCTION public.is_paying_and_good_plan_org_action( - uuid, public.action_type [] -) -FROM AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.is_paying_and_good_plan_org_action( - uuid, public.action_type [] -) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.modify_permissions_tmp(text, uuid, public.user_min_right) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.modify_permissions_tmp(text, uuid, public.user_min_right) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.modify_permissions_tmp(text, uuid, public.user_min_right) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.modify_permissions_tmp(text, uuid, public.user_min_right) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.rbac_check_permission_no_password_policy( - text, uuid, character varying, bigint -) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.rbac_check_permission_no_password_policy( - text, uuid, character varying, bigint -) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.rbac_check_permission_no_password_policy( - text, uuid, character varying, bigint -) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.rbac_check_permission_no_password_policy( - text, uuid, character varying, bigint -) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.update_org_invite_role_rbac(uuid, uuid, text) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.update_org_invite_role_rbac(uuid, uuid, text) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.update_org_invite_role_rbac(uuid, uuid, text) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.update_org_invite_role_rbac(uuid, uuid, text) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.update_org_member_role(uuid, uuid, text) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.update_org_member_role(uuid, uuid, text) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.update_org_member_role(uuid, uuid, text) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.update_org_member_role(uuid, uuid, text) -TO SERVICE_ROLE; - -REVOKE ALL -ON FUNCTION public.update_tmp_invite_role_rbac(uuid, text, text) -FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.update_tmp_invite_role_rbac(uuid, text, text) -FROM ANON; -GRANT EXECUTE -ON FUNCTION public.update_tmp_invite_role_rbac(uuid, text, text) -TO AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.update_tmp_invite_role_rbac(uuid, text, text) -TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.verify_mfa() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.verify_mfa() TO ANON; -GRANT EXECUTE ON FUNCTION public.verify_mfa() TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.verify_mfa() TO SERVICE_ROLE; diff --git a/supabase/migrations/20260427105817_restrict_is_paying_and_good_plan_org_action_access.sql b/supabase/migrations/20260427105817_restrict_is_paying_and_good_plan_org_action_access.sql deleted file mode 100644 index ec25fe10d4..0000000000 --- a/supabase/migrations/20260427105817_restrict_is_paying_and_good_plan_org_action_access.sql +++ /dev/null @@ -1,84 +0,0 @@ --- Restrict org billing/usage status RPCs --- so anonymous callers cannot infer org plan state. -CREATE OR REPLACE FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [] -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - caller_role text; - org_customer_id text; - result boolean; - has_credits boolean; -BEGIN - SELECT current_setting('role', true) INTO caller_role; - - IF COALESCE(caller_role, '') NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - IF NOT (public.check_min_rights( - 'read'::public.user_min_right, - (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_paying_and_good_plan_org_action.orgid)), - is_paying_and_good_plan_org_action.orgid, - NULL::character varying, - NULL::bigint - )) THEN - RETURN false; - END IF; - END IF; - - SELECT EXISTS ( - SELECT 1 - FROM public.usage_credit_balances ucb - WHERE ucb.org_id = orgid - AND COALESCE(ucb.available_credits, 0) > 0 - ) INTO has_credits; - - IF has_credits THEN - RETURN true; - END IF; - - SELECT o.customer_id INTO org_customer_id - FROM public.orgs o - WHERE o.id = orgid; - - SELECT (si.trial_at > now()) OR (si.status = 'succeeded' AND NOT ( - (si.mau_exceeded AND 'mau' = ANY(actions)) - OR (si.storage_exceeded AND 'storage' = ANY(actions)) - OR (si.bandwidth_exceeded AND 'bandwidth' = ANY(actions)) - OR (si.build_time_exceeded AND 'build_time' = ANY(actions)) - )) - INTO result - FROM public.stripe_info si - WHERE si.customer_id = org_customer_id - LIMIT 1; - - RETURN COALESCE(result, false); -END; -$$; - -ALTER FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [] -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [] -) FROM public; -REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [] -) FROM anon; -REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [] -) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [] -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [] -) TO service_role; diff --git a/supabase/migrations/20260427105834_restrict_manifest_mutation_access.sql b/supabase/migrations/20260427105834_restrict_manifest_mutation_access.sql deleted file mode 100644 index b930423416..0000000000 --- a/supabase/migrations/20260427105834_restrict_manifest_mutation_access.sql +++ /dev/null @@ -1,14 +0,0 @@ -DROP POLICY IF EXISTS "Allow users to delete manifest entries" ON "public"."manifest"; -DROP POLICY IF EXISTS "Allow users to insert manifest entries" ON "public"."manifest"; - -CREATE POLICY "Prevent users from inserting manifest entries" ON "public"."manifest" -AS RESTRICTIVE -FOR INSERT -TO "authenticated", "anon" -WITH CHECK (false); - -CREATE POLICY "Prevent users from deleting manifest entries" ON "public"."manifest" -AS RESTRICTIVE -FOR DELETE -TO "authenticated", "anon" -USING (false); diff --git a/supabase/migrations/20260427105838_enforce_apikey_expiration_policy.sql b/supabase/migrations/20260427105838_enforce_apikey_expiration_policy.sql deleted file mode 100644 index 6bdb56397a..0000000000 --- a/supabase/migrations/20260427105838_enforce_apikey_expiration_policy.sql +++ /dev/null @@ -1,66 +0,0 @@ -CREATE OR REPLACE FUNCTION public.enforce_apikey_expiration_policy() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - scoped_org RECORD; -BEGIN - IF TG_OP = 'UPDATE' - AND NEW.expires_at IS NOT DISTINCT FROM OLD.expires_at - AND NEW.limited_to_orgs IS NOT DISTINCT FROM OLD.limited_to_orgs - AND NEW.limited_to_apps IS NOT DISTINCT FROM OLD.limited_to_apps THEN - RETURN NEW; - END IF; - - FOR scoped_org IN - WITH scope_orgs AS ( - SELECT unnest(COALESCE(NEW.limited_to_orgs, '{}'::uuid[])) AS org_id - UNION - SELECT public.apps.owner_org - FROM public.apps - WHERE public.apps.app_id = ANY(COALESCE(NEW.limited_to_apps, '{}'::text[])) - ) - SELECT - public.orgs.id, - public.orgs.require_apikey_expiration, - public.orgs.max_apikey_expiration_days - FROM public.orgs - JOIN scope_orgs ON scope_orgs.org_id = public.orgs.id - LOOP - IF scoped_org.require_apikey_expiration AND NEW.expires_at IS NULL THEN - RAISE EXCEPTION USING - ERRCODE = 'P0001', - MESSAGE = 'expiration_required', - DETAIL = 'This organization requires API keys to have an expiration date'; - END IF; - - IF scoped_org.max_apikey_expiration_days IS NOT NULL - AND NEW.expires_at IS NOT NULL - AND NEW.expires_at > clock_timestamp() - + make_interval(days => scoped_org.max_apikey_expiration_days) THEN - RAISE EXCEPTION USING - ERRCODE = 'P0001', - MESSAGE = 'expiration_exceeds_max', - DETAIL = format( - 'API key expiration cannot exceed %s days for this organization', - scoped_org.max_apikey_expiration_days - ); - END IF; - END LOOP; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.enforce_apikey_expiration_policy() OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.enforce_apikey_expiration_policy() FROM public; - -DROP TRIGGER IF EXISTS apikeys_enforce_expiration_policy ON public.apikeys; - -CREATE TRIGGER apikeys_enforce_expiration_policy -BEFORE INSERT OR UPDATE ON public.apikeys -FOR EACH ROW -EXECUTE FUNCTION public.enforce_apikey_expiration_policy(); diff --git a/supabase/migrations/20260427105909_fix_apikey_helper_rpc_public_execute.sql b/supabase/migrations/20260427105909_fix_apikey_helper_rpc_public_execute.sql deleted file mode 100644 index 2a56f60d1a..0000000000 --- a/supabase/migrations/20260427105909_fix_apikey_helper_rpc_public_execute.sql +++ /dev/null @@ -1,251 +0,0 @@ --- Fix GHSA-7r6g-whg3-5mm4 by revoking helper RPC execution from PUBLIC. --- --- Previous migrations only revoked these SECURITY DEFINER functions from the --- anon role directly. PostgreSQL grants EXECUTE on new functions to PUBLIC by --- default, and anon/authenticated both inherit PUBLIC, so the direct anon --- revokes did not actually remove access. --- --- Storage RLS still needs API-key identity resolution for anon requests, so we --- add a non-exposed helper in a private schema for app-bucket checks instead --- of keeping the parameterized get_user_id(text) RPC callable by anon. - -REVOKE ALL ON FUNCTION public.get_user_id("apikey" text) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_user_id("apikey" text) FROM ANON; -REVOKE ALL ON FUNCTION public.get_user_id( - "apikey" text -) FROM AUTHENTICATED; -REVOKE ALL ON FUNCTION public.get_user_id( - "apikey" text -) FROM SERVICE_ROLE; -GRANT EXECUTE ON FUNCTION public.get_user_id( - "apikey" text -) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_user_id( - "apikey" text -) TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_user_id( - "apikey" text, "app_id" text -) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_user_id( - "apikey" text, "app_id" text -) FROM ANON; -REVOKE ALL ON FUNCTION public.get_user_id( - "apikey" text, "app_id" text -) FROM AUTHENTICATED; -REVOKE ALL ON FUNCTION public.get_user_id( - "apikey" text, "app_id" text -) FROM SERVICE_ROLE; -GRANT EXECUTE ON FUNCTION public.get_user_id( - "apikey" text, "app_id" text -) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_user_id( - "apikey" text, "app_id" text -) TO SERVICE_ROLE; - -REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey( - "apikey" text, "app_id" text -) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey( - "apikey" text, "app_id" text -) FROM ANON; -REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey( - "apikey" text, "app_id" text -) FROM AUTHENTICATED; -REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey( - "apikey" text, "app_id" text -) FROM SERVICE_ROLE; -GRANT EXECUTE ON FUNCTION public.get_org_perm_for_apikey( - "apikey" text, "app_id" text -) TO AUTHENTICATED; -GRANT EXECUTE ON FUNCTION public.get_org_perm_for_apikey( - "apikey" text, "app_id" text -) TO SERVICE_ROLE; - -CREATE SCHEMA IF NOT EXISTS capgo_private; -- noqa: CP02 -REVOKE ALL ON SCHEMA capgo_private FROM PUBLIC; -- noqa: CP02 -GRANT USAGE ON SCHEMA capgo_private TO ANON; -- noqa: CP02 -GRANT USAGE ON SCHEMA capgo_private TO AUTHENTICATED; -- noqa: CP02 -GRANT USAGE ON SCHEMA capgo_private TO SERVICE_ROLE; -- noqa: CP02 - -CREATE OR REPLACE FUNCTION capgo_private.matches_app_storage_apikey_owner( - folder_user_id text, - target_app_id character varying, - keymode public.key_mode [] -) RETURNS boolean -LANGUAGE PLPGSQL -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key record; - target_app record; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - - IF api_key_text IS NULL THEN - RETURN false; - END IF; - - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key.id IS NULL OR NOT (api_key.mode = ANY(keymode)) THEN - RETURN false; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - RETURN false; - END IF; - - SELECT user_id, owner_org - INTO target_app - FROM public.apps - WHERE app_id = target_app_id - LIMIT 1; - - IF target_app.user_id IS NULL THEN - RETURN false; - END IF; - - IF api_key.user_id::text <> folder_user_id THEN - RETURN false; - END IF; - - IF target_app.user_id <> api_key.user_id THEN - RETURN false; - END IF; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 - AND NOT (target_app.owner_org = ANY(api_key.limited_to_orgs)) THEN - RETURN false; - END IF; - - IF api_key.limited_to_apps IS DISTINCT FROM '{}' - AND NOT (target_app_id = ANY(api_key.limited_to_apps)) THEN - RETURN false; - END IF; - - RETURN true; -END; -$$; - -ALTER FUNCTION capgo_private.matches_app_storage_apikey_owner( - text, - character varying, - public.key_mode [] -) OWNER TO postgres; - -COMMENT ON FUNCTION capgo_private.matches_app_storage_apikey_owner( - text, - character varying, - public.key_mode [] -) IS -'Internal non-RPC helper for storage app-bucket API-key auth.'; - -REVOKE ALL ON FUNCTION capgo_private.matches_app_storage_apikey_owner( - text, - character varying, - public.key_mode [] -) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION capgo_private.matches_app_storage_apikey_owner( - text, - character varying, - public.key_mode [] -) TO ANON, AUTHENTICATED, SERVICE_ROLE; - -DROP POLICY IF EXISTS -"Allow user or apikey to delete they own folder in apps" -- noqa: RF05 -ON STORAGE.OBJECTS; -CREATE POLICY -"Allow user or apikey to delete they own folder in apps" -- noqa: RF05 -ON STORAGE.OBJECTS -FOR DELETE -USING ( - ( - (BUCKET_ID = 'apps'::text) - AND ( - ( - ((SELECT auth.uid() AS AUTH_USER_ID))::text - = (storage.foldername(NAME))[1] - ) - OR capgo_private.matches_app_storage_apikey_owner( - (storage.foldername(NAME))[1], - (storage.foldername(NAME))[2]::character varying, - '{all}'::public.key_mode [] - ) - ) - ) -); - -DROP POLICY IF EXISTS -"Allow user or apikey to update they own folder in apps" -- noqa: RF05 -ON STORAGE.OBJECTS; -CREATE POLICY -"Allow user or apikey to update they own folder in apps" -- noqa: RF05 -ON STORAGE.OBJECTS -FOR UPDATE -USING ( - ( - (BUCKET_ID = 'apps'::text) - AND ( - ( - ((SELECT auth.uid() AS AUTH_USER_ID))::text - = (storage.foldername(NAME))[1] - ) - OR capgo_private.matches_app_storage_apikey_owner( - (storage.foldername(NAME))[1], - (storage.foldername(NAME))[2]::character varying, - '{write,all}'::public.key_mode [] - ) - ) - ) -); - -DROP POLICY IF EXISTS -"Allow user or apikey to insert they own folder in apps" -- noqa: RF05 -ON STORAGE.OBJECTS; -CREATE POLICY -"Allow user or apikey to insert they own folder in apps" -- noqa: RF05 -ON STORAGE.OBJECTS -FOR INSERT -WITH CHECK ( - ( - (BUCKET_ID = 'apps'::text) - AND ( - ( - ((SELECT auth.uid() AS AUTH_USER_ID))::text - = (storage.foldername(NAME))[1] - ) - OR capgo_private.matches_app_storage_apikey_owner( - (storage.foldername(NAME))[1], - (storage.foldername(NAME))[2]::character varying, - '{write,all}'::public.key_mode [] - ) - ) - ) -); - -DROP POLICY IF EXISTS -"Allow user or apikey to read they own folder in apps" -- noqa: RF05 -ON STORAGE.OBJECTS; -CREATE POLICY -"Allow user or apikey to read they own folder in apps" -- noqa: RF05 -ON STORAGE.OBJECTS -FOR SELECT -USING ( - ( - (BUCKET_ID = 'apps'::text) - AND ( - ( - ((SELECT auth.uid() AS AUTH_USER_ID))::text - = (storage.foldername(NAME))[1] - ) - OR capgo_private.matches_app_storage_apikey_owner( - (storage.foldername(NAME))[1], - (storage.foldername(NAME))[2]::character varying, - '{read,all}'::public.key_mode [] - ) - ) - ) -); diff --git a/supabase/migrations/20260427110612_retention_metrics_service_role_rls.sql b/supabase/migrations/20260427110612_retention_metrics_service_role_rls.sql deleted file mode 100644 index 6b75cdf201..0000000000 --- a/supabase/migrations/20260427110612_retention_metrics_service_role_rls.sql +++ /dev/null @@ -1,24 +0,0 @@ --- Keep retention metrics internal to backend workers while satisfying the --- project-wide RLS convention for public tables. service_role bypasses RLS, so --- backend-only tables use deny-all policies instead of service_role policies. -ALTER TABLE public.daily_revenue_metrics ENABLE ROW LEVEL SECURITY; - -ALTER TABLE public.processed_stripe_events ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Allow service_role full access" ON public.daily_revenue_metrics; -DROP POLICY IF EXISTS "Deny all access" ON public.daily_revenue_metrics; - -CREATE POLICY "Deny all access" ON public.daily_revenue_metrics FOR ALL USING ( - false -) -WITH -CHECK (false); - -DROP POLICY IF EXISTS "Allow service_role full access" ON public.processed_stripe_events; -DROP POLICY IF EXISTS "Deny all access" ON public.processed_stripe_events; - -CREATE POLICY "Deny all access" ON public.processed_stripe_events FOR ALL USING ( - false -) -WITH -CHECK (false); diff --git a/supabase/migrations/20260427142358_require_recent_email_otp_for_delete_user.sql b/supabase/migrations/20260427142358_require_recent_email_otp_for_delete_user.sql deleted file mode 100644 index 2d58d5005a..0000000000 --- a/supabase/migrations/20260427142358_require_recent_email_otp_for_delete_user.sql +++ /dev/null @@ -1,77 +0,0 @@ --- Require a recent custom email OTP verification before allowing account deletion. - -CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql" SECURITY DEFINER -SET - search_path = '' AS $$ -DECLARE - user_id_fn uuid; - user_email text; - old_record_json jsonb; - last_sign_in_at_ts timestamptz; - did_schedule integer; -BEGIN - SELECT "auth"."uid"() INTO user_id_fn; - IF user_id_fn IS NULL THEN - RAISE EXCEPTION 'not_authenticated' USING ERRCODE = '42501'; - END IF; - - SELECT "email", "last_sign_in_at" - INTO user_email, last_sign_in_at_ts - FROM "auth"."users" - WHERE "id" = user_id_fn; - - -- Require proof of email ownership from the custom email OTP flow rather than - -- relying on Supabase auth email_confirmed_at, which may be auto-populated. - IF NOT "public"."is_recent_email_otp_verified"(user_id_fn) THEN - RAISE EXCEPTION 'email_not_verified' USING ERRCODE = 'P0003'; - END IF; - - IF last_sign_in_at_ts IS NULL OR last_sign_in_at_ts < NOW() - INTERVAL '5 minutes' THEN - RAISE EXCEPTION 'reauth_required' USING ERRCODE = 'P0001'; - END IF; - - SELECT row_to_json(u)::jsonb INTO old_record_json - FROM ( - SELECT * - FROM "public"."users" - WHERE id = user_id_fn - ) AS u; - - IF old_record_json IS NULL THEN - RAISE EXCEPTION 'user_not_found' USING ERRCODE = 'P0002'; - END IF; - - INSERT INTO "public"."to_delete_accounts" ( - "account_id", - "removal_date", - "removed_data" - ) VALUES - ( - user_id_fn, - NOW() + INTERVAL '30 days', - "jsonb_build_object"('email', user_email, 'apikeys', COALESCE((SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn), '[]'::jsonb)) - ) - ON CONFLICT ("account_id") DO NOTHING - RETURNING 1 INTO did_schedule; - - IF did_schedule IS NULL THEN - RETURN; - END IF; - - PERFORM "pgmq"."send"( - 'on_user_delete'::text, - "jsonb_build_object"( - 'payload', "jsonb_build_object"( - 'old_record', old_record_json, - 'table', 'users', - 'type', 'DELETE' - ), - 'function_name', 'on_user_delete' - ) - ); - - DELETE FROM "public"."apikeys" WHERE "public"."apikeys"."user_id" = user_id_fn; -END; -$$; - -ALTER FUNCTION "public"."delete_user"() OWNER TO "postgres"; diff --git a/supabase/migrations/20260427144300_rbac_apikey_bindings_priority.sql b/supabase/migrations/20260427144300_rbac_apikey_bindings_priority.sql deleted file mode 100644 index 93ea0fc809..0000000000 --- a/supabase/migrations/20260427144300_rbac_apikey_bindings_priority.sql +++ /dev/null @@ -1,515 +0,0 @@ --- API Key RBAC Priority --- --- Changes to rbac_check_permission_direct (RBAC path): --- OLD: check user permissions first, fall back to apikey bindings --- NEW: if the API key has explicit role_bindings → use ONLY those (user perms ignored, --- ensuring limited keys are truly limited). If no bindings → enforce --- limited_to_orgs/limited_to_apps scope, then fall back to user perms. --- --- New function get_org_perm_for_apikey_v2: RBAC-aware version of get_org_perm_for_apikey. --- Routes to legacy function for non-RBAC orgs; uses rbac_check_permission_direct --- for RBAC orgs to return the correct perm_* level. --- --- New function get_org_apikeys: SECURITY DEFINER RPC for frontend to list all API keys --- relevant to an org (owner is an org member, key scope matches org). - --- ============================================================================= --- 1. Update rbac_check_permission_direct --- ============================================================================= - -CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"( - "p_permission_key" "text", - "p_user_id" "uuid", - "p_org_id" "uuid", - "p_app_id" character varying, - "p_channel_id" bigint, - "p_apikey" "text" DEFAULT NULL::"text" -) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - c_empty_text constant text := ''; - c_permission_key constant text := 'permission'; - c_org_id_key constant text := 'org_id'; - c_app_id_key constant text := 'app_id'; - c_channel_id_key constant text := 'channel_id'; - c_user_id_key constant text := 'user_id'; - c_has_apikey_key constant text := 'has_apikey'; - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_effective_user_id uuid := p_user_id; - v_legacy_right public.user_min_right; - v_apikey_user_id uuid; - v_apikey_principal uuid; - v_apikey_has_bindings boolean := false; - v_api_limited_orgs uuid[]; - v_api_limited_apps varchar[]; - v_override boolean; - v_channel_scope boolean := false; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; -BEGIN - -- Validate permission key - IF p_permission_key IS NULL OR p_permission_key = c_empty_text THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object(c_user_id_key, p_user_id)); - RETURN false; - END IF; - - IF p_channel_id IS NOT NULL AND p_permission_key LIKE 'channel.%' THEN - v_channel_scope := true; - END IF; - - -- Resolve API key first (handles hashed keys too) so it cannot be bypassed by p_user_id. - IF p_apikey IS NOT NULL THEN - SELECT user_id, rbac_id, limited_to_orgs, limited_to_apps - INTO v_apikey_user_id, v_apikey_principal, v_api_limited_orgs, v_api_limited_apps - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_apikey_user_id IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_INVALID_APIKEY', jsonb_build_object( - c_permission_key, p_permission_key, - c_org_id_key, v_effective_org_id, - c_app_id_key, p_app_id, - c_channel_id_key, p_channel_id - )); - RETURN false; - END IF; - - IF p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_apikey_user_id THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_USER_MISMATCH', jsonb_build_object( - c_permission_key, p_permission_key, - 'session_user_id', p_user_id, - 'apikey_user_id', v_apikey_user_id, - c_org_id_key, v_effective_org_id, - c_app_id_key, p_app_id, - c_channel_id_key, p_channel_id - )); - RETURN false; - END IF; - - v_effective_user_id := v_apikey_user_id; - END IF; - - -- Derive org from app/channel when not provided - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF v_effective_org_id IS NULL AND p_channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - c_permission_key, p_permission_key, - c_org_id_key, v_effective_org_id, - c_app_id_key, p_app_id, - c_channel_id_key, p_channel_id, - c_user_id_key, v_effective_user_id, - c_has_apikey_key, p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(v_effective_user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - c_permission_key, p_permission_key, - c_org_id_key, v_effective_org_id, - c_app_id_key, p_app_id, - c_channel_id_key, p_channel_id, - c_user_id_key, v_effective_user_id, - c_has_apikey_key, p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- Check if RBAC is enabled for this org - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - -- API key principal was resolved early so it cannot be bypassed by p_user_id. - IF p_apikey IS NOT NULL THEN - IF v_apikey_principal IS NOT NULL THEN - -- Does this key have any explicit RBAC role bindings? - SELECT EXISTS( - SELECT 1 FROM public.role_bindings - WHERE principal_type = public.rbac_principal_apikey() - AND principal_id = v_apikey_principal - ) INTO v_apikey_has_bindings; - - IF v_apikey_has_bindings THEN - -- Key has explicit bindings: ONLY check those (owner's user perms are ignored). - -- This ensures a limited key cannot exceed its explicitly granted permissions. - v_allowed := public.rbac_has_permission( - public.rbac_principal_apikey(), v_apikey_principal, - p_permission_key, v_effective_org_id, p_app_id, p_channel_id - ); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_apikey() - AND o.principal_id = v_apikey_principal - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - c_permission_key, p_permission_key, - c_user_id_key, v_effective_user_id, - c_org_id_key, v_effective_org_id, - c_app_id_key, p_app_id, - c_channel_id_key, p_channel_id, - c_has_apikey_key, true, - 'apikey_has_bindings', true - )); - END IF; - - RETURN v_allowed; - - ELSE - -- No explicit bindings: enforce limited_to_orgs / limited_to_apps scope - -- before falling through to the owner's user permissions. - -- Enforce org scope restriction - IF v_effective_org_id IS NOT NULL - AND v_api_limited_orgs IS NOT NULL - AND cardinality(v_api_limited_orgs) > 0 - AND NOT (v_effective_org_id = ANY(v_api_limited_orgs)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_ORG_SCOPE', jsonb_build_object( - c_permission_key, p_permission_key, - 'apikey_rbac_id', v_apikey_principal, - c_org_id_key, v_effective_org_id - )); - RETURN false; - END IF; - - -- Enforce app scope restriction - IF p_app_id IS NOT NULL - AND v_api_limited_apps IS NOT NULL - AND cardinality(v_api_limited_apps) > 0 - AND NOT (p_app_id = ANY(v_api_limited_apps)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_APP_SCOPE', jsonb_build_object( - c_permission_key, p_permission_key, - 'apikey_rbac_id', v_apikey_principal, - c_app_id_key, p_app_id - )); - RETURN false; - END IF; - - -- Scope OK — fall through to owner's user permission check below. - END IF; - END IF; - END IF; - - -- User permission check (owner fallback or no API key in request). - IF v_effective_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission( - public.rbac_principal_user(), v_effective_user_id, - p_permission_key, v_effective_org_id, p_app_id, p_channel_id - ); - - IF v_channel_scope THEN - -- Direct user override - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_user() - AND o.principal_id = v_effective_user_id - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - ELSE - -- Group overrides (deny > allow) - IF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = false - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := false; - ELSIF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = true - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := true; - END IF; - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - c_permission_key, p_permission_key, - c_user_id_key, v_effective_user_id, - c_org_id_key, v_effective_org_id, - c_app_id_key, p_app_id, - c_channel_id_key, p_channel_id, - c_has_apikey_key, p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - - ELSE - -- Legacy path: Map permission to min_right and use legacy check - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - c_permission_key, p_permission_key, - c_user_id_key, p_user_id - )); - RETURN false; - END IF; - - IF p_apikey IS NOT NULL AND p_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, COALESCE(v_effective_user_id, p_user_id), p_apikey); - ELSIF p_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(p_app_id, v_legacy_right, p_user_id); - ELSE - RETURN public.check_min_rights_legacy(v_legacy_right, COALESCE(v_effective_user_id, p_user_id), v_effective_org_id, p_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - --- ============================================================================= --- 2. get_org_perm_for_apikey_v2 --- RBAC-aware version of get_org_perm_for_apikey. --- For RBAC-enabled orgs: determines the effective permission level by probing --- rbac_check_permission_direct with characteristic permissions for each level. --- For legacy orgs: delegates to the existing get_org_perm_for_apikey. --- ============================================================================= - -CREATE OR REPLACE FUNCTION "public"."get_org_perm_for_apikey_v2"( - "apikey" "text", - "app_id" "text" -) RETURNS "text" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_user_id uuid; - v_org_id uuid; - v_use_rbac boolean; -BEGIN - -- Resolve user from API key (supports hashed keys) - SELECT user_id INTO v_user_id - FROM public.find_apikey_by_value(get_org_perm_for_apikey_v2.apikey) - LIMIT 1; - - IF v_user_id IS NULL THEN - RETURN 'INVALID_APIKEY'; - END IF; - - -- Resolve org from app - SELECT owner_org INTO v_org_id - FROM public.apps - WHERE public.apps.app_id = get_org_perm_for_apikey_v2.app_id - LIMIT 1; - - IF v_org_id IS NULL THEN - RETURN 'NO_APP'; - END IF; - - -- Route to legacy function for non-RBAC orgs - v_use_rbac := public.rbac_is_enabled_for_org(v_org_id); - IF NOT v_use_rbac THEN - RETURN public.get_org_perm_for_apikey(get_org_perm_for_apikey_v2.apikey, get_org_perm_for_apikey_v2.app_id); - END IF; - - -- RBAC path: probe permissions from highest to lowest, return first match. - -- rbac_check_permission_direct handles "key bindings take priority" logic internally. - - IF public.rbac_check_permission_direct( - 'org.delete', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, - get_org_perm_for_apikey_v2.apikey - ) THEN - RETURN 'perm_owner'; - END IF; - - IF public.rbac_check_permission_direct( - 'app.delete', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, - get_org_perm_for_apikey_v2.apikey - ) THEN - RETURN 'perm_admin'; - END IF; - - IF public.rbac_check_permission_direct( - 'app.create_channel', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, - get_org_perm_for_apikey_v2.apikey - ) THEN - RETURN 'perm_write'; - END IF; - - IF public.rbac_check_permission_direct( - 'app.upload_bundle', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, - get_org_perm_for_apikey_v2.apikey - ) THEN - RETURN 'perm_upload'; - END IF; - - IF public.rbac_check_permission_direct( - 'app.read', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, - get_org_perm_for_apikey_v2.apikey - ) THEN - RETURN 'perm_read'; - END IF; - - RETURN 'perm_none'; -END; -$$; - -ALTER FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") FROM PUBLIC; -REVOKE EXECUTE ON FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") FROM "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") TO "service_role"; - --- ============================================================================= --- 3. get_org_apikeys --- Returns API keys relevant to an org for the RBAC management UI. --- "Relevant" includes owner membership, org/app-scoped RBAC bindings, or --- app/org limits that point to apps in this org. --- key/key_hash are intentionally excluded (sensitive). --- ============================================================================= - -CREATE OR REPLACE FUNCTION "public"."get_org_apikeys"( - "p_org_id" "uuid" -) RETURNS TABLE ( - "id" bigint, - "rbac_id" "uuid", - "name" "text", - "mode" "public"."key_mode", - "limited_to_orgs" "uuid"[], - "limited_to_apps" "varchar"[], - "user_id" "uuid", - "owner_email" character varying, - "created_at" timestamptz, - "expires_at" timestamptz -) - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -BEGIN - -- Permission check: caller must be allowed to manage org roles/API keys. - IF NOT public.rbac_check_permission_direct( - public.rbac_perm_org_update_user_roles(), - auth.uid(), - p_org_id, - NULL::varchar, - NULL::bigint, - public.get_apikey_header() - ) THEN - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - RETURN QUERY - SELECT - ak.id, - ak.rbac_id, - ak.name::text, - ak.mode, - ak.limited_to_orgs, - ak.limited_to_apps, - ak.user_id, - u.email, - ak.created_at, - ak.expires_at - FROM public.apikeys ak - INNER JOIN public.users u - ON u.id = ak.user_id - WHERE - ( - EXISTS ( - SELECT 1 - FROM public.org_users ou - WHERE ou.user_id = ak.user_id - AND ou.org_id = p_org_id - ) - OR EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.scope_type = public.rbac_scope_org() - AND rb.principal_id = ak.user_id - AND rb.org_id = p_org_id - ) - OR EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.scope_type = public.rbac_scope_org() - AND rb.principal_id = ak.rbac_id - AND rb.org_id = p_org_id - ) - OR EXISTS ( - SELECT 1 - FROM public.role_bindings rb - INNER JOIN public.apps a - ON a.id = rb.app_id - AND a.owner_org = p_org_id - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.scope_type = public.rbac_scope_app() - AND rb.principal_id = ak.rbac_id - ) - OR EXISTS ( - SELECT 1 - FROM public.apps a - WHERE a.owner_org = p_org_id - AND ak.limited_to_apps IS NOT NULL - AND a.app_id = ANY(ak.limited_to_apps) - ) - ) - -- Key scope: either unlimited (no org restriction) or includes this org - AND (ak.limited_to_orgs IS NULL OR cardinality(ak.limited_to_orgs) = 0 OR p_org_id = ANY(ak.limited_to_orgs)) - -- Exclude expired keys - AND (ak.expires_at IS NULL OR ak.expires_at > now()) - ORDER BY ak.created_at DESC; -END; -$$; - -ALTER FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") FROM PUBLIC; -REVOKE EXECUTE ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") FROM "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") TO "service_role"; diff --git a/supabase/migrations/20260427144323_cli_rbac_permission_wrappers.sql b/supabase/migrations/20260427144323_cli_rbac_permission_wrappers.sql deleted file mode 100644 index 7b0965428a..0000000000 --- a/supabase/migrations/20260427144323_cli_rbac_permission_wrappers.sql +++ /dev/null @@ -1,127 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid" DEFAULT NULL, - "app_id" "text" DEFAULT NULL, - "channel_id" bigint DEFAULT NULL -) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_user_id uuid; -BEGIN - IF apikey IS NULL OR apikey = '' OR permission_key IS NULL OR permission_key = '' THEN - RETURN false; - END IF; - - SELECT public.get_user_id(apikey) INTO v_user_id; - - IF v_user_id IS NULL THEN - RETURN false; - END IF; - - RETURN public.rbac_check_permission_direct( - permission_key, - v_user_id, - org_id, - app_id, - channel_id, - apikey - ); -END; -$$; - -ALTER FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) TO "service_role"; - -COMMENT ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) IS 'CLI permission wrapper. Resolves the user from the API key and delegates to rbac_check_permission_direct, preserving RBAC/legacy fallback semantics.'; - -CREATE OR REPLACE FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) RETURNS SETOF "public"."apps" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_user_id uuid; -BEGIN - SELECT public.get_user_id(apikey) INTO v_user_id; - - IF v_user_id IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT a.* - FROM public.apps a - WHERE public.rbac_check_permission_direct( - public.rbac_perm_app_read(), - v_user_id, - a.owner_org, - a.app_id, - NULL, - apikey - ) - ORDER BY a.created_at DESC; -END; -$$; - -ALTER FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) TO "service_role"; - -COMMENT ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) IS 'Returns apps visible to an API key using RBAC-aware permission checks with legacy fallback.'; diff --git a/supabase/migrations/20260427144324_add_org_create_app_permission.sql b/supabase/migrations/20260427144324_add_org_create_app_permission.sql deleted file mode 100644 index f5ed3b741f..0000000000 --- a/supabase/migrations/20260427144324_add_org_create_app_permission.sql +++ /dev/null @@ -1,190 +0,0 @@ -CREATE OR REPLACE FUNCTION public.rbac_perm_org_create_app() RETURNS text -LANGUAGE sql -IMMUTABLE -SET search_path = '' -AS $$ SELECT 'org.create_app'::text $$; - -ALTER FUNCTION public.rbac_perm_org_create_app() OWNER TO postgres; - -COMMENT ON FUNCTION public.rbac_perm_org_create_app() IS - 'RBAC permission key: create an app within an organization.'; - -REVOKE ALL ON FUNCTION public.rbac_perm_org_create_app() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.rbac_perm_org_create_app() TO anon; -GRANT EXECUTE ON FUNCTION public.rbac_perm_org_create_app() TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_perm_org_create_app() TO service_role; - -INSERT INTO public.permissions (key, scope_type, description) -VALUES ( - public.rbac_perm_org_create_app(), - public.rbac_scope_org(), - 'Create a new app within an organization' -) -ON CONFLICT (key) DO NOTHING; - -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key = public.rbac_perm_org_create_app() -WHERE r.name IN ( - public.rbac_role_org_super_admin(), - public.rbac_role_org_admin(), - public.rbac_role_org_billing_admin(), - public.rbac_role_org_member() -) -ON CONFLICT DO NOTHING; - -CREATE OR REPLACE FUNCTION public.rbac_legacy_right_for_permission( - p_permission_key text -) RETURNS public.user_min_right -LANGUAGE plpgsql -SET search_path = '' -IMMUTABLE AS $$ -BEGIN - CASE p_permission_key - WHEN public.rbac_perm_org_read() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_org_read_members() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read_bundles() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read_channels() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read_logs() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_app_read_devices() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_channel_read() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_channel_read_history() THEN RETURN public.rbac_right_read(); - WHEN public.rbac_perm_channel_read_forced_devices() THEN RETURN public.rbac_right_read(); - - WHEN public.rbac_perm_app_upload_bundle() THEN RETURN public.rbac_right_upload(); - - WHEN public.rbac_perm_app_update_settings() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_app_create_channel() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_app_manage_devices() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_app_build_native() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_channel_update_settings() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_channel_promote_bundle() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_channel_rollback_bundle() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_channel_manage_forced_devices() THEN RETURN public.rbac_right_write(); - - WHEN public.rbac_perm_org_create_app() THEN RETURN public.rbac_right_write(); - WHEN public.rbac_perm_org_update_settings() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_org_invite_user() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_org_read_billing() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_org_read_invoices() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_org_read_audit() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_app_delete() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_app_read_audit() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_bundle_delete() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_channel_delete() THEN RETURN public.rbac_right_admin(); - WHEN public.rbac_perm_channel_read_audit() THEN RETURN public.rbac_right_admin(); - - WHEN public.rbac_perm_org_update_user_roles() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_org_update_billing() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_org_read_billing_audit() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_org_delete() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_app_transfer() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_impersonate_user() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_manage_orgs_any() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_manage_apps_any() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_manage_channels_any() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_run_maintenance_jobs() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_delete_orphan_users() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_read_all_audit() THEN RETURN public.rbac_right_super_admin(); - WHEN public.rbac_perm_platform_db_break_glass() THEN RETURN public.rbac_right_super_admin(); - ELSE RETURN NULL; - END CASE; -END; -$$; - -CREATE OR REPLACE FUNCTION public.rbac_check_permission_request( - p_permission_key text, - p_org_id uuid DEFAULT NULL, - p_app_id character varying DEFAULT NULL, - p_channel_id bigint DEFAULT NULL -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - RETURN public.rbac_check_permission_direct( - p_permission_key, - auth.uid(), - p_org_id, - p_app_id, - p_channel_id, - public.get_apikey_header() - ); -END; -$$; - -ALTER FUNCTION public.rbac_check_permission_request(text, uuid, character varying, bigint) OWNER TO postgres; - -COMMENT ON FUNCTION public.rbac_check_permission_request(text, uuid, character varying, bigint) IS - 'Request-aware RBAC permission wrapper for RLS and SQL callers. Uses auth.uid() and capgkey header, preserving RBAC/legacy fallback semantics.'; - -REVOKE ALL ON FUNCTION public.rbac_check_permission_request(text, uuid, character varying, bigint) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.rbac_check_permission_request(text, uuid, character varying, bigint) TO anon; -GRANT EXECUTE ON FUNCTION public.rbac_check_permission_request(text, uuid, character varying, bigint) TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_check_permission_request(text, uuid, character varying, bigint) TO service_role; - -DROP POLICY IF EXISTS "Allow insert for apikey (write,all) (admin+)" ON public.apps; - -CREATE POLICY "Allow insert for apikey (write,all) (admin+)" ON public.apps -FOR INSERT TO anon, authenticated -WITH CHECK ( - public.rbac_check_permission_request( - public.rbac_perm_org_create_app(), - owner_org, - NULL::character varying, - NULL::bigint - ) -); - -DROP POLICY IF EXISTS "Allow user or apikey to insert they own folder in images" ON storage.objects; - -CREATE POLICY "Allow user or apikey to insert they own folder in images" -ON storage.objects -FOR INSERT -TO anon, authenticated -WITH CHECK ( - bucket_id = 'images' - AND ( - CASE - WHEN (storage.foldername(name))[1] = 'org' THEN - ( - EXISTS ( - SELECT 1 - FROM public.apps - WHERE owner_org = ((storage.foldername(name))[2])::uuid - AND app_id = (storage.foldername(name))[3] - ) - AND public.rbac_check_permission_request( - public.rbac_perm_app_update_settings(), - ((storage.foldername(name))[2])::uuid, - (storage.foldername(name))[3], - NULL::bigint - ) - ) - OR ( - NOT EXISTS ( - SELECT 1 - FROM public.apps - WHERE owner_org = ((storage.foldername(name))[2])::uuid - AND app_id = (storage.foldername(name))[3] - ) - AND public.rbac_check_permission_request( - public.rbac_perm_org_create_app(), - ((storage.foldername(name))[2])::uuid, - NULL::character varying, - NULL::bigint - ) - ) - ELSE false - END - OR EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid) AS auth_user - WHERE auth_user.uid IS NOT NULL - AND auth_user.uid::text = (storage.foldername(name))[1] - ) - ) -); diff --git a/supabase/migrations/20260427144325_fix_helper_rpc_request_role_and_admin_grants.sql b/supabase/migrations/20260427144325_fix_helper_rpc_request_role_and_admin_grants.sql deleted file mode 100644 index 2623306f72..0000000000 --- a/supabase/migrations/20260427144325_fix_helper_rpc_request_role_and_admin_grants.sql +++ /dev/null @@ -1,533 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."current_request_role"() -RETURNS "text" -LANGUAGE "sql" STABLE -SET "search_path" TO '' -AS $$ - SELECT COALESCE( - NULLIF(current_setting('request.jwt.claim.role', true), ''), - NULLIF((SELECT auth.jwt() ->> 'role'), ''), - NULLIF(current_setting('role', true), ''), - '' - ) -$$; - -CREATE OR REPLACE FUNCTION "public"."internal_request_role_names"() -RETURNS text[] -LANGUAGE "sql" IMMUTABLE -SET "search_path" TO '' -AS $$ - SELECT ARRAY['service_role', 'postgres', 'supabase_admin']::text[] -$$; - -ALTER FUNCTION "public"."internal_request_role_names"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."internal_request_role_names"() FROM PUBLIC; - -CREATE OR REPLACE FUNCTION "public"."internal_request_db_user_names"() -RETURNS text[] -LANGUAGE "sql" IMMUTABLE -SET "search_path" TO '' -AS $$ - SELECT ARRAY['postgres', 'supabase_admin']::text[] -$$; - -ALTER FUNCTION "public"."internal_request_db_user_names"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."internal_request_db_user_names"() FROM PUBLIC; - -CREATE OR REPLACE FUNCTION "public"."request_read_key_modes"() -RETURNS public.key_mode[] -LANGUAGE "sql" IMMUTABLE -SET "search_path" TO '' -AS $$ - SELECT '{read,upload,write,all}'::public.key_mode[] -$$; - -ALTER FUNCTION "public"."request_read_key_modes"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."request_read_key_modes"() FROM PUBLIC; - -CREATE OR REPLACE FUNCTION "public"."is_internal_request_role"("caller_role" text) -RETURNS boolean -LANGUAGE "sql" STABLE -SET "search_path" TO '' -AS $$ - SELECT ( - caller_role = ANY (public.internal_request_role_names()) - OR ( - caller_role = ANY (ARRAY['', 'none']::text[]) - AND COALESCE(session_user, current_user) = ANY (public.internal_request_db_user_names()) - ) - ) -$$; - -ALTER FUNCTION "public"."is_internal_request_role"(text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."is_internal_request_role"(text) FROM PUBLIC; - -CREATE OR REPLACE FUNCTION "public"."request_has_org_read_access"("orgid" "uuid") -RETURNS boolean -LANGUAGE "plpgsql" STABLE -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - caller_id uuid; -BEGIN - SELECT public.get_identity_org_allowed( - public.request_read_key_modes(), - request_has_org_read_access.orgid - ) - INTO caller_id; - - RETURN ( - caller_id IS NOT NULL - AND public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - request_has_org_read_access.orgid, - NULL::character varying, - NULL::bigint - ) - ); -END; -$$; - -ALTER FUNCTION "public"."request_has_org_read_access"("orgid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."request_has_org_read_access"("orgid" "uuid") FROM PUBLIC; - -CREATE OR REPLACE FUNCTION "public"."request_has_app_read_access"("orgid" "uuid", "appid" character varying) -RETURNS boolean -LANGUAGE "plpgsql" STABLE -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - caller_id uuid; -BEGIN - SELECT public.get_identity_org_appid( - public.request_read_key_modes(), - request_has_app_read_access.orgid, - request_has_app_read_access.appid - ) - INTO caller_id; - - RETURN ( - caller_id IS NOT NULL - AND public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - request_has_app_read_access.orgid, - request_has_app_read_access.appid, - NULL::bigint - ) - ); -END; -$$; - -ALTER FUNCTION "public"."request_has_app_read_access"("orgid" "uuid", "appid" character varying) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."request_has_app_read_access"("orgid" "uuid", "appid" character varying) FROM PUBLIC; - -CREATE OR REPLACE FUNCTION "public"."is_platform_admin"("userid" "uuid") -RETURNS boolean -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - admin_ids_jsonb jsonb; - is_platform_admin_from_secret boolean; - mfa_verified boolean; -BEGIN - SELECT public.verify_mfa() INTO mfa_verified; - IF NOT mfa_verified THEN - RETURN false; - END IF; - - SELECT decrypted_secret::jsonb - INTO admin_ids_jsonb - FROM vault.decrypted_secrets - WHERE name = 'admin_users'; - - is_platform_admin_from_secret := COALESCE(admin_ids_jsonb ? userid::text, false); - - RETURN is_platform_admin_from_secret; -END; -$$; - -ALTER FUNCTION "public"."is_platform_admin"("userid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."is_platform_admin"() -RETURNS boolean -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -BEGIN - RETURN public.is_platform_admin((SELECT auth.uid())); -END; -$$; - -ALTER FUNCTION "public"."is_platform_admin"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."is_platform_admin"("userid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_platform_admin"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."is_platform_admin"("userid" "uuid") TO "service_role"; -GRANT EXECUTE ON FUNCTION "public"."is_platform_admin"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_platform_admin"() TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_paying_org"("orgid" "uuid") -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - caller_role text; -BEGIN - SELECT public.current_request_role() INTO caller_role; - - IF NOT public.is_internal_request_role(caller_role) THEN - IF NOT public.request_has_org_read_access(is_paying_org.orgid) THEN - RETURN false; - END IF; - END IF; - - RETURN ( - SELECT EXISTS ( - SELECT 1 - FROM public.stripe_info - WHERE customer_id = (SELECT customer_id FROM public.orgs WHERE id = orgid) - AND status = 'succeeded' - ) - ); -END; -$$; - -ALTER FUNCTION "public"."is_paying_org"("orgid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."is_paying_org"("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_paying_org"("orgid" "uuid") FROM "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_paying_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_paying_org"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_trial_org"("orgid" "uuid") -RETURNS integer -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - caller_role text; -BEGIN - SELECT public.current_request_role() INTO caller_role; - - IF NOT public.is_internal_request_role(caller_role) THEN - IF NOT public.request_has_org_read_access(is_trial_org.orgid) THEN - RETURN 0; - END IF; - END IF; - - RETURN COALESCE( - ( - SELECT GREATEST((trial_at::date - NOW()::date), 0) - FROM public.stripe_info - WHERE customer_id = (SELECT customer_id FROM public.orgs WHERE id = orgid) - ), - 0 - ); -END; -$$; - -ALTER FUNCTION "public"."is_trial_org"("orgid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."is_trial_org"("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_trial_org"("orgid" "uuid") FROM "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_trial_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_trial_org"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") -RETURNS TABLE("mau" bigint, "bandwidth" bigint, "storage" bigint, "build_time_unit" bigint) -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_request_user uuid; - v_request_role text; - v_is_internal boolean; -BEGIN - SELECT public.current_request_role() INTO v_request_role; - - v_is_internal := public.is_internal_request_role(v_request_role); - - IF NOT v_is_internal THEN - v_request_user := public.get_identity_org_allowed( - public.request_read_key_modes(), - get_current_plan_max_org.orgid - ); - - IF NOT public.request_has_org_read_access(get_current_plan_max_org.orgid) THEN - PERFORM public.pg_log( - 'deny: NO_RIGHTS', - pg_catalog.jsonb_build_object( - 'orgid', - get_current_plan_max_org.orgid, - 'uid', - v_request_user - ) - ); - RETURN; - END IF; - END IF; - - RETURN QUERY - SELECT p.mau, p.bandwidth, p.storage, p.build_time_unit - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; -END; -$$; - -ALTER FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") FROM "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") -RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - caller_role text; -BEGIN - SELECT public.current_request_role() INTO caller_role; - - IF NOT public.is_internal_request_role(caller_role) THEN - IF NOT public.request_has_org_read_access(is_paying_and_good_plan_org.orgid) THEN - RETURN false; - END IF; - END IF; - - RETURN ( - SELECT - EXISTS ( - SELECT 1 - FROM public.usage_credit_balances ucb - WHERE ucb.org_id = orgid - AND COALESCE(ucb.available_credits, 0) > 0 - ) - OR EXISTS ( - SELECT 1 - FROM public.stripe_info - WHERE customer_id = (SELECT customer_id FROM public.orgs WHERE id = orgid) - AND ( - (status = 'succeeded' AND is_good_plan = true) - OR (trial_at::date - NOW()::date > 0) - ) - ) - ); -END; -$$; - -ALTER FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") TO "anon"; -GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") TO "authenticated"; -GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") -RETURNS double precision -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - total_size double precision := 0; - caller_role text; -BEGIN - SELECT public.current_request_role() INTO caller_role; - - IF NOT public.is_internal_request_role(caller_role) THEN - IF NOT public.request_has_org_read_access(get_total_storage_size_org.org_id) THEN - RETURN 0; - END IF; - END IF; - - SELECT COALESCE(SUM(app_versions_meta.size), 0) INTO total_size - FROM public.app_versions - INNER JOIN public.app_versions_meta ON app_versions.id = app_versions_meta.id - WHERE app_versions.owner_org = org_id - AND app_versions.deleted = false; - - RETURN total_size; -END; -$$; - -ALTER FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") TO "anon"; -GRANT ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") TO "authenticated"; -GRANT ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) -RETURNS double precision -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - total_size double precision := 0; - caller_role text; -BEGIN - SELECT public.current_request_role() INTO caller_role; - - IF NOT public.is_internal_request_role(caller_role) THEN - IF NOT public.request_has_app_read_access( - get_total_app_storage_size_orgs.org_id, - get_total_app_storage_size_orgs.app_id - ) THEN - RETURN 0; - END IF; - END IF; - - SELECT COALESCE(SUM(app_versions_meta.size), 0) INTO total_size - FROM public.app_versions - INNER JOIN public.app_versions_meta ON app_versions.id = app_versions_meta.id - WHERE app_versions.owner_org = org_id - AND app_versions.app_id = get_total_app_storage_size_orgs.app_id - AND app_versions.deleted = false; - - RETURN total_size; -END; -$$; - -ALTER FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) TO "anon"; -GRANT ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) TO "authenticated"; -GRANT ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") -RETURNS "uuid" -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - org_id uuid; - caller_role text; - caller_id uuid; -BEGIN - SELECT public.current_request_role() INTO caller_role; - - IF NOT public.is_internal_request_role(caller_role) THEN - SELECT auth.uid() INTO caller_id; - IF caller_id IS NULL OR caller_id <> get_user_main_org_id.user_id THEN - RETURN NULL; - END IF; - END IF; - - SELECT orgs.id - INTO org_id - FROM public.orgs - WHERE orgs.created_by = get_user_main_org_id.user_id - LIMIT 1; - - RETURN org_id; -END; -$$; - -ALTER FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") FROM "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") -RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - is_found integer; - caller_role text; - caller_id uuid; -BEGIN - SELECT public.current_request_role() INTO caller_role; - - IF NOT public.is_internal_request_role(caller_role) THEN - SELECT public.get_identity_org_allowed(public.request_read_key_modes(), is_member_of_org.org_id) - INTO caller_id; - - IF caller_id IS NULL OR caller_id <> is_member_of_org.user_id OR NOT public.check_min_rights( - 'read'::public.user_min_right, - caller_id, - is_member_of_org.org_id, - NULL::character varying, - NULL::bigint - ) THEN - RETURN false; - END IF; - END IF; - - SELECT count(*) - INTO is_found - FROM public.orgs - JOIN public.org_users ON org_users.org_id = orgs.id - WHERE org_users.user_id = is_member_of_org.user_id - AND orgs.id = is_member_of_org.org_id; - - RETURN is_found != 0; -END; -$$; - -ALTER FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "anon"; -GRANT ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "authenticated"; -GRANT ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_account_disabled"("user_id" "uuid") -RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - caller_role text; - caller_id uuid; -BEGIN - SELECT public.current_request_role() INTO caller_role; - - IF NOT public.is_internal_request_role(caller_role) THEN - SELECT auth.uid() INTO caller_id; - IF caller_id IS NULL OR caller_id <> is_account_disabled.user_id THEN - RETURN false; - END IF; - END IF; - - RETURN EXISTS ( - SELECT 1 - FROM public.to_delete_accounts - WHERE account_id = user_id - ); -END; -$$; - -ALTER FUNCTION "public"."is_account_disabled"("user_id" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") FROM "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."rbac_enable_for_org"("p_org_id" "uuid", "p_granted_by" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."rbac_enable_for_org"("p_org_id" "uuid", "p_granted_by" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."rbac_enable_for_org"("p_org_id" "uuid", "p_granted_by" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."rbac_enable_for_org"("p_org_id" "uuid", "p_granted_by" "uuid") TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."rbac_migrate_org_users_to_bindings"("p_org_id" "uuid", "p_granted_by" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."rbac_migrate_org_users_to_bindings"("p_org_id" "uuid", "p_granted_by" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."rbac_migrate_org_users_to_bindings"("p_org_id" "uuid", "p_granted_by" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."rbac_migrate_org_users_to_bindings"("p_org_id" "uuid", "p_granted_by" "uuid") TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."rbac_rollback_org"("p_org_id" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."rbac_rollback_org"("p_org_id" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."rbac_rollback_org"("p_org_id" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."rbac_rollback_org"("p_org_id" "uuid") TO "service_role"; diff --git a/supabase/migrations/20260427144331_restore_rbac_apikey_mismatch_and_bindings_priority.sql b/supabase/migrations/20260427144331_restore_rbac_apikey_mismatch_and_bindings_priority.sql deleted file mode 100644 index a49e09fcbe..0000000000 --- a/supabase/migrations/20260427144331_restore_rbac_apikey_mismatch_and_bindings_priority.sql +++ /dev/null @@ -1,625 +0,0 @@ --- Restore user-mismatch check and API key bindings-priority that were --- overwritten by 20260424094101_enforce_apikey_scope_in_rbac_check.sql. --- --- Main's migration rewrote rbac_check_permission_direct but lost two features --- from 20260305120000_rbac_apikey_bindings_priority.sql: --- 1. User mismatch check: deny when the session user != API key owner. --- 2. Bindings priority: keys with explicit role_bindings use ONLY those --- bindings (early return) so limited keys cannot exceed their grants. --- --- This migration merges main's improvements (full row type, is_apikey_expired, --- channel scope resolution, effective_app_id, no_password_policy variant) with --- our branch's two features above. - --- ============================================================================= --- 1. rbac_check_permission_direct (with password policy) --- ============================================================================= - -CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"( - "p_permission_key" "text", - "p_user_id" "uuid", - "p_org_id" "uuid", - "p_app_id" character varying, - "p_channel_id" bigint, - "p_apikey" "text" DEFAULT NULL::"text" -) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_effective_user_id uuid := p_user_id; - v_effective_app_id character varying := p_app_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; - v_apikey_has_bindings boolean := false; - v_override boolean; - v_channel_scope boolean := false; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; - v_api_key public.apikeys%ROWTYPE; - v_channel_org_id uuid; - v_channel_app_id character varying; -BEGIN - -- Validate permission key - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - IF p_channel_id IS NOT NULL AND p_permission_key LIKE 'channel.%' THEN - v_channel_scope := true; - END IF; - - -- Resolve org from app when not provided - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - -- Resolve channel scope (overrides org/app if present) - IF p_channel_id IS NOT NULL THEN - SELECT owner_org, app_id - INTO v_channel_org_id, v_channel_app_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_org_id IS NOT NULL THEN - v_effective_org_id := v_channel_org_id; - v_effective_app_id := v_channel_app_id; - END IF; - END IF; - - -- ── API key resolution and validation ── - IF p_apikey IS NOT NULL THEN - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NOT_FOUND', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - IF public.is_apikey_expired(v_api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object( - 'key_id', v_api_key.id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - -- User mismatch check: the session user must own the API key. - -- Without this, an attacker with broad user permissions could use - -- another user's restricted key and still pass auth via their own roles. - IF p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_USER_MISMATCH', jsonb_build_object( - 'permission', p_permission_key, - 'session_user_id', p_user_id, - 'apikey_user_id', v_api_key.user_id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - -- Always use the API key owner as the effective user so that downstream - -- permission checks resolve against the correct principal. - v_effective_user_id := v_api_key.user_id; - - IF v_effective_org_id IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NO_ORG', jsonb_build_object( - 'permission', p_permission_key, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - - -- Org scope restriction - IF COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 - AND NOT (v_effective_org_id = ANY(v_api_key.limited_to_orgs)) - THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_ORG_RESTRICT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - - -- App scope restriction - IF COALESCE(array_length(v_api_key.limited_to_apps, 1), 0) > 0 THEN - IF v_effective_app_id IS NULL OR NOT (v_effective_app_id = ANY(v_api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_APP_RESTRICT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - END IF; - END IF; - - -- ── 2FA enforcement ── - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- ── Password policy enforcement ── - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(v_effective_user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- ── RBAC vs legacy dispatch ── - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - -- ── Bindings priority: API keys with explicit role_bindings use ONLY - -- those bindings (user permissions are ignored). This guarantees a - -- limited key cannot exceed its explicitly granted permission set. ── - IF v_api_key.id IS NOT NULL THEN - v_apikey_principal := v_api_key.rbac_id; - - IF v_apikey_principal IS NOT NULL THEN - SELECT EXISTS( - SELECT 1 FROM public.role_bindings - WHERE principal_type = public.rbac_principal_apikey() - AND principal_id = v_apikey_principal - ) INTO v_apikey_has_bindings; - - IF v_apikey_has_bindings THEN - -- Key has explicit bindings: ONLY check those (owner user perms ignored). - v_allowed := public.rbac_has_permission( - public.rbac_principal_apikey(), v_apikey_principal, - p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id - ); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_apikey() - AND o.principal_id = v_apikey_principal - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'has_apikey', true, - 'apikey_has_bindings', true - )); - END IF; - - -- Early return: bindings-only evaluation, user perms not consulted. - RETURN v_allowed; - END IF; - END IF; - END IF; - - -- ── User permission check (no apikey, or apikey without explicit bindings). ── - IF v_effective_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission( - public.rbac_principal_user(), v_effective_user_id, - p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id - ); - - IF v_channel_scope THEN - -- Direct user override - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_user() - AND o.principal_id = v_effective_user_id - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - ELSE - -- Group overrides (deny wins over allow) - IF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = false - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := false; - ELSIF EXISTS ( - SELECT 1 - FROM public.channel_permission_overrides o - JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id - JOIN public.groups g ON g.id = gm.group_id - WHERE o.principal_type = public.rbac_principal_group() - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - AND o.is_allowed = true - AND g.org_id = v_effective_org_id - ) THEN - v_allowed := true; - END IF; - END IF; - END IF; - END IF; - - -- Fallback: apikey without explicit bindings may still carry role_bindings - -- from group membership or other indirect paths. - IF NOT v_allowed AND v_api_key.id IS NOT NULL THEN - v_apikey_principal := v_api_key.rbac_id; - - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission( - public.rbac_principal_apikey(), v_apikey_principal, - p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id - ); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_apikey() - AND o.principal_id = v_apikey_principal - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - END IF; - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - - ELSE - -- ── Legacy path ── - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', p_user_id - )); - RETURN false; - END IF; - - IF p_apikey IS NOT NULL AND v_effective_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(v_effective_app_id, v_legacy_right, v_effective_user_id, p_apikey); - ELSIF v_effective_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(v_effective_app_id, v_legacy_right, v_effective_user_id); - ELSE - RETURN public.check_min_rights_legacy(v_legacy_right, v_effective_user_id, v_effective_org_id, v_effective_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - -ALTER FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct"("text", "uuid", "uuid", character varying, bigint, "text") TO "service_role"; - - --- ============================================================================= --- 2. rbac_check_permission_direct_no_password_policy (same fixes) --- ============================================================================= - -CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct_no_password_policy"( - "p_permission_key" "text", - "p_user_id" "uuid", - "p_org_id" "uuid", - "p_app_id" character varying, - "p_channel_id" bigint, - "p_apikey" "text" DEFAULT NULL::"text" -) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_allowed boolean := false; - v_use_rbac boolean; - v_effective_org_id uuid := p_org_id; - v_effective_user_id uuid := p_user_id; - v_effective_app_id character varying := p_app_id; - v_legacy_right public.user_min_right; - v_apikey_principal uuid; - v_apikey_has_bindings boolean := false; - v_org_enforcing_2fa boolean; - v_api_key public.apikeys%ROWTYPE; - v_channel_org_id uuid; - v_channel_app_id character varying; -BEGIN - -- Validate permission key - IF p_permission_key IS NULL OR p_permission_key = '' THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); - RETURN false; - END IF; - - -- Resolve org from app when not provided - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - -- Resolve channel scope (overrides org/app if present) - IF p_channel_id IS NOT NULL THEN - SELECT owner_org, app_id - INTO v_channel_org_id, v_channel_app_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_org_id IS NOT NULL THEN - v_effective_org_id := v_channel_org_id; - v_effective_app_id := v_channel_app_id; - END IF; - END IF; - - -- ── API key resolution and validation ── - IF p_apikey IS NOT NULL THEN - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NOT_FOUND', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - IF public.is_apikey_expired(v_api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object( - 'key_id', v_api_key.id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - -- User mismatch check - IF p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_USER_MISMATCH', jsonb_build_object( - 'permission', p_permission_key, - 'session_user_id', p_user_id, - 'apikey_user_id', v_api_key.user_id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id - )); - RETURN false; - END IF; - - v_effective_user_id := v_api_key.user_id; - - IF v_effective_org_id IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_NO_ORG', jsonb_build_object( - 'permission', p_permission_key, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - - IF COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 - AND NOT (v_effective_org_id = ANY(v_api_key.limited_to_orgs)) - THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_ORG_RESTRICT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - - IF COALESCE(array_length(v_api_key.limited_to_apps, 1), 0) > 0 THEN - IF v_effective_app_id IS NULL OR NOT (v_effective_app_id = ANY(v_api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_APIKEY_APP_RESTRICT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'key_id', v_api_key.id - )); - RETURN false; - END IF; - END IF; - END IF; - - -- ── 2FA enforcement ── - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( - 'permission', p_permission_key, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'user_id', v_effective_user_id, - 'has_apikey', p_apikey IS NOT NULL - )); - RETURN false; - END IF; - END IF; - - -- (no password policy check in this variant) - - -- ── RBAC vs legacy dispatch ── - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - - IF v_use_rbac THEN - -- Bindings priority: keys with explicit role_bindings use ONLY those. - IF v_api_key.id IS NOT NULL THEN - v_apikey_principal := v_api_key.rbac_id; - - IF v_apikey_principal IS NOT NULL THEN - SELECT EXISTS( - SELECT 1 FROM public.role_bindings - WHERE principal_type = public.rbac_principal_apikey() - AND principal_id = v_apikey_principal - ) INTO v_apikey_has_bindings; - - IF v_apikey_has_bindings THEN - v_allowed := public.rbac_has_permission( - public.rbac_principal_apikey(), v_apikey_principal, - p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id - ); - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'has_apikey', true, - 'apikey_has_bindings', true - )); - END IF; - - RETURN v_allowed; - END IF; - END IF; - END IF; - - -- User permission check - IF v_effective_user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission( - public.rbac_principal_user(), v_effective_user_id, - p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id - ); - END IF; - - -- Fallback: apikey without explicit bindings - IF NOT v_allowed AND v_api_key.id IS NOT NULL THEN - v_apikey_principal := v_api_key.rbac_id; - - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission( - public.rbac_principal_apikey(), v_apikey_principal, - p_permission_key, v_effective_org_id, v_effective_app_id, p_channel_id - ); - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id, - 'org_id', v_effective_org_id, - 'app_id', v_effective_app_id, - 'channel_id', p_channel_id, - 'has_apikey', p_apikey IS NOT NULL - )); - END IF; - - RETURN v_allowed; - - ELSE - -- Legacy path - v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); - - IF v_legacy_right IS NULL THEN - PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( - 'permission', p_permission_key, - 'user_id', v_effective_user_id - )); - RETURN false; - END IF; - - IF p_apikey IS NOT NULL AND v_effective_app_id IS NOT NULL THEN - RETURN public.has_app_right_apikey(v_effective_app_id, v_legacy_right, v_effective_user_id, p_apikey); - ELSIF v_effective_app_id IS NOT NULL THEN - RETURN public.has_app_right_userid(v_effective_app_id, v_legacy_right, v_effective_user_id); - ELSE - RETURN public.check_min_rights_legacy_no_password_policy(v_legacy_right, v_effective_user_id, v_effective_org_id, v_effective_app_id, p_channel_id); - END IF; - END IF; -END; -$$; - -ALTER FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("text", "uuid", "uuid", character varying, bigint, "text") TO "service_role"; diff --git a/supabase/migrations/20260427175506_temporary_cli_apps_list_anon_helper_grants.sql b/supabase/migrations/20260427175506_temporary_cli_apps_list_anon_helper_grants.sql deleted file mode 100644 index d2461ae270..0000000000 --- a/supabase/migrations/20260427175506_temporary_cli_apps_list_anon_helper_grants.sql +++ /dev/null @@ -1,50 +0,0 @@ --- Temporary compatibility fix for the published CLI `app list` flow. --- Sunset: remove these grants in cleanup migration --- `remove_temporary_cli_apps_anon_helper_grants` once the published CLI --- switches `app list` to the RBAC-aware wrappers from --- `20260427144323_cli_rbac_permission_wrappers.sql` --- (`get_accessible_apps_for_apikey_v2()` / `cli_check_permission()`). --- --- The currently published CLI still does legacy anonymous PostgREST auth checks --- before issuing a direct `GET /rest/v1/apps` request with the `capgkey` --- header. The `public.apps` SELECT policy for `anon` / `authenticated` is: --- --- public.check_min_rights( --- 'read'::public.user_min_right, --- public.get_identity_org_appid( --- '{read,upload,write,all}'::public.key_mode[], --- owner_org, --- app_id --- ), --- owner_org, --- app_id, --- NULL::bigint --- ) --- --- That policy makes each helper below part of the anonymous table read: --- - public.get_apikey_header() --- Extracts `capgkey` from `request.headers` so RLS helpers can see the API --- key on an anonymous PostgREST request. --- - public.is_apikey_expired(timestamp with time zone) --- Called by `get_identity_org_appid()` and by the RBAC API-key branch inside --- `check_min_rights()` to reject expired keys before identity or permission --- checks continue. --- - public.get_identity_org_appid(public.key_mode[], uuid, character varying) --- Called directly by the `public.apps` SELECT policy to convert the --- anonymous request plus `capgkey` into the API-key owner identity after --- mode, org, and app-scope checks pass. --- - public.check_min_rights(public.user_min_right, uuid, uuid, character --- varying, bigint) --- Called directly by the `public.apps` SELECT policy to enforce `read` --- permission for that derived identity. On RBAC orgs it also re-reads the --- API key to evaluate direct API-key principal grants and org/app --- restrictions. --- --- Until the CLI switches `app list` to the RBAC-aware wrappers, removing any --- of these anon grants breaks the anonymous `public.apps` read even when the --- key itself is valid. - -GRANT EXECUTE ON FUNCTION public.get_apikey_header() TO anon; -GRANT EXECUTE ON FUNCTION public.is_apikey_expired(timestamp with time zone) TO anon; -GRANT EXECUTE ON FUNCTION public.get_identity_org_appid(public.key_mode[], uuid, character varying) TO anon; -GRANT EXECUTE ON FUNCTION public.check_min_rights(public.user_min_right, uuid, uuid, character varying, bigint) TO anon; diff --git a/supabase/migrations/20260429094653_restore_deleted_account_recovery.sql b/supabase/migrations/20260429094653_restore_deleted_account_recovery.sql deleted file mode 100644 index a3ed8d43d4..0000000000 --- a/supabase/migrations/20260429094653_restore_deleted_account_recovery.sql +++ /dev/null @@ -1,52 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."restore_deleted_account"() RETURNS "void" -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - auth_uid uuid; - auth_email text; - last_sign_in_at_ts timestamptz; - hashed_email text; - restored_account_id uuid; -BEGIN - SELECT "auth"."uid"() INTO auth_uid; - IF auth_uid IS NULL THEN - RAISE EXCEPTION 'not_authenticated' USING ERRCODE = '42501'; - END IF; - - SELECT "email", "last_sign_in_at" - INTO auth_email, last_sign_in_at_ts - FROM "auth"."users" - WHERE "id" = auth_uid; - - IF last_sign_in_at_ts IS NULL OR last_sign_in_at_ts < NOW() - INTERVAL '5 minutes' THEN - RAISE EXCEPTION 'reauth_required' USING ERRCODE = 'P0001'; - END IF; - - DELETE FROM "public"."to_delete_accounts" - WHERE "account_id" = auth_uid - AND "removal_date" > NOW() - AND "removal_date" <= NOW() + INTERVAL '30 days' - RETURNING "account_id" INTO restored_account_id; - - IF restored_account_id IS NULL THEN - RAISE EXCEPTION 'restore_window_expired' USING ERRCODE = 'P0001'; - END IF; - - IF auth_email IS NOT NULL AND auth_email <> '' THEN - hashed_email := "encode"("extensions"."digest"(auth_email::text, 'sha256'::text), 'hex'::text); - - DELETE FROM "public"."deleted_account" - WHERE "email" = hashed_email; - END IF; -END; -$$; - -ALTER FUNCTION "public"."restore_deleted_account"() OWNER TO "postgres"; - -COMMENT ON FUNCTION "public"."restore_deleted_account"() IS 'Restore the authenticated user account while still inside the delayed deletion window. Requires a recent sign-in.'; - -REVOKE ALL ON FUNCTION "public"."restore_deleted_account"() FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."restore_deleted_account"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."restore_deleted_account"() FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."restore_deleted_account"() TO "authenticated"; diff --git a/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql b/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql deleted file mode 100644 index 8ba2dff1f6..0000000000 --- a/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Enable RBAC for all existing organizations. --- Uses rbac_enable_for_org() to properly backfill role_bindings from org_users --- before flipping the use_new_rbac flag. --- --- Rollback (if critical issues are discovered): --- UPDATE "public"."orgs" SET "use_new_rbac" = false WHERE "use_new_rbac" = true; --- Note: role_bindings created by this migration will remain but become unused --- when the flag is false. They do not need to be deleted for a safe rollback. -DO $$ -DECLARE - v_org_id uuid; - v_result jsonb; -BEGIN - FOR v_org_id IN - SELECT id FROM "public"."orgs" WHERE "use_new_rbac" = false - LOOP - v_result := "public"."rbac_enable_for_org"(v_org_id, NULL); - END LOOP; -END $$; diff --git a/supabase/migrations/20260430145247_validate_org_security_settings.sql b/supabase/migrations/20260430145247_validate_org_security_settings.sql deleted file mode 100644 index bd89fa89b0..0000000000 --- a/supabase/migrations/20260430145247_validate_org_security_settings.sql +++ /dev/null @@ -1,32 +0,0 @@ -UPDATE "public"."orgs" -SET "max_apikey_expiration_days" = NULL -WHERE "max_apikey_expiration_days" IS NOT NULL - AND ( - "max_apikey_expiration_days" < 1 - OR "max_apikey_expiration_days" > 365 - ); - -UPDATE "public"."orgs" -SET "required_encryption_key" = NULL -WHERE "required_encryption_key" IS NOT NULL - AND length("required_encryption_key") NOT IN (20, 21); - -ALTER TABLE "public"."orgs" -DROP CONSTRAINT IF EXISTS "orgs_max_apikey_expiration_days_valid"; - -ALTER TABLE "public"."orgs" -ADD CONSTRAINT "orgs_max_apikey_expiration_days_valid" -CHECK ( - "max_apikey_expiration_days" IS NULL - OR "max_apikey_expiration_days" BETWEEN 1 AND 365 -); - -ALTER TABLE "public"."orgs" -DROP CONSTRAINT IF EXISTS "orgs_required_encryption_key_valid"; - -ALTER TABLE "public"."orgs" -ADD CONSTRAINT "orgs_required_encryption_key_valid" -CHECK ( - "required_encryption_key" IS NULL - OR length("required_encryption_key") IN (20, 21) -); diff --git a/supabase/migrations/20260430145518_enforce_check_min_rights_app_org_scope.sql b/supabase/migrations/20260430145518_enforce_check_min_rights_app_org_scope.sql deleted file mode 100644 index 60c2b8df1d..0000000000 --- a/supabase/migrations/20260430145518_enforce_check_min_rights_app_org_scope.sql +++ /dev/null @@ -1,143 +0,0 @@ --- Enforce that app-scoped permission checks cannot be authorized through a foreign org_id. - -CREATE OR REPLACE FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_allowed boolean := false; - v_perm text; - v_scope text; - v_apikey text; - v_apikey_principal uuid; - v_use_rbac boolean; - v_effective_org_id uuid := org_id; - v_app_owner_org uuid; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; - api_key record; -BEGIN - -- Existing apps are always authorized in the app owner's org scope. - -- Keep nonexistent apps on the caller org so API handlers can still return their - -- own not-found errors after a valid org-level check. - IF app_id IS NOT NULL THEN - SELECT owner_org INTO v_app_owner_org - FROM public.apps - WHERE public.apps.app_id = check_min_rights.app_id - LIMIT 1; - - IF v_app_owner_org IS NOT NULL THEN - IF v_effective_org_id IS NOT NULL AND v_effective_org_id IS DISTINCT FROM v_app_owner_org THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APP_ORG_MISMATCH', jsonb_build_object( - 'org_id', v_effective_org_id, - 'app_owner_org', v_app_owner_org, - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - - v_effective_org_id := v_app_owner_org; - END IF; - END IF; - - -- Derive org from channel when not provided to honor org-level flag and scoping. - IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id FROM public.channels WHERE public.channels.id = channel_id LIMIT 1; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id; - IF v_org_enforcing_2fa = true AND (user_id IS NULL OR NOT public.has_2fa_enabled(user_id)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - IF NOT v_use_rbac THEN - RETURN public.check_min_rights_legacy(min_right, user_id, COALESCE(org_id, v_effective_org_id), app_id, channel_id); - END IF; - - IF channel_id IS NOT NULL THEN - v_scope := public.rbac_scope_channel(); - ELSIF app_id IS NOT NULL THEN - v_scope := public.rbac_scope_app(); - ELSE - v_scope := public.rbac_scope_org(); - END IF; - - v_perm := public.rbac_permission_for_legacy(min_right, v_scope); - - IF user_id IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_user(), user_id, v_perm, v_effective_org_id, app_id, channel_id); - END IF; - - -- Also consider apikey principal when RBAC is enabled (API keys can hold roles directly). - IF NOT v_allowed THEN - SELECT public.get_apikey_header() INTO v_apikey; - IF v_apikey IS NOT NULL THEN - -- Enforce org/app scoping before using the apikey RBAC principal. - SELECT * FROM public.find_apikey_by_value(v_apikey) INTO api_key; - IF api_key.id IS NOT NULL THEN - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id, 'org_id', v_effective_org_id, 'app_id', app_id)); - ELSIF v_effective_org_id IS NULL THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_NO_ORG', jsonb_build_object('app_id', app_id)); - ELSIF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 AND NOT (v_effective_org_id = ANY(api_key.limited_to_orgs)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_ORG_RESTRICT', jsonb_build_object('org_id', v_effective_org_id, 'app_id', app_id)); - ELSIF app_id IS NOT NULL AND api_key.limited_to_apps IS DISTINCT FROM '{}' AND NOT (app_id = ANY(api_key.limited_to_apps)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_APP_RESTRICT', jsonb_build_object('org_id', v_effective_org_id, 'app_id', app_id)); - ELSE - v_apikey_principal := api_key.rbac_id; - IF v_apikey_principal IS NOT NULL THEN - v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, v_perm, v_effective_org_id, app_id, channel_id); - END IF; - END IF; - END IF; - END IF; - END IF; - - IF NOT v_allowed THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_RBAC', jsonb_build_object('org_id', COALESCE(org_id, v_effective_org_id), 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id, 'scope', v_scope, 'perm', v_perm)); - END IF; - - RETURN v_allowed; -END; -$$; - -ALTER FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "service_role"; diff --git a/supabase/migrations/20260501162433_fix_storage_cleanup_counts.sql b/supabase/migrations/20260501162433_fix_storage_cleanup_counts.sql deleted file mode 100644 index 927a2c901c..0000000000 --- a/supabase/migrations/20260501162433_fix_storage_cleanup_counts.sql +++ /dev/null @@ -1,52 +0,0 @@ --- Keep deleted bundle metadata out of the admin storage trend. --- Physical R2 cleanup is asynchronous, but this metric is used for active bundle storage. -CREATE OR REPLACE FUNCTION "public"."total_bundle_storage_bytes"() RETURNS bigint - LANGUAGE "sql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ - SELECT ( - -- Sum bundle sizes only for active app versions. - COALESCE( - ( - SELECT SUM(avm.size) - FROM public.app_versions_meta avm - INNER JOIN public.app_versions av ON av.id = avm.id - WHERE av.deleted = false - ), - 0 - ) + - -- Sum manifest file sizes only for active app versions. - COALESCE( - ( - SELECT SUM(m.file_size) - FROM public.manifest m - WHERE EXISTS ( - SELECT 1 - FROM public.app_versions av - WHERE av.id = m.app_version_id - AND av.deleted = false - ) - ), - 0 - ) - )::bigint; -$$; - -ALTER FUNCTION "public"."total_bundle_storage_bytes"() OWNER TO "postgres"; - -COMMENT ON FUNCTION "public"."total_bundle_storage_bytes"() IS 'Returns active bundle storage in bytes including bundle sizes (app_versions_meta.size) and manifest file sizes for non-deleted app versions.'; - -REVOKE ALL ON FUNCTION "public"."total_bundle_storage_bytes"() FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."total_bundle_storage_bytes"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."total_bundle_storage_bytes"() FROM "authenticated"; -REVOKE ALL ON FUNCTION "public"."total_bundle_storage_bytes"() FROM "service_role"; -GRANT EXECUTE ON FUNCTION "public"."total_bundle_storage_bytes"() TO "service_role"; - --- The high-frequency queue previously used the default 950-message batch for every --- queue, which can fan out hundreds of S3 deletes at once during retention cleanup. -UPDATE public.cron_tasks -SET - batch_size = 100, - updated_at = now() -WHERE name = 'high_frequency_queues' - AND (batch_size IS NULL OR batch_size > 100); diff --git a/supabase/migrations/20260501200000_remove_sso_enabled_flag.sql b/supabase/migrations/20260501200000_remove_sso_enabled_flag.sql deleted file mode 100644 index ff6f9014a3..0000000000 --- a/supabase/migrations/20260501200000_remove_sso_enabled_flag.sql +++ /dev/null @@ -1,425 +0,0 @@ --- Migration: Remove sso_enabled feature flag --- SSO is now always available: enterprise orgs get the form, others get an upgrade prompt. --- The flag is replaced by the enterprise plan check already enforced in requireEnterprisePlan. - --- 1) Update check_domain_sso: SSO is active when a provider is active (no org flag needed) -CREATE OR REPLACE FUNCTION public.check_domain_sso(p_domain text) -RETURNS TABLE ( - has_sso boolean, - provider_id text, - org_id uuid -) -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = '' -AS $$ - SELECT - true AS has_sso, - sp.provider_id, - sp.org_id - FROM public.sso_providers AS sp - JOIN public.orgs AS o ON o.id = sp.org_id - WHERE sp."domain" = lower(btrim(p_domain)) - AND sp.status = 'active' - LIMIT 1; -$$; - -ALTER FUNCTION public.check_domain_sso(text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION public.check_domain_sso(text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.check_domain_sso(text) TO anon; -GRANT EXECUTE ON FUNCTION public.check_domain_sso(text) TO authenticated; -GRANT EXECUTE ON FUNCTION public.check_domain_sso(text) TO service_role; - --- 2) Update get_sso_enforcement_by_domain: same, no org flag -CREATE OR REPLACE FUNCTION "public"."get_sso_enforcement_by_domain"("p_domain" text) -RETURNS TABLE("org_id" uuid, "enforce_sso" boolean) -LANGUAGE "sql" -STABLE -SECURITY DEFINER -SET "search_path" TO '' -AS $$ - SELECT - sp.org_id, - sp.enforce_sso - FROM "public"."sso_providers" sp - JOIN "public"."orgs" o ON o.id = sp.org_id - WHERE sp.domain = lower(btrim(p_domain)) - AND sp.status = 'active' - LIMIT 1; -$$; - -ALTER FUNCTION "public"."get_sso_enforcement_by_domain"(text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) TO anon; -GRANT EXECUTE ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) TO authenticated; -GRANT EXECUTE ON FUNCTION "public"."get_sso_enforcement_by_domain"(text) TO service_role; - --- 3) Update generate_org_on_user_create trigger: remove sso_enabled guard from has_sso check -CREATE OR REPLACE FUNCTION "public"."generate_org_on_user_create" () RETURNS "trigger" LANGUAGE "plpgsql" -SET - search_path = '' SECURITY DEFINER AS $$ -DECLARE - org_record record; - has_sso boolean; - user_provider text; -BEGIN - SELECT raw_app_meta_data->>'provider' - INTO user_provider - FROM auth.users - WHERE id = NEW.id; - - SELECT EXISTS ( - SELECT 1 FROM public.sso_providers sp - JOIN public.orgs o ON o.id = sp.org_id - WHERE sp.domain = lower(btrim(split_part(NEW.email, '@', 2))) - AND sp.status = 'active' - ) INTO has_sso; - - -- Skip org creation only for genuine SAML SSO logins on SSO-managed domains. - IF NOT (user_provider ~ '^sso:' AND has_sso) THEN - INSERT INTO public.orgs (created_by, name, management_email) values (NEW.id, format('%s organization', NEW.first_name), NEW.email) RETURNING * INTO org_record; - END IF; - - RETURN NEW; -END $$; - -ALTER FUNCTION public.generate_org_on_user_create() OWNER TO postgres; - --- 4) Recreate get_orgs_v7 without sso_enabled in the return type --- Must DROP first because CREATE OR REPLACE cannot change return type. --- Drop no-args overload first (it depends on the with-args overload). -DROP FUNCTION IF EXISTS public.get_orgs_v7(); -DROP FUNCTION IF EXISTS public.get_orgs_v7(userid uuid); - -CREATE FUNCTION public.get_orgs_v7() RETURNS TABLE( - gid uuid, - created_by uuid, - created_at timestamp with time zone, - logo text, - website text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamp with time zone, - subscription_end timestamp with time zone, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - stats_refresh_requested_at timestamp without time zone, - next_stats_update_at timestamp with time zone, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamp with time zone, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $$ -DECLARE - api_key_text text; - api_key record; - user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - user_id := NULL; - - IF api_key_text IS NOT NULL THEN - SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; - - IF api_key IS NULL THEN - PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RAISE EXCEPTION 'API key has expired'; - END IF; - - user_id := api_key.user_id; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(user_id) AS orgs - WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); - RETURN; - END IF; - END IF; - - IF user_id IS NULL THEN - SELECT public.get_identity() INTO user_id; - - IF user_id IS NULL THEN - PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - END IF; - - RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); -END; -$$; - -ALTER FUNCTION public.get_orgs_v7() OWNER TO postgres; -REVOKE ALL ON FUNCTION public.get_orgs_v7() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v7() FROM anon; -REVOKE ALL ON FUNCTION public.get_orgs_v7() FROM authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO anon; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO authenticated; -GRANT ALL ON FUNCTION public.get_orgs_v7() TO service_role; - -CREATE FUNCTION public.get_orgs_v7(userid uuid) RETURNS TABLE( - gid uuid, - created_by uuid, - created_at timestamp with time zone, - logo text, - website text, - name text, - role character varying, - paying boolean, - trial_left integer, - can_use_more boolean, - is_canceled boolean, - app_count bigint, - subscription_start timestamp with time zone, - subscription_end timestamp with time zone, - management_email text, - is_yearly boolean, - stats_updated_at timestamp without time zone, - stats_refresh_requested_at timestamp without time zone, - next_stats_update_at timestamp with time zone, - credit_available numeric, - credit_total numeric, - credit_next_expiration timestamp with time zone, - enforcing_2fa boolean, - "2fa_has_access" boolean, - enforce_hashed_api_keys boolean, - password_policy_config jsonb, - password_has_access boolean, - require_apikey_expiration boolean, - max_apikey_expiration_days integer, - enforce_encrypted_bundles boolean, - required_encryption_key character varying, - use_new_rbac boolean -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO '' -AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) AS cnt - FROM public.apps - GROUP BY owner_org - ), - rbac_roles AS ( - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION ALL - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - rbac_org_roles AS ( - SELECT org_id, (ARRAY_AGG(rbac_roles.name ORDER BY rbac_roles.priority_rank DESC))[1] AS role_name - FROM rbac_roles - GROUP BY org_id - ), - user_orgs AS ( - SELECT ou.org_id - FROM public.org_users ou - WHERE ou.user_id = userid - UNION - SELECT rbac_org_roles.org_id - FROM rbac_org_roles - ), - time_constants AS ( - SELECT - NOW() AS current_time, - date_trunc('MONTH', NOW()) AS current_month_start, -- NOSONAR: migration-local billing anchor - '0 DAYS'::INTERVAL AS zero_day_interval - ), - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 AS preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - CROSS JOIN time_constants tc - WHERE ( - (si.status = 'succeeded' -- NOSONAR: existing stripe_info status contract - AND (si.canceled_at IS NULL OR si.canceled_at > tc.current_time) - AND si.subscription_anchor_end > tc.current_time) - OR si.trial_at > tc.current_time - ) - ), - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) - > tc.current_time - tc.current_month_start - THEN date_trunc('MONTH', tc.current_time - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) - ELSE tc.current_month_start - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) - END AS cycle_start - FROM public.orgs o - CROSS JOIN time_constants tc - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ), - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - public.user_meets_password_policy(userid, o.id) AS password_has_access, - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ) - SELECT - o.id AS gid, - o.created_by, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE o.created_at - END AS created_at, - o.logo, - o.website, - o.name, - CASE - WHEN o.use_new_rbac AND ou.user_right::text LIKE 'invite_%' THEN ou.user_right::varchar - WHEN o.use_new_rbac THEN COALESCE(ror.role_name, ou.rbac_role_name, ou.user_right::varchar) - ELSE COALESCE(ou.user_right::varchar, ror.role_name) - END AS role, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'succeeded', false) -- NOSONAR: existing stripe_info status contract - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE((si.status = 'succeeded' AND si.is_good_plan = true) -- NOSONAR: existing stripe_info status contract - OR (si.trial_at::date - NOW()::date > 0) - OR COALESCE(ucb.available_credits, 0) > 0, false) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'canceled', false) - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - o.stats_refresh_requested_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.available_credits, 0) - END AS credit_available, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.total_credits, 0) - END AS credit_total, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE ucb.next_expiration - END AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key, - o.use_new_rbac - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - LEFT JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id - LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id - LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION public.get_orgs_v7(userid uuid) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.get_orgs_v7(userid uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_orgs_v7(userid uuid) FROM anon; -REVOKE ALL ON FUNCTION public.get_orgs_v7(userid uuid) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(userid uuid) TO postgres; -GRANT EXECUTE ON FUNCTION public.get_orgs_v7(userid uuid) TO service_role; - --- 5) Drop the column — functions no longer reference it -ALTER TABLE public.orgs DROP COLUMN sso_enabled; diff --git a/supabase/migrations/20260502134045_fix_audit_logs_anon_dos.sql b/supabase/migrations/20260502134045_fix_audit_logs_anon_dos.sql deleted file mode 100644 index c7d12018a0..0000000000 --- a/supabase/migrations/20260502134045_fix_audit_logs_anon_dos.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Evaluate audit_logs_allowed_orgs() once per statement instead of once per --- audit_logs row. This keeps API-key access on the normal RLS path while making --- unauthenticated anon requests fail fast with an empty allowed org list. - -DROP POLICY IF EXISTS "Allow select for auth, api keys (super_admin+)" -- noqa: RF05,LT05 -ON public.audit_logs; -DROP POLICY IF EXISTS "Allow select for auth (super_admin+)" -- noqa: RF05 -ON public.audit_logs; - -CREATE POLICY "Allow select for auth, api keys (super_admin+)" -- noqa: RF05,LT05 -ON public.audit_logs -FOR SELECT -TO anon, authenticated -USING ( - org_id = ANY( - COALESCE((SELECT public.audit_logs_allowed_orgs()), '{}'::uuid []) - ) -); diff --git a/supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql b/supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql deleted file mode 100644 index ba41e2f3e5..0000000000 --- a/supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql +++ /dev/null @@ -1,81 +0,0 @@ --- Prevent role updates from bypassing the last org super_admin guard. --- The existing delete trigger blocks deleting the final super_admin binding; --- this companion trigger blocks demoting that final binding through role_id updates. - -CREATE OR REPLACE FUNCTION "public"."prevent_last_super_admin_binding_update"() -RETURNS TRIGGER -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_remaining_count integer; - v_org_exists boolean; -BEGIN - IF OLD.role_id IS NOT DISTINCT FROM NEW.role_id THEN - RETURN NEW; - END IF; - - IF OLD.scope_type != public.rbac_scope_org() THEN - RETURN NEW; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM public.roles r - WHERE r.id = OLD.role_id - AND r.name = public.rbac_role_org_super_admin() - ) THEN - RETURN NEW; - END IF; - - IF EXISTS ( - SELECT 1 - FROM public.roles r - WHERE r.id = NEW.role_id - AND r.name = public.rbac_role_org_super_admin() - ) THEN - RETURN NEW; - END IF; - - SELECT EXISTS( - SELECT 1 - FROM public.orgs - WHERE id = OLD.org_id - ) INTO v_org_exists; - - IF NOT v_org_exists THEN - RETURN NEW; - END IF; - - PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(OLD.org_id::text)); - - SELECT COUNT(*) INTO v_remaining_count - FROM public.role_bindings rb - INNER JOIN public.roles r ON rb.role_id = r.id - WHERE rb.scope_type = public.rbac_scope_org() - AND rb.org_id = OLD.org_id - AND rb.principal_type = public.rbac_principal_user() - AND r.name = public.rbac_role_org_super_admin() - AND rb.id != OLD.id; - - IF v_remaining_count < 1 THEN - RAISE EXCEPTION 'CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING' - USING HINT = 'At least one super_admin binding must remain in the org'; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."prevent_last_super_admin_binding_update"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() FROM "anon"; -REVOKE ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() FROM "authenticated"; -GRANT ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() TO "service_role"; - -DROP TRIGGER IF EXISTS "prevent_last_super_admin_update" ON "public"."role_bindings"; -CREATE TRIGGER "prevent_last_super_admin_update" - BEFORE UPDATE OF "role_id" ON "public"."role_bindings" - FOR EACH ROW - EXECUTE FUNCTION "public"."prevent_last_super_admin_binding_update"(); diff --git a/supabase/migrations/20260502134355_fix_rbac_role_binding_demoted_super_admin.sql b/supabase/migrations/20260502134355_fix_rbac_role_binding_demoted_super_admin.sql deleted file mode 100644 index 44ce588088..0000000000 --- a/supabase/migrations/20260502134355_fix_rbac_role_binding_demoted_super_admin.sql +++ /dev/null @@ -1,160 +0,0 @@ --- Fix GHSA-rvvc-rvxv-qcrh: --- Authorize encrypted-bundle cleanup RPCs through RBAC instead of stale legacy rights. - -CREATE OR REPLACE FUNCTION "public"."count_non_compliant_bundles"( - "org_id" uuid, - "required_key" text DEFAULT NULL -) RETURNS TABLE ( - "non_encrypted_count" bigint, - "wrong_key_count" bigint, - "total_non_compliant" bigint -) -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - non_encrypted bigint := 0; - wrong_key bigint := 0; - caller_user_id uuid; - api_key_text text; -BEGIN - SELECT public.get_identity('{read,upload,write,all}'::public.key_mode[]) INTO caller_user_id; - SELECT public.get_apikey_header() INTO api_key_text; - - IF caller_user_id IS NULL THEN - RAISE EXCEPTION 'Unauthorized: Authentication required'; - END IF; - - -- org.delete is the RBAC/legacy super_admin-equivalent org gate. Using it - -- preserves the previous super_admin-only requirement for this org-wide scan. - IF NOT public.rbac_check_permission_direct( - public.rbac_perm_org_delete(), - caller_user_id, - count_non_compliant_bundles.org_id, - NULL::character varying, - NULL::bigint, - api_key_text - ) THEN - RAISE EXCEPTION 'Unauthorized: Only super_admin can access this function'; - END IF; - - SELECT COUNT(*) INTO non_encrypted - FROM public.app_versions av - INNER JOIN public.apps a ON a.app_id = av.app_id - WHERE a.owner_org = count_non_compliant_bundles.org_id - AND av.deleted = false - AND (av.session_key IS NULL OR av.session_key = ''); - - IF required_key IS NOT NULL AND required_key <> '' THEN - SELECT COUNT(*) INTO wrong_key - FROM public.app_versions av - INNER JOIN public.apps a ON a.app_id = av.app_id - WHERE a.owner_org = count_non_compliant_bundles.org_id - AND av.deleted = false - AND av.session_key IS NOT NULL - AND av.session_key <> '' - AND ( - av.key_id IS NULL - OR av.key_id = '' - -- key_id can store either the 20-char required_key prefix or the full key, so accept both match directions. - OR NOT (av.key_id = LEFT(required_key, 20) OR LEFT(av.key_id, LENGTH(required_key)) = required_key) - ); - END IF; - - RETURN QUERY SELECT non_encrypted, wrong_key, (non_encrypted + wrong_key); -END; -$$; - -ALTER FUNCTION "public"."count_non_compliant_bundles"(uuid, text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."count_non_compliant_bundles"(uuid, text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."count_non_compliant_bundles"(uuid, text) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."count_non_compliant_bundles"(uuid, text) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."delete_non_compliant_bundles"( - "org_id" uuid, - "required_key" text DEFAULT NULL -) RETURNS bigint -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - deleted_count bigint := 0; - bundle_ids bigint[]; - caller_user_id uuid; - api_key_text text; -BEGIN - SELECT public.get_identity('{read,upload,write,all}'::public.key_mode[]) INTO caller_user_id; - SELECT public.get_apikey_header() INTO api_key_text; - - IF caller_user_id IS NULL THEN - RAISE EXCEPTION 'Unauthorized: Authentication required'; - END IF; - - -- org.delete is the RBAC/legacy super_admin-equivalent org gate. Using it - -- preserves the previous super_admin-only requirement for this destructive cleanup. - IF NOT public.rbac_check_permission_direct( - public.rbac_perm_org_delete(), - caller_user_id, - delete_non_compliant_bundles.org_id, - NULL::character varying, - NULL::bigint, - api_key_text - ) THEN - RAISE EXCEPTION 'Unauthorized: Only super_admin can access this function'; - END IF; - - IF required_key IS NULL OR required_key = '' THEN - SELECT ARRAY_AGG(av.id) INTO bundle_ids - FROM public.app_versions av - INNER JOIN public.apps a ON a.app_id = av.app_id - WHERE a.owner_org = delete_non_compliant_bundles.org_id - AND av.deleted = false - AND (av.session_key IS NULL OR av.session_key = ''); - ELSE - SELECT ARRAY_AGG(av.id) INTO bundle_ids - FROM public.app_versions av - INNER JOIN public.apps a ON a.app_id = av.app_id - WHERE a.owner_org = delete_non_compliant_bundles.org_id - AND av.deleted = false - AND ( - (av.session_key IS NULL OR av.session_key = '') - OR ( - av.session_key IS NOT NULL - AND av.session_key <> '' - AND ( - av.key_id IS NULL - OR av.key_id = '' - -- key_id can store either the 20-char required_key prefix or the full key, so accept both match directions. - OR NOT (av.key_id = LEFT(required_key, 20) OR LEFT(av.key_id, LENGTH(required_key)) = required_key) - ) - ) - ); - END IF; - - IF bundle_ids IS NOT NULL AND array_length(bundle_ids, 1) > 0 THEN - UPDATE public.app_versions - SET deleted = true - WHERE id = ANY(bundle_ids); - - deleted_count := array_length(bundle_ids, 1); - - PERFORM public.pg_log('action: DELETED_NON_COMPLIANT_BUNDLES', - jsonb_build_object( - 'org_id', org_id, - 'required_key', required_key, - 'deleted_count', deleted_count, - 'bundle_ids', bundle_ids, - 'caller_user_id', caller_user_id - )); - END IF; - - RETURN deleted_count; -END; -$$; - -ALTER FUNCTION "public"."delete_non_compliant_bundles"(uuid, text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."delete_non_compliant_bundles"(uuid, text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."delete_non_compliant_bundles"(uuid, text) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."delete_non_compliant_bundles"(uuid, text) TO "service_role"; diff --git a/supabase/migrations/20260504174812_fix_build_time_daily_aggregation.sql b/supabase/migrations/20260504174812_fix_build_time_daily_aggregation.sql deleted file mode 100644 index 9492821f5f..0000000000 --- a/supabase/migrations/20260504174812_fix_build_time_daily_aggregation.sql +++ /dev/null @@ -1,231 +0,0 @@ --- Fix: build_logs was never aggregated into daily_build_time, causing build --- time usage to always report 0 in billing/quota checks. --- --- This migration: --- 1. Adds app_id to build_logs (required for daily_build_time PK (app_id, date)) --- 2. Backfills app_id from build_requests using build_id = builder_job_id --- 3. Replaces record_build_time() to accept and store app_id --- 4. Adds a trigger on build_logs that upserts into daily_build_time --- 5. Backfills daily_build_time from existing build_logs data - --- ============================================================================ --- Step 1: Add app_id column to build_logs --- ============================================================================ -ALTER TABLE "public"."build_logs" - ADD COLUMN "app_id" character varying; - --- FK to apps: use SET NULL to preserve raw build-time history for billing --- reconciliation even after app deletion (org_id still identifies the owner). -ALTER TABLE "public"."build_logs" - ADD CONSTRAINT "build_logs_app_id_fkey" - FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE SET NULL; - --- Index for aggregation queries -CREATE INDEX IF NOT EXISTS "idx_build_logs_app_id_created_at" - ON "public"."build_logs" ("app_id", "created_at"); - --- ============================================================================ --- Step 2: Backfill app_id from build_requests --- ============================================================================ -UPDATE "public"."build_logs" bl -SET "app_id" = br."app_id" -FROM "public"."build_requests" br -WHERE bl."build_id" = br."builder_job_id" - AND bl."org_id" = br."owner_org" - AND bl."app_id" IS NULL; - --- Warn if any build_logs rows remain without app_id (orphaned legacy data). --- These rows won't appear in daily_build_time but are preserved for audit via org_id. --- We use WARNING instead of EXCEPTION because orphaned historical rows should not --- block deployment; all future inserts always have app_id set via record_build_time(). -DO $$ -DECLARE - v_count bigint; -BEGIN - SELECT count(*) INTO v_count FROM public.build_logs WHERE app_id IS NULL; - IF v_count > 0 THEN - RAISE WARNING 'build_logs backfill: % rows remain without app_id (orphaned legacy data)', v_count; - END IF; -END; -$$; - --- ============================================================================ --- Step 3: Replace record_build_time() to accept p_app_id --- ============================================================================ -CREATE OR REPLACE FUNCTION "public"."record_build_time"( - "p_org_id" "uuid", - "p_user_id" "uuid", - "p_build_id" character varying, - "p_platform" character varying, - "p_build_time_unit" bigint, - "p_app_id" character varying -) RETURNS "uuid" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_build_log_id uuid; - v_multiplier numeric; - v_billable_seconds bigint; - v_caller_user_id uuid; - v_invoking_role text; -BEGIN - -- Reject NULL/empty app_id: daily_build_time is keyed by app_id - IF p_app_id IS NULL OR p_app_id = '' THEN - RAISE EXCEPTION 'INVALID_APP_ID'; - END IF; - - -- Verify the app belongs to the org to prevent wrong attribution - IF NOT EXISTS ( - SELECT 1 FROM public.apps - WHERE app_id = p_app_id AND owner_org = p_org_id - ) THEN - RAISE EXCEPTION 'INVALID_APP_ID'; - END IF; - - SELECT NULLIF(current_setting('role', true), '') INTO v_invoking_role; - - -- Service-role callers do not have JWT/API key context and pass p_user_id directly. - -- Keep this path for internal calls from backend services. - IF v_invoking_role = 'service_role' THEN - v_caller_user_id := p_user_id; - ELSE - -- Use get_identity_org_appid (not get_identity_org_allowed) per project guidelines, - -- since we have app_id available for scoped authorization. - v_caller_user_id := public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode[], - p_org_id, - p_app_id - ); - END IF; - - IF v_caller_user_id IS NULL THEN - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - IF NOT public.check_min_rights( - 'write'::public.user_min_right, - v_caller_user_id, - p_org_id, - p_app_id, - NULL::bigint - ) THEN - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - IF p_build_time_unit < 0 THEN - RAISE EXCEPTION 'Build time cannot be negative'; - END IF; - IF p_platform NOT IN ('ios', 'android') THEN - RAISE EXCEPTION 'Invalid platform: %', p_platform; - END IF; - - -- Apply platform multiplier - v_multiplier := CASE p_platform - WHEN 'ios' THEN 2 - WHEN 'android' THEN 1 - ELSE 1 - END; - - v_billable_seconds := (p_build_time_unit * v_multiplier)::bigint; - - INSERT INTO public.build_logs (org_id, user_id, build_id, platform, build_time_unit, billable_seconds, app_id) - VALUES (p_org_id, v_caller_user_id, p_build_id, p_platform, p_build_time_unit, v_billable_seconds, p_app_id) - ON CONFLICT (build_id, org_id) DO UPDATE SET - user_id = EXCLUDED.user_id, - platform = EXCLUDED.platform, - build_time_unit = EXCLUDED.build_time_unit, - billable_seconds = EXCLUDED.billable_seconds, - app_id = EXCLUDED.app_id - RETURNING id INTO v_build_log_id; - - RETURN v_build_log_id; -END; -$$; - -ALTER FUNCTION "public"."record_build_time"("p_org_id" "uuid", "p_user_id" "uuid", "p_build_id" character varying, "p_platform" character varying, "p_build_time_unit" bigint, "p_app_id" character varying) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."record_build_time"("p_org_id" "uuid", "p_user_id" "uuid", "p_build_id" character varying, "p_platform" character varying, "p_build_time_unit" bigint, "p_app_id" character varying) FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."record_build_time"("p_org_id" "uuid", "p_user_id" "uuid", "p_build_id" character varying, "p_platform" character varying, "p_build_time_unit" bigint, "p_app_id" character varying) TO "service_role"; - --- Drop the old 5-param overload to avoid ambiguity -DROP FUNCTION IF EXISTS "public"."record_build_time"("uuid", "uuid", character varying, character varying, bigint); - --- ============================================================================ --- Step 4: Trigger function to aggregate build_logs into daily_build_time --- ============================================================================ -CREATE OR REPLACE FUNCTION "public"."aggregate_build_log_to_daily"() -RETURNS trigger -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_old_date date; -BEGIN - -- Handle DELETE: subtract old values and return - IF TG_OP = 'DELETE' THEN - IF OLD.app_id IS NOT NULL THEN - v_old_date := (OLD.created_at AT TIME ZONE 'UTC')::date; - UPDATE public.daily_build_time - SET build_time_unit = GREATEST(build_time_unit - OLD.billable_seconds, 0), - build_count = GREATEST(build_count - 1, 0) - WHERE app_id = OLD.app_id AND date = v_old_date; - END IF; - RETURN OLD; - END IF; - - -- Handle UPDATE: subtract old values from the old bucket (if old had app_id) - IF TG_OP = 'UPDATE' AND OLD.app_id IS NOT NULL THEN - v_old_date := (OLD.created_at AT TIME ZONE 'UTC')::date; - UPDATE public.daily_build_time - SET build_time_unit = GREATEST(build_time_unit - OLD.billable_seconds, 0), - build_count = GREATEST(build_count - 1, 0) - WHERE app_id = OLD.app_id AND date = v_old_date; - END IF; - - -- Handle INSERT/UPDATE: add new values (only if new app_id is set) - IF NEW.app_id IS NOT NULL THEN - INSERT INTO public.daily_build_time (app_id, date, build_time_unit, build_count) - VALUES (NEW.app_id, (NEW.created_at AT TIME ZONE 'UTC')::date, NEW.billable_seconds, 1) - ON CONFLICT (app_id, date) DO UPDATE SET - build_time_unit = public.daily_build_time.build_time_unit + EXCLUDED.build_time_unit, - build_count = public.daily_build_time.build_count + EXCLUDED.build_count; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."aggregate_build_log_to_daily"() OWNER TO "postgres"; - --- Attach the trigger -CREATE TRIGGER "aggregate_build_log_to_daily_trigger" - AFTER INSERT OR UPDATE OR DELETE ON "public"."build_logs" - FOR EACH ROW - EXECUTE FUNCTION "public"."aggregate_build_log_to_daily"(); - --- ============================================================================ --- Step 5: Backfill daily_build_time from existing build_logs --- ============================================================================ --- Clear any stale/test data in daily_build_time and rebuild from build_logs. --- Delete all existing rows first so the subsequent INSERT truly rebuilds from source. -DELETE FROM public.daily_build_time; - --- Disable the trigger during backfill to avoid double-counting -ALTER TABLE "public"."build_logs" DISABLE TRIGGER "aggregate_build_log_to_daily_trigger"; - -INSERT INTO public.daily_build_time (app_id, date, build_time_unit, build_count) -SELECT - bl.app_id, - (bl.created_at AT TIME ZONE 'UTC')::date AS date, - SUM(bl.billable_seconds), - COUNT(*) -FROM public.build_logs bl -WHERE bl.app_id IS NOT NULL -GROUP BY bl.app_id, (bl.created_at AT TIME ZONE 'UTC')::date -ON CONFLICT (app_id, date) DO UPDATE SET - build_time_unit = EXCLUDED.build_time_unit, - build_count = EXCLUDED.build_count; - --- Re-enable the trigger after backfill -ALTER TABLE "public"."build_logs" ENABLE TRIGGER "aggregate_build_log_to_daily_trigger"; diff --git a/supabase/migrations/20260505163356_apikey_nullable_mode_with_bindings.sql b/supabase/migrations/20260505163356_apikey_nullable_mode_with_bindings.sql deleted file mode 100644 index 3592d3fab4..0000000000 --- a/supabase/migrations/20260505163356_apikey_nullable_mode_with_bindings.sql +++ /dev/null @@ -1,213 +0,0 @@ --- 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.'; - -CREATE OR REPLACE FUNCTION "public"."get_identity_for_apikey_creation"() RETURNS "uuid" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key public.apikeys%ROWTYPE; -BEGIN - SELECT auth.uid() INTO auth_uid; - - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT public.get_apikey_header() INTO api_key_text; - - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - SELECT * INTO api_key - FROM public.find_apikey_by_value(api_key_text) - LIMIT 1; - - IF api_key.id IS NULL THEN - RETURN NULL; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - PERFORM public.pg_log('deny: APIKEY_CREATE_API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); - RETURN NULL; - END IF; - - IF api_key.mode IS DISTINCT FROM 'all'::public.key_mode THEN - PERFORM public.pg_log('deny: APIKEY_CREATE_API_KEY_MODE', jsonb_build_object('key_id', api_key.id, 'mode', api_key.mode)); - RETURN NULL; - END IF; - - IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 - OR COALESCE(array_length(api_key.limited_to_apps, 1), 0) > 0 - THEN - PERFORM public.pg_log('deny: APIKEY_CREATE_LIMITED_API_KEY', jsonb_build_object('key_id', api_key.id)); - RETURN NULL; - END IF; - - RETURN api_key.user_id; -END; -$$; - -ALTER FUNCTION "public"."get_identity_for_apikey_creation"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_identity_for_apikey_creation"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_identity_for_apikey_creation"() TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_identity_for_apikey_creation"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_identity_for_apikey_creation"() TO "service_role"; - -DROP POLICY IF EXISTS "Allow owner to insert own apikeys" ON "public"."apikeys"; -CREATE POLICY "Allow owner to insert own apikeys" ON "public"."apikeys" -FOR INSERT -TO "anon", "authenticated" -WITH CHECK ( - "mode" IS NOT NULL - AND "user_id" = (SELECT public.get_identity_for_apikey_creation()) -); - -DROP POLICY IF EXISTS "Allow owner to update own apikeys" ON "public"."apikeys"; -CREATE POLICY "Allow owner to update own apikeys" ON "public"."apikeys" -FOR UPDATE -TO "anon", "authenticated" -USING ( - "user_id" = (SELECT public.get_identity_for_apikey_creation()) -) -WITH CHECK ( - "user_id" = (SELECT public.get_identity_for_apikey_creation()) -); - --- Public RPC for legacy mode-based keys. RBAC-managed keys (mode IS NULL) --- must be created by the Edge endpoint so the key and role_bindings are created --- together in one transaction and cannot be bypassed through direct PostgREST. -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; - v_plain_key text; - v_apikey public.apikeys; -BEGIN - IF p_mode IS NULL THEN - RAISE EXCEPTION 'RBAC_MANAGED_APIKEY_REQUIRES_BINDINGS'; - END IF; - - SELECT public.get_identity_for_apikey_creation() INTO v_user_id; - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'No authentication provided'; - END IF; - - 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 ( - v_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; -$$; - -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; -$$; - -ALTER FUNCTION "public"."create_hashed_apikey"( - "public"."key_mode", "text", "uuid"[], "text"[], timestamp with time zone -) OWNER TO "postgres"; -ALTER FUNCTION "public"."create_hashed_apikey_for_user"( - "uuid", "public"."key_mode", "text", "uuid"[], "text"[], timestamp with time zone -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."create_hashed_apikey_for_user"( - "uuid", "public"."key_mode", "text", "uuid"[], "text"[], timestamp with time zone -) FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."create_hashed_apikey_for_user"( - "uuid", "public"."key_mode", "text", "uuid"[], "text"[], timestamp with time zone -) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."create_hashed_apikey_for_user"( - "uuid", "public"."key_mode", "text", "uuid"[], "text"[], timestamp with time zone -) FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."create_hashed_apikey_for_user"( - "uuid", "public"."key_mode", "text", "uuid"[], "text"[], timestamp with time zone -) TO "service_role"; diff --git a/supabase/migrations/20260505193449_harden_encrypted_bundle_update_invariant.sql b/supabase/migrations/20260505193449_harden_encrypted_bundle_update_invariant.sql deleted file mode 100644 index b8b2c80e84..0000000000 --- a/supabase/migrations/20260505193449_harden_encrypted_bundle_update_invariant.sql +++ /dev/null @@ -1,161 +0,0 @@ --- Keep encrypted-bundle enforcement consistent for both INSERT and direct --- UPDATE paths. The function name is kept for compatibility with the existing --- trigger. -CREATE OR REPLACE FUNCTION public.check_encrypted_bundle_on_insert() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - org_id uuid; - org_enforcing boolean; - org_required_key varchar(21); - bundle_is_encrypted boolean; - bundle_key_id varchar(20); - bundle_was_ready boolean; -BEGIN - IF TG_OP = 'UPDATE' THEN - bundle_was_ready := OLD.storage_provider IS DISTINCT FROM 'r2-direct'; - - IF bundle_was_ready - AND ( - NEW.name IS DISTINCT FROM OLD.name - OR NEW.app_id IS DISTINCT FROM OLD.app_id - OR NEW.session_key IS DISTINCT FROM OLD.session_key - OR NEW.key_id IS DISTINCT FROM OLD.key_id - OR NEW.storage_provider IS DISTINCT FROM OLD.storage_provider - OR NEW.r2_path IS DISTINCT FROM OLD.r2_path - OR NEW.external_url IS DISTINCT FROM OLD.external_url - OR NEW.checksum IS DISTINCT FROM OLD.checksum - OR NEW.manifest IS DISTINCT FROM OLD.manifest - OR NEW.native_packages IS DISTINCT FROM OLD.native_packages - ) - THEN - PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', - jsonb_build_object( - 'org_id', OLD.owner_org, - 'app_id', OLD.app_id, - 'version_name', OLD.name, - 'user_id', OLD.user_id, - 'old_storage_provider', OLD.storage_provider, - 'new_storage_provider', NEW.storage_provider, - 'reason', 'bundle_ready' - )); - RAISE EXCEPTION '%', - 'bundle_already_ready: Bundle content cannot be changed ' - || 'after upload is complete. Upload a new bundle instead.'; - END IF; - END IF; - - -- Derive org_id from NEW.app_id first because - -- force_valid_owner_org_app_versions runs after this trigger. - SELECT apps.owner_org INTO org_id - FROM public.apps - WHERE apps.app_id = NEW.app_id; - - IF org_id IS NULL THEN - org_id := NEW.owner_org; - END IF; - - -- If org not found, allow the existing foreign-key/owner checks to fail. - IF org_id IS NULL THEN - RETURN NEW; - END IF; - - SELECT enforce_encrypted_bundles, required_encryption_key - INTO org_enforcing, org_required_key - FROM public.orgs - WHERE id = org_id; - - IF org_enforcing IS NULL OR org_enforcing = false THEN - RETURN NEW; - END IF; - - bundle_is_encrypted := public.is_bundle_encrypted(NEW.session_key); - bundle_key_id := NULLIF(btrim(NEW.key_id), '')::varchar(20); - - IF NOT bundle_is_encrypted THEN - PERFORM public.pg_log('deny: ORG_REQUIRES_ENCRYPTED_BUNDLES_TRIGGER', - jsonb_build_object( - 'org_id', org_id, - 'app_id', NEW.app_id, - 'version_name', NEW.name, - 'user_id', NEW.user_id, - 'reason', 'not_encrypted' - )); - RAISE EXCEPTION '%', - 'encryption_required: This organization requires all bundles to be ' - || 'encrypted. Please upload an encrypted bundle with a session_key.'; - END IF; - - IF org_required_key IS NOT NULL AND org_required_key <> '' THEN - IF bundle_key_id IS NULL THEN - PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', - jsonb_build_object( - 'org_id', org_id, - 'app_id', NEW.app_id, - 'version_name', NEW.name, - 'user_id', NEW.user_id, - 'required_key', org_required_key, - 'bundle_key_id', bundle_key_id, - 'reason', 'missing_key_id' - )); - RAISE EXCEPTION '%', - 'encryption_key_required: This organization requires bundles to be ' - || 'encrypted with a specific key. The uploaded bundle does not have ' - || 'a key_id.'; - END IF; - - -- key_id is 20 chars and required_encryption_key may be 20 or 21 chars. - IF NOT ( - bundle_key_id = LEFT(org_required_key, 20) - OR LEFT(bundle_key_id, LENGTH(org_required_key)) = org_required_key - ) THEN - PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', - jsonb_build_object( - 'org_id', org_id, - 'app_id', NEW.app_id, - 'version_name', NEW.name, - 'user_id', NEW.user_id, - 'required_key', org_required_key, - 'bundle_key_id', bundle_key_id, - 'reason', 'key_mismatch' - )); - RAISE EXCEPTION '%', - 'encryption_key_mismatch: This organization requires bundles to be ' - || 'encrypted with a specific key. The uploaded bundle was encrypted ' - || 'with a different key.'; - END IF; - END IF; - - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS enforce_encrypted_bundle_trigger ON public.app_versions; - -CREATE TRIGGER enforce_encrypted_bundle_trigger -BEFORE INSERT OR UPDATE OF -name, -app_id, -session_key, -key_id, -storage_provider, -r2_path, -external_url, -checksum, -manifest, -native_packages -ON public.app_versions -FOR EACH ROW -EXECUTE FUNCTION public.check_encrypted_bundle_on_insert(); - -ALTER FUNCTION public.check_encrypted_bundle_on_insert() OWNER TO postgres; -REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() -FROM public; -REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() FROM anon; -REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() -FROM authenticated; -GRANT EXECUTE ON FUNCTION public.check_encrypted_bundle_on_insert() -TO service_role; diff --git a/supabase/migrations/20260506101503_add_churn_revenue_plan_breakdown.sql b/supabase/migrations/20260506101503_add_churn_revenue_plan_breakdown.sql deleted file mode 100644 index 2724738d02..0000000000 --- a/supabase/migrations/20260506101503_add_churn_revenue_plan_breakdown.sql +++ /dev/null @@ -1,53 +0,0 @@ -ALTER TABLE public.daily_revenue_metrics -ADD COLUMN IF NOT EXISTS churn_mrr_solo -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS churn_mrr_maker -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS churn_mrr_team -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS churn_mrr_enterprise -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS contraction_mrr_solo -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS contraction_mrr_maker -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS contraction_mrr_team -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS contraction_mrr_enterprise -double precision DEFAULT 0 NOT NULL; - -COMMENT ON COLUMN public.daily_revenue_metrics.churn_mrr_solo IS -'Solo plan MRR fully lost to churn on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.churn_mrr_maker IS -'Maker plan MRR fully lost to churn on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.churn_mrr_team IS -'Team plan MRR fully lost to churn on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.churn_mrr_enterprise IS -'Enterprise plan MRR fully lost to churn on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.contraction_mrr_solo IS -'Solo plan MRR lost to downgrades on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.contraction_mrr_maker IS -'Maker plan MRR lost to downgrades on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.contraction_mrr_team IS -'Team plan MRR lost to downgrades on the day.'; -COMMENT ON COLUMN public.daily_revenue_metrics.contraction_mrr_enterprise IS -'Enterprise plan MRR lost to downgrades on the day.'; - -ALTER TABLE public.global_stats -ADD COLUMN IF NOT EXISTS churn_revenue_solo -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS churn_revenue_maker -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS churn_revenue_team -double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS churn_revenue_enterprise -double precision DEFAULT 0 NOT NULL; - -COMMENT ON COLUMN public.global_stats.churn_revenue_solo IS -'Solo plan MRR lost to churn and downgrades on the day.'; -COMMENT ON COLUMN public.global_stats.churn_revenue_maker IS -'Maker plan MRR lost to churn and downgrades on the day.'; -COMMENT ON COLUMN public.global_stats.churn_revenue_team IS -'Team plan MRR lost to churn and downgrades on the day.'; -COMMENT ON COLUMN public.global_stats.churn_revenue_enterprise IS -'Enterprise plan MRR lost to churn and downgrades on the day.'; diff --git a/supabase/migrations/20260506103727_add_plugin_version_ladder_to_global_stats.sql b/supabase/migrations/20260506103727_add_plugin_version_ladder_to_global_stats.sql deleted file mode 100644 index 7d80bf62af..0000000000 --- a/supabase/migrations/20260506103727_add_plugin_version_ladder_to_global_stats.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "public"."global_stats" -ADD COLUMN IF NOT EXISTS "plugin_version_ladder" jsonb DEFAULT '[]'::jsonb NOT NULL; diff --git a/supabase/migrations/20260506152006_native_version_usage_chart.sql b/supabase/migrations/20260506152006_native_version_usage_chart.sql deleted file mode 100644 index 1fd4101664..0000000000 --- a/supabase/migrations/20260506152006_native_version_usage_chart.sql +++ /dev/null @@ -1,136 +0,0 @@ -ALTER TABLE public.device_usage -ADD COLUMN IF NOT EXISTS version_build character varying(70), -ADD COLUMN IF NOT EXISTS platform character varying(32); - -CREATE INDEX IF NOT EXISTS idx_device_usage_app_timestamp_version_build -ON public.device_usage USING btree (app_id, timestamp, version_build); - -CREATE INDEX IF NOT EXISTS idx_device_usage_app_timestamp_platform_version_build -ON public.device_usage USING btree (app_id, timestamp, platform, version_build); - -DROP POLICY IF EXISTS "Disable for all" ON public.device_usage; -DROP POLICY IF EXISTS "Allow org members to select device_usage" ON public.device_usage; -DROP POLICY IF EXISTS "Deny insert on device_usage" ON public.device_usage; -DROP POLICY IF EXISTS "Deny update on device_usage" ON public.device_usage; -DROP POLICY IF EXISTS "Deny delete on device_usage" ON public.device_usage; - -CREATE POLICY "Disable for all" -ON public.device_usage -USING (false) -WITH CHECK (false); - -CREATE OR REPLACE FUNCTION public.read_native_version_usage( - p_app_id character varying, - p_period_start timestamp without time zone, - p_period_end timestamp without time zone -) -RETURNS TABLE ( - date date, - platform character varying, - version_build character varying, - devices bigint -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - RETURN QUERY - WITH authorized_app AS ( - SELECT apps.app_id - FROM public.apps - WHERE - apps.app_id = p_app_id - AND public.check_min_rights( - 'read'::public.user_min_right, - public.get_identity_org_appid( - '{read,upload,write,all}'::public.key_mode[], - apps.owner_org, - apps.app_id - ), - apps.owner_org, - apps.app_id, - NULL::bigint - ) - ), - daily_version_usage AS ( - SELECT - date_trunc('day', du.timestamp)::date AS usage_date, - COALESCE( - NULLIF(du.platform, ''), - NULLIF(d.platform::text, ''), - 'unknown' - )::character varying AS usage_platform, - COALESCE( - NULLIF(du.version_build, ''), - 'unknown' - )::character varying AS usage_version_build, - du.device_id - FROM public.device_usage AS du - INNER JOIN authorized_app AS aa - ON aa.app_id = du.app_id - LEFT JOIN public.devices AS d - ON d.app_id = du.app_id - AND d.device_id = du.device_id - WHERE - du.timestamp >= p_period_start - AND du.timestamp < p_period_end - ) - SELECT - usage_date AS date, - usage_platform AS platform, - usage_version_build AS version_build, - COUNT(DISTINCT device_id)::bigint AS devices - FROM daily_version_usage - GROUP BY usage_date, usage_platform, usage_version_build - ORDER BY usage_date, usage_platform, usage_version_build; -END; -$$; - -ALTER FUNCTION public.read_native_version_usage( - character varying, - timestamp without time zone, - timestamp without time zone -) OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.read_native_version_usage( - character varying, - timestamp without time zone, - timestamp without time zone -) FROM public; - -REVOKE ALL ON FUNCTION public.read_native_version_usage( - character varying, - timestamp without time zone, - timestamp without time zone -) FROM anon; - -REVOKE ALL ON FUNCTION public.read_native_version_usage( - character varying, - timestamp without time zone, - timestamp without time zone -) FROM authenticated; - -GRANT ALL ON FUNCTION public.read_native_version_usage( - character varying, - timestamp without time zone, - timestamp without time zone -) TO service_role; - -GRANT ALL ON FUNCTION public.read_native_version_usage( - character varying, - timestamp without time zone, - timestamp without time zone -) TO authenticated; - -GRANT ALL ON FUNCTION public.read_native_version_usage( - character varying, - timestamp without time zone, - timestamp without time zone -) TO anon; - -COMMENT ON FUNCTION public.read_native_version_usage( - character varying, - timestamp without time zone, - timestamp without time zone -) IS 'Authorized aggregate for native version usage by platform. Raw device_usage rows remain denied by RLS.'; diff --git a/supabase/migrations/20260507082135_active_usage_credits_flag.sql b/supabase/migrations/20260507082135_active_usage_credits_flag.sql deleted file mode 100644 index daadf4782a..0000000000 --- a/supabase/migrations/20260507082135_active_usage_credits_flag.sql +++ /dev/null @@ -1,103 +0,0 @@ -BEGIN; - -COMMENT ON COLUMN public.orgs.has_usage_credits -IS 'True only with positive, unexpired usage credits.'; - -CREATE OR REPLACE FUNCTION public.refresh_orgs_has_usage_credits() -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - WITH credit_state AS ( - SELECT - o."id", - COALESCE(g."has_usage_credits", false) AS "has_usage_credits" - FROM "public"."orgs" AS o - LEFT JOIN ( - SELECT - grant_rows."org_id", - bool_or( - grant_rows."expires_at" >= now() - AND grant_rows."credits_consumed" < grant_rows."credits_total" - ) AS "has_usage_credits" - FROM "public"."usage_credit_grants" AS grant_rows - GROUP BY grant_rows."org_id" - ) AS g ON g."org_id" = o."id" - ) - UPDATE "public"."orgs" AS o - SET "has_usage_credits" = credit_state."has_usage_credits" - FROM credit_state - WHERE o."id" = credit_state."id" - AND o."has_usage_credits" IS DISTINCT FROM credit_state."has_usage_credits"; -END; -$$; - -ALTER FUNCTION public.refresh_orgs_has_usage_credits() OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.refresh_orgs_has_usage_credits() FROM public; -GRANT EXECUTE -ON FUNCTION public.refresh_orgs_has_usage_credits() -TO service_role; - -CREATE OR REPLACE FUNCTION public.sync_org_has_usage_credits_from_grants() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_org_id uuid; -BEGIN - FOR v_org_id IN - SELECT DISTINCT affected."org_id" - FROM (VALUES (NEW."org_id"), (OLD."org_id")) AS affected("org_id") - WHERE affected."org_id" IS NOT NULL - LOOP - UPDATE "public"."orgs" AS o - SET "has_usage_credits" = credit_state."has_usage_credits" - FROM ( - SELECT EXISTS ( - SELECT 1 - FROM "public"."usage_credit_grants" AS g - WHERE g."org_id" = v_org_id - AND g."expires_at" >= now() - AND g."credits_consumed" < g."credits_total" - ) AS "has_usage_credits" - ) AS credit_state - WHERE o."id" = v_org_id - AND o."has_usage_credits" IS DISTINCT FROM credit_state."has_usage_credits"; - END LOOP; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION public.sync_org_has_usage_credits_from_grants() -OWNER TO "postgres"; - -REVOKE ALL -ON FUNCTION public.sync_org_has_usage_credits_from_grants() -FROM public; -GRANT EXECUTE -ON FUNCTION public.sync_org_has_usage_credits_from_grants() -TO service_role; - -DROP TRIGGER IF EXISTS trg_sync_org_has_usage_credits -ON public.usage_credit_grants; - -CREATE TRIGGER trg_sync_org_has_usage_credits -AFTER INSERT OR UPDATE OR DELETE -ON public.usage_credit_grants -FOR EACH ROW -EXECUTE FUNCTION public.sync_org_has_usage_credits_from_grants(); - -SELECT public.refresh_orgs_has_usage_credits(); - -UPDATE public.cron_tasks -SET - description = 'Refresh active credit flag for replica plugin gates' -WHERE name = 'refresh_org_usage_credits_flag'; - -COMMIT; diff --git a/supabase/migrations/20260507090047_fix_app_versions_anon_dos.sql b/supabase/migrations/20260507090047_fix_app_versions_anon_dos.sql deleted file mode 100644 index 92a8c63ff3..0000000000 --- a/supabase/migrations/20260507090047_fix_app_versions_anon_dos.sql +++ /dev/null @@ -1,102 +0,0 @@ --- Fix app_versions unfiltered SELECT timeouts by avoiding per-row identity --- resolution. The previous policy called get_identity_org_appid() and --- check_min_rights() for every app_versions row, so unauthenticated anon --- requests with no Capgo API key could force expensive scans before RLS denied --- access. Compute readable app IDs once per statement, then use the indexed --- app_id predicate in the RLS policy. - -CREATE OR REPLACE FUNCTION "public"."app_versions_readable_app_ids"() -RETURNS character varying[] -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; - v_allowed character varying[] := '{}'::character varying[]; -BEGIN - SELECT auth.uid() INTO v_user_id; - - -- If no authenticated user is present, authenticate through the Capgo API key - -- header once. No API key means the anon request can read no app_versions. - IF v_user_id IS NULL THEN - SELECT public.get_apikey_header() INTO v_api_key_text; - IF v_api_key_text IS NULL THEN - RETURN v_allowed; - END IF; - - SELECT * - FROM public.find_apikey_by_value(v_api_key_text) - INTO v_api_key; - - IF v_api_key.id IS NULL THEN - RETURN v_allowed; - END IF; - - IF public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN v_allowed; - END IF; - - IF v_api_key.mode IS NOT NULL THEN - IF NOT (v_api_key.mode = ANY('{read,upload,write,all}'::public.key_mode[])) THEN - RETURN v_allowed; - END IF; - - v_user_id := v_api_key.user_id; - END IF; - END IF; - - SELECT COALESCE(array_agg(DISTINCT apps.app_id), '{}'::character varying[]) - INTO v_allowed - FROM public.apps - WHERE ( - v_api_key.id IS NULL - OR COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) = 0 - OR apps.owner_org = ANY(v_api_key.limited_to_orgs) - ) - AND ( - v_api_key.id IS NULL - OR v_api_key.limited_to_apps IS NULL - OR v_api_key.limited_to_apps = '{}'::character varying[] - OR apps.app_id = ANY(v_api_key.limited_to_apps) - ) - AND public.check_min_rights( - 'read'::public.user_min_right, - v_user_id, - apps.owner_org, - apps.app_id, - NULL::bigint - ); - - RETURN v_allowed; -END; -$$; - -ALTER FUNCTION "public"."app_versions_readable_app_ids"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."app_versions_readable_app_ids"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."app_versions_readable_app_ids"() TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."app_versions_readable_app_ids"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."app_versions_readable_app_ids"() TO "service_role"; - -COMMENT ON FUNCTION "public"."app_versions_readable_app_ids"() IS -'Returns the app IDs whose bundle rows are readable by the current authenticated user or Capgo API key. This intentionally reveals only app IDs the caller can already list through normal app/bundle read access, and is used by app_versions RLS to avoid per-row auth work on unfiltered PostgREST requests.'; - -DROP POLICY IF EXISTS "Allow for auth, api keys (read+)" -- noqa: RF05,LT05 -ON "public"."app_versions"; - -CREATE POLICY "Allow for auth, api keys (read+)" -- noqa: RF05,LT05 -ON "public"."app_versions" -FOR SELECT -TO "anon", "authenticated" -USING ( - "app_id" = ANY( - COALESCE((SELECT "public"."app_versions_readable_app_ids"()), '{}'::character varying[]) - ) - AND EXISTS ( - SELECT 1 - FROM "public"."apps" - WHERE "apps"."app_id" = "app_versions"."app_id" - AND "apps"."owner_org" = "app_versions"."owner_org" - ) -); diff --git a/supabase/migrations/20260507090436_fix_apikey_rbac_rpc_oracle_and_expiration_scope.sql b/supabase/migrations/20260507090436_fix_apikey_rbac_rpc_oracle_and_expiration_scope.sql deleted file mode 100644 index ed14d21a30..0000000000 --- a/supabase/migrations/20260507090436_fix_apikey_rbac_rpc_oracle_and_expiration_scope.sql +++ /dev/null @@ -1,235 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."cli_check_permission"( - "apikey" "text" DEFAULT NULL, - "permission_key" "text" DEFAULT NULL, - "org_id" "uuid" DEFAULT NULL, - "app_id" "text" DEFAULT NULL, - "channel_id" bigint DEFAULT NULL -) RETURNS boolean - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_request_apikey text; - v_api_key public.apikeys%ROWTYPE; -BEGIN - IF permission_key IS NULL OR permission_key = '' THEN - RETURN false; - END IF; - - SELECT public.get_apikey_header() INTO v_request_apikey; - - IF v_request_apikey IS NULL OR v_request_apikey = '' THEN - RETURN false; - END IF; - - IF apikey IS NOT NULL AND apikey <> '' AND apikey IS DISTINCT FROM v_request_apikey THEN - RETURN false; - END IF; - - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(v_request_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL THEN - RETURN false; - END IF; - - RETURN public.rbac_check_permission_direct( - permission_key, - v_api_key.user_id, - org_id, - app_id, - channel_id, - v_request_apikey - ); -END; -$$; - -ALTER FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) FROM PUBLIC; - -GRANT EXECUTE ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) TO "service_role"; - -COMMENT ON FUNCTION "public"."cli_check_permission"( - "apikey" "text", - "permission_key" "text", - "org_id" "uuid", - "app_id" "text", - "channel_id" bigint -) IS 'CLI permission wrapper bound to the request capgkey header. The apikey argument is retained for CLI compatibility and must match the header when provided.'; - -CREATE OR REPLACE FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" DEFAULT NULL -) RETURNS SETOF "public"."apps" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' - AS $$ -DECLARE - v_request_apikey text; - v_api_key public.apikeys%ROWTYPE; -BEGIN - SELECT public.get_apikey_header() INTO v_request_apikey; - - IF v_request_apikey IS NULL OR v_request_apikey = '' THEN - RETURN; - END IF; - - IF apikey IS NOT NULL AND apikey <> '' AND apikey IS DISTINCT FROM v_request_apikey THEN - RETURN; - END IF; - - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(v_request_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT a.* - FROM public.apps a - WHERE public.rbac_check_permission_direct( - public.rbac_perm_app_read(), - v_api_key.user_id, - a.owner_org, - a.app_id, - NULL, - v_request_apikey - ) - ORDER BY a.created_at DESC; -END; -$$; - -ALTER FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) FROM PUBLIC; - -GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) TO "service_role"; - -COMMENT ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" -) IS 'Returns apps visible to the request capgkey using RBAC-aware permission checks with legacy fallback. The apikey argument is retained for CLI compatibility and must match the header when provided.'; - -CREATE OR REPLACE FUNCTION public.enforce_apikey_expiration_policy() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - scoped_org RECORD; -BEGIN - IF TG_OP = 'UPDATE' - AND NEW.expires_at IS NOT DISTINCT FROM OLD.expires_at - AND NEW.limited_to_orgs IS NOT DISTINCT FROM OLD.limited_to_orgs - AND NEW.limited_to_apps IS NOT DISTINCT FROM OLD.limited_to_apps THEN - RETURN NEW; - END IF; - - FOR scoped_org IN - WITH explicit_scope_orgs AS ( - SELECT unnest(COALESCE(NEW.limited_to_orgs, '{}'::uuid[])) AS org_id - UNION - SELECT public.apps.owner_org - FROM public.apps - WHERE public.apps.app_id = ANY(COALESCE(NEW.limited_to_apps, '{}'::text[])) - ), - scope_orgs AS ( - SELECT explicit_scope_orgs.org_id - FROM explicit_scope_orgs - UNION - SELECT public.org_users.org_id - FROM public.org_users - WHERE public.org_users.user_id = NEW.user_id - AND COALESCE(array_length(NEW.limited_to_orgs, 1), 0) = 0 - AND COALESCE(array_length(NEW.limited_to_apps, 1), 0) = 0 - ) - SELECT - public.orgs.id, - public.orgs.require_apikey_expiration, - public.orgs.max_apikey_expiration_days - FROM public.orgs - JOIN scope_orgs ON scope_orgs.org_id = public.orgs.id - LOOP - IF scoped_org.require_apikey_expiration AND NEW.expires_at IS NULL THEN - RAISE EXCEPTION USING - ERRCODE = 'P0001', - MESSAGE = 'expiration_required', - DETAIL = 'This organization requires API keys to have an expiration date'; - END IF; - - IF scoped_org.max_apikey_expiration_days IS NOT NULL - AND NEW.expires_at IS NOT NULL - AND NEW.expires_at > clock_timestamp() - + make_interval(days => scoped_org.max_apikey_expiration_days) THEN - RAISE EXCEPTION USING - ERRCODE = 'P0001', - MESSAGE = 'expiration_exceeds_max', - DETAIL = format( - 'API key expiration cannot exceed %s days for this organization', - scoped_org.max_apikey_expiration_days - ); - END IF; - END LOOP; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.enforce_apikey_expiration_policy() OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.enforce_apikey_expiration_policy() FROM public; -GRANT EXECUTE ON FUNCTION public.enforce_apikey_expiration_policy() TO service_role; - -DROP TRIGGER IF EXISTS apikeys_enforce_expiration_policy ON public.apikeys; - -CREATE TRIGGER apikeys_enforce_expiration_policy -BEFORE INSERT OR UPDATE ON public.apikeys -FOR EACH ROW -EXECUTE FUNCTION public.enforce_apikey_expiration_policy(); diff --git a/supabase/migrations/20260507091347_secure_exist_app_versions_rpc.sql b/supabase/migrations/20260507091347_secure_exist_app_versions_rpc.sql deleted file mode 100644 index 227bd53e9a..0000000000 --- a/supabase/migrations/20260507091347_secure_exist_app_versions_rpc.sql +++ /dev/null @@ -1,313 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_perm text; - v_scope text; - v_apikey text; - v_use_rbac boolean; - v_effective_org_id uuid := org_id; - v_app_owner_org uuid; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; -BEGIN - -- Existing apps are always authorized in the app owner's org scope. - -- Keep nonexistent apps on the caller org so API handlers can still return their - -- own not-found errors after a valid org-level check. - IF app_id IS NOT NULL THEN - SELECT owner_org INTO v_app_owner_org - FROM public.apps - WHERE public.apps.app_id = check_min_rights.app_id - LIMIT 1; - - IF v_app_owner_org IS NOT NULL THEN - IF v_effective_org_id IS NOT NULL AND v_effective_org_id IS DISTINCT FROM v_app_owner_org THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APP_ORG_MISMATCH', jsonb_build_object( - 'org_id', v_effective_org_id, - 'app_owner_org', v_app_owner_org, - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - - v_effective_org_id := v_app_owner_org; - END IF; - END IF; - - -- Derive org from channel when not provided to honor org-level flag and scoping. - IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE public.channels.id = channel_id - LIMIT 1; - END IF; - - -- Enforce 2FA if the org requires it. - IF v_effective_org_id IS NOT NULL THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (user_id IS NULL OR NOT public.has_2fa_enabled(user_id)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - -- Enforce password policy if enabled for the org. - IF v_effective_org_id IS NOT NULL THEN - v_password_policy_ok := public.user_meets_password_policy(user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - IF NOT v_use_rbac THEN - RETURN public.check_min_rights_legacy(min_right, user_id, COALESCE(org_id, v_effective_org_id), app_id, channel_id); - END IF; - - IF channel_id IS NOT NULL THEN - v_scope := public.rbac_scope_channel(); - ELSIF app_id IS NOT NULL THEN - v_scope := public.rbac_scope_app(); - ELSE - v_scope := public.rbac_scope_org(); - END IF; - - v_perm := public.rbac_permission_for_legacy(min_right, v_scope); - SELECT public.get_apikey_header() INTO v_apikey; - - -- Keep RLS authorization semantics aligned with explicit RBAC checks. In - -- particular, an API key with direct role bindings must be evaluated as the - -- API-key principal and must not inherit broader owner-user permissions. - RETURN public.rbac_check_permission_direct( - v_perm, - user_id, - v_effective_org_id, - app_id, - channel_id, - v_apikey - ); -END; -$$; - -ALTER FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) FROM PUBLIC; - -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "anon"; - -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "authenticated"; - -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "service_role"; - -DROP FUNCTION IF EXISTS "public"."get_accessible_apps_for_apikey_v2"("apikey" "text"); - -CREATE OR REPLACE FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying -) RETURNS boolean -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -BEGIN - RETURN public.exist_app_versions( - exist_app_versions.appid, - exist_app_versions.name_version, - public.get_apikey_header() - ); -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) RETURNS boolean -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_org_id uuid; - v_request_role text; - v_user_id uuid; - v_api_key text; -BEGIN - SELECT owner_org - INTO v_org_id - FROM public.apps - WHERE app_id = exist_app_versions.appid - LIMIT 1; - - IF v_org_id IS NULL THEN - RETURN false; - END IF; - - SELECT public.current_request_role() - INTO v_request_role; - - IF public.is_internal_request_role(v_request_role) THEN - RETURN ( - SELECT EXISTS ( - SELECT 1 - FROM public.app_versions - WHERE app_id = exist_app_versions.appid - AND name = exist_app_versions.name_version - AND owner_org = v_org_id - ) - ); - END IF; - - SELECT auth.uid() - INTO v_user_id; - - v_api_key := exist_app_versions.apikey; - - IF v_api_key = '' THEN - v_api_key := NULL; - END IF; - - IF v_api_key IS NULL THEN - SELECT public.get_apikey_header() - INTO v_api_key; - END IF; - - IF v_user_id IS NULL AND v_api_key IS NULL THEN - RETURN false; - END IF; - - IF public.rbac_check_permission_direct( - public.rbac_perm_app_read_bundles(), - v_user_id, - v_org_id, - exist_app_versions.appid, - NULL::bigint, - v_api_key - ) IS NOT TRUE THEN - RETURN false; - END IF; - - RETURN ( - SELECT EXISTS ( - SELECT 1 - FROM public.app_versions - WHERE app_id = exist_app_versions.appid - AND name = exist_app_versions.name_version - AND owner_org = v_org_id - ) - ); -END; -$$; - -ALTER FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying -) OWNER TO "postgres"; - -ALTER FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying -) FROM PUBLIC; - -REVOKE ALL ON FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) FROM PUBLIC; - --- API key requests reach PostgREST as anon, so keep EXECUTE while the function gates data with RBAC. -GRANT EXECUTE ON FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying -) TO "anon"; - -GRANT EXECUTE ON FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying -) TO "authenticated"; - -GRANT EXECUTE ON FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying -) TO "service_role"; - -GRANT EXECUTE ON FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "anon"; - -GRANT EXECUTE ON FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "authenticated"; - -GRANT EXECUTE ON FUNCTION "public"."exist_app_versions"( - "appid" character varying, - "name_version" character varying, - "apikey" "text" -) TO "service_role"; diff --git a/supabase/migrations/20260507153639_fast_app_versions_select_policy.sql b/supabase/migrations/20260507153639_fast_app_versions_select_policy.sql deleted file mode 100644 index 25b2efc600..0000000000 --- a/supabase/migrations/20260507153639_fast_app_versions_select_policy.sql +++ /dev/null @@ -1,196 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."app_versions_readable_app_ids"() -RETURNS character varying[] -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; - v_allowed character varying[] := '{}'::character varying[]; -BEGIN - SELECT auth.uid() INTO v_user_id; - - -- No authenticated user and no Capgo API key means no readable bundles. - IF v_user_id IS NULL THEN - SELECT public.get_apikey_header() INTO v_api_key_text; - IF v_api_key_text IS NULL THEN - RETURN v_allowed; - END IF; - - SELECT * - FROM public.find_apikey_by_value(v_api_key_text) - INTO v_api_key; - - IF v_api_key.id IS NULL THEN - RETURN v_allowed; - END IF; - - IF public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN v_allowed; - END IF; - - IF v_api_key.mode IS NOT NULL THEN - IF NOT (v_api_key.mode = ANY('{read,upload,write,all}'::public.key_mode[])) THEN - RETURN v_allowed; - END IF; - END IF; - - v_user_id := v_api_key.user_id; - END IF; - - WITH candidate_apps AS ( - -- Legacy org-scoped grants can read every app in the org. - SELECT apps.app_id, apps.owner_org - FROM public.org_users - INNER JOIN public.apps ON apps.owner_org = org_users.org_id - WHERE v_user_id IS NOT NULL - AND org_users.user_id = v_user_id - AND org_users.user_right >= 'read'::public.user_min_right - AND org_users.app_id IS NULL - AND org_users.channel_id IS NULL - - UNION - - -- Legacy app-scoped grants can read that app. - SELECT apps.app_id, apps.owner_org - FROM public.org_users - INNER JOIN public.apps - ON apps.app_id = org_users.app_id - AND apps.owner_org = org_users.org_id - WHERE v_user_id IS NOT NULL - AND org_users.user_id = v_user_id - AND org_users.user_right >= 'read'::public.user_min_right - AND org_users.app_id IS NOT NULL - AND org_users.channel_id IS NULL - - UNION - - -- RBAC org-scoped direct user/API-key bindings can read candidate apps in the org. - SELECT apps.app_id, apps.owner_org - FROM public.role_bindings - INNER JOIN public.apps ON apps.owner_org = role_bindings.org_id - WHERE role_bindings.scope_type = public.rbac_scope_org() - AND role_bindings.org_id IS NOT NULL - AND (role_bindings.expires_at IS NULL OR role_bindings.expires_at > now()) - AND ( - ( - v_user_id IS NOT NULL - AND role_bindings.principal_type = public.rbac_principal_user() - AND role_bindings.principal_id = v_user_id - ) - OR ( - v_api_key.rbac_id IS NOT NULL - AND role_bindings.principal_type = public.rbac_principal_apikey() - AND role_bindings.principal_id = v_api_key.rbac_id - ) - ) - - UNION - - -- RBAC app-scoped direct user/API-key bindings can read candidate apps. - SELECT apps.app_id, apps.owner_org - FROM public.role_bindings - INNER JOIN public.apps - ON apps.id = role_bindings.app_id - AND apps.owner_org = role_bindings.org_id - WHERE role_bindings.scope_type = public.rbac_scope_app() - AND role_bindings.app_id IS NOT NULL - AND (role_bindings.expires_at IS NULL OR role_bindings.expires_at > now()) - AND ( - ( - v_user_id IS NOT NULL - AND role_bindings.principal_type = public.rbac_principal_user() - AND role_bindings.principal_id = v_user_id - ) - OR ( - v_api_key.rbac_id IS NOT NULL - AND role_bindings.principal_type = public.rbac_principal_apikey() - AND role_bindings.principal_id = v_api_key.rbac_id - ) - ) - - UNION - - -- RBAC group org-scoped bindings are user-only and can read candidate apps in the org. - SELECT apps.app_id, apps.owner_org - FROM public.group_members - INNER JOIN public.groups ON groups.id = group_members.group_id - INNER JOIN public.role_bindings - ON role_bindings.principal_type = public.rbac_principal_group() - AND role_bindings.principal_id = group_members.group_id - AND role_bindings.scope_type = public.rbac_scope_org() - AND role_bindings.org_id = groups.org_id - INNER JOIN public.apps ON apps.owner_org = role_bindings.org_id - WHERE v_user_id IS NOT NULL - AND group_members.user_id = v_user_id - AND (role_bindings.expires_at IS NULL OR role_bindings.expires_at > now()) - - UNION - - -- RBAC group app-scoped bindings are user-only and can read candidate apps. - SELECT apps.app_id, apps.owner_org - FROM public.group_members - INNER JOIN public.groups ON groups.id = group_members.group_id - INNER JOIN public.role_bindings - ON role_bindings.principal_type = public.rbac_principal_group() - AND role_bindings.principal_id = group_members.group_id - AND role_bindings.scope_type = public.rbac_scope_app() - AND role_bindings.org_id = groups.org_id - INNER JOIN public.apps - ON apps.id = role_bindings.app_id - AND apps.owner_org = role_bindings.org_id - WHERE v_user_id IS NOT NULL - AND group_members.user_id = v_user_id - AND role_bindings.app_id IS NOT NULL - AND (role_bindings.expires_at IS NULL OR role_bindings.expires_at > now()) - ) - SELECT COALESCE(array_agg(DISTINCT candidate_apps.app_id), '{}'::character varying[]) - INTO v_allowed - FROM candidate_apps - WHERE ( - v_api_key.id IS NULL - OR COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) = 0 - OR candidate_apps.owner_org = ANY(v_api_key.limited_to_orgs) - ) - AND ( - v_api_key.id IS NULL - OR v_api_key.limited_to_apps IS NULL - OR v_api_key.limited_to_apps = '{}'::character varying[] - OR candidate_apps.app_id = ANY(v_api_key.limited_to_apps) - ) - -- Candidate collection is intentionally broad; this exact check preserves - -- legacy/RBAC permission semantics, 2FA, password policy, and API-key scope. - AND public.check_min_rights( - 'read'::public.user_min_right, - v_user_id, - candidate_apps.owner_org, - candidate_apps.app_id, - NULL::bigint - ); - - RETURN v_allowed; -END; -$$; - -ALTER FUNCTION "public"."app_versions_readable_app_ids"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."app_versions_readable_app_ids"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."app_versions_readable_app_ids"() TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."app_versions_readable_app_ids"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."app_versions_readable_app_ids"() TO "service_role"; - -COMMENT ON FUNCTION "public"."app_versions_readable_app_ids"() IS -'Returns app IDs whose bundle rows are readable by the current authenticated user or Capgo API key. It only evaluates candidate apps from legacy/RBAC bindings, then verifies each candidate with check_min_rights() to avoid global app scans while preserving authorization semantics.'; - -DROP POLICY IF EXISTS "Allow for auth, api keys (read+)" -- noqa: RF05,LT05 -ON "public"."app_versions"; - -CREATE POLICY "Allow for auth, api keys (read+)" -- noqa: RF05,LT05 -ON "public"."app_versions" -FOR SELECT -TO "anon", "authenticated" -USING ( - "app_id" = ANY( - COALESCE((SELECT "public"."app_versions_readable_app_ids"()), '{}'::character varying[]) - ) -); diff --git a/supabase/migrations/20260507165636_fast_usage_credit_rls_policies.sql b/supabase/migrations/20260507165636_fast_usage_credit_rls_policies.sql deleted file mode 100644 index af5283a265..0000000000 --- a/supabase/migrations/20260507165636_fast_usage_credit_rls_policies.sql +++ /dev/null @@ -1,305 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."usage_credit_readable_org_ids"() -RETURNS uuid[] -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_auth_user_id uuid; - v_user_id uuid; - v_check_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; - v_has_valid_api_key boolean := false; - v_user_candidates_need_key_scope boolean := false; - v_allowed uuid[] := '{}'::uuid[]; -BEGIN - SELECT auth.uid() INTO v_auth_user_id; - v_user_id := v_auth_user_id; - v_check_user_id := v_auth_user_id; - - SELECT public.get_apikey_header() INTO v_api_key_text; - IF v_api_key_text IS NOT NULL THEN - SELECT * - FROM public.find_apikey_by_value(v_api_key_text) - INTO v_api_key; - - v_has_valid_api_key := v_api_key.id IS NOT NULL - AND NOT public.is_apikey_expired(v_api_key.expires_at); - - IF v_auth_user_id IS NULL AND v_has_valid_api_key THEN - v_check_user_id := v_api_key.user_id; - - IF v_api_key.mode IS NOT NULL THEN - IF v_api_key.mode = ANY('{read,upload,write,all}'::public.key_mode[]) THEN - -- Legacy-mode API keys inherit their owner's org-level grants and stay - -- restricted to the key's configured org scope. - v_user_id := v_api_key.user_id; - v_user_candidates_need_key_scope := true; - END IF; - END IF; - END IF; - END IF; - - IF v_user_id IS NULL AND NOT v_has_valid_api_key THEN - RETURN v_allowed; - END IF; - - WITH candidate_orgs AS ( - -- Authenticated-user candidates are not limited by any accompanying API key; - -- legacy API-key owner candidates are limited by that key's org scope. - SELECT org_users.org_id, v_user_candidates_need_key_scope AS needs_api_key_scope - FROM public.org_users - WHERE v_user_id IS NOT NULL - AND org_users.user_id = v_user_id - AND org_users.user_right >= 'admin'::public.user_min_right - AND org_users.app_id IS NULL - AND org_users.channel_id IS NULL - - UNION - - SELECT role_bindings.org_id, v_user_candidates_need_key_scope AS needs_api_key_scope - FROM public.role_bindings - WHERE v_user_id IS NOT NULL - AND role_bindings.scope_type = public.rbac_scope_org() - AND role_bindings.org_id IS NOT NULL - AND role_bindings.principal_type = public.rbac_principal_user() - AND role_bindings.principal_id = v_user_id - AND (role_bindings.expires_at IS NULL OR role_bindings.expires_at > now()) - - UNION - - -- API-key RBAC candidates are available even when the request also carries a - -- user JWT, matching check_min_rights() mixed-auth behavior. - SELECT role_bindings.org_id, true AS needs_api_key_scope - FROM public.role_bindings - WHERE v_has_valid_api_key - AND v_api_key.rbac_id IS NOT NULL - AND role_bindings.scope_type = public.rbac_scope_org() - AND role_bindings.org_id IS NOT NULL - AND role_bindings.principal_type = public.rbac_principal_apikey() - AND role_bindings.principal_id = v_api_key.rbac_id - AND (role_bindings.expires_at IS NULL OR role_bindings.expires_at > now()) - - UNION - - -- RBAC group org-scoped bindings are user-only and exact-checked below. - SELECT role_bindings.org_id, v_user_candidates_need_key_scope AS needs_api_key_scope - FROM public.group_members - INNER JOIN public.groups ON groups.id = group_members.group_id - INNER JOIN public.role_bindings - ON role_bindings.principal_type = public.rbac_principal_group() - AND role_bindings.principal_id = group_members.group_id - AND role_bindings.scope_type = public.rbac_scope_org() - AND role_bindings.org_id = groups.org_id - WHERE v_user_id IS NOT NULL - AND group_members.user_id = v_user_id - AND role_bindings.org_id IS NOT NULL - AND (role_bindings.expires_at IS NULL OR role_bindings.expires_at > now()) - ) - SELECT COALESCE(array_agg(DISTINCT candidate_orgs.org_id), '{}'::uuid[]) - INTO v_allowed - FROM candidate_orgs - WHERE ( - NOT candidate_orgs.needs_api_key_scope - OR COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) = 0 - OR candidate_orgs.org_id = ANY(v_api_key.limited_to_orgs) - ) - -- Candidate collection is intentionally broad; this exact check preserves - -- legacy/RBAC permission semantics, 2FA, password policy, and API-key scope. - AND public.check_min_rights( - 'admin'::public.user_min_right, - v_check_user_id, - candidate_orgs.org_id, - NULL::character varying, - NULL::bigint - ); - - RETURN v_allowed; -END; -$$; - - -ALTER FUNCTION "public"."usage_credit_readable_org_ids"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."usage_credit_readable_org_ids"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."usage_credit_readable_org_ids"() TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."usage_credit_readable_org_ids"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."usage_credit_readable_org_ids"() TO "service_role"; - -COMMENT ON FUNCTION "public"."usage_credit_readable_org_ids"() IS -'Returns org IDs whose usage-credit rows are readable by the current authenticated user or Capgo API key. It evaluates candidate orgs from legacy/RBAC bindings once per statement, then verifies each candidate with check_min_rights() to avoid per-row RLS work while preserving authorization semantics.'; - -DROP POLICY IF EXISTS "Allow org members to select usage_overage_events" -ON "public"."usage_overage_events"; - -CREATE POLICY "Allow org members to select usage_overage_events" -ON "public"."usage_overage_events" -FOR SELECT -TO "anon", "authenticated" -USING ( - "org_id" = ANY( - COALESCE((SELECT "public"."usage_credit_readable_org_ids"()), '{}'::uuid[]) - ) -); - -DROP POLICY IF EXISTS "Deny insert for org members" -ON "public"."usage_overage_events"; - -CREATE POLICY "Deny insert for org members" -ON "public"."usage_overage_events" -AS RESTRICTIVE -FOR INSERT -TO "anon", "authenticated" -WITH CHECK (false); - -DROP POLICY IF EXISTS "Deny update for org members" -ON "public"."usage_overage_events"; - -CREATE POLICY "Deny update for org members" -ON "public"."usage_overage_events" -AS RESTRICTIVE -FOR UPDATE -TO "anon", "authenticated" -USING (false) -WITH CHECK (false); - -DROP POLICY IF EXISTS "Deny delete for org members" -ON "public"."usage_overage_events"; - -CREATE POLICY "Deny delete for org members" -ON "public"."usage_overage_events" -AS RESTRICTIVE -FOR DELETE -TO "anon", "authenticated" -USING (false); - -DROP POLICY IF EXISTS "Allow org members to select usage_credit_consumptions" -ON "public"."usage_credit_consumptions"; - -CREATE POLICY "Allow org members to select usage_credit_consumptions" -ON "public"."usage_credit_consumptions" -FOR SELECT -TO "anon", "authenticated" -USING ( - "org_id" = ANY( - COALESCE((SELECT "public"."usage_credit_readable_org_ids"()), '{}'::uuid[]) - ) -); - -DROP POLICY IF EXISTS "Deny insert for org members" -ON "public"."usage_credit_consumptions"; - -CREATE POLICY "Deny insert for org members" -ON "public"."usage_credit_consumptions" -AS RESTRICTIVE -FOR INSERT -TO "anon", "authenticated" -WITH CHECK (false); - -DROP POLICY IF EXISTS "Deny update for org members" -ON "public"."usage_credit_consumptions"; - -CREATE POLICY "Deny update for org members" -ON "public"."usage_credit_consumptions" -AS RESTRICTIVE -FOR UPDATE -TO "anon", "authenticated" -USING (false) -WITH CHECK (false); - -DROP POLICY IF EXISTS "Deny delete for org members" -ON "public"."usage_credit_consumptions"; - -CREATE POLICY "Deny delete for org members" -ON "public"."usage_credit_consumptions" -AS RESTRICTIVE -FOR DELETE -TO "anon", "authenticated" -USING (false); - -DROP POLICY IF EXISTS "Allow org members to select usage_credit_grants" -ON "public"."usage_credit_grants"; - -CREATE POLICY "Allow org members to select usage_credit_grants" -ON "public"."usage_credit_grants" -FOR SELECT -TO "anon", "authenticated" -USING ( - "org_id" = ANY( - COALESCE((SELECT "public"."usage_credit_readable_org_ids"()), '{}'::uuid[]) - ) -); - -DROP POLICY IF EXISTS "Deny insert for org members" -ON "public"."usage_credit_grants"; - -CREATE POLICY "Deny insert for org members" -ON "public"."usage_credit_grants" -AS RESTRICTIVE -FOR INSERT -TO "anon", "authenticated" -WITH CHECK (false); - -DROP POLICY IF EXISTS "Deny update for org members" -ON "public"."usage_credit_grants"; - -CREATE POLICY "Deny update for org members" -ON "public"."usage_credit_grants" -AS RESTRICTIVE -FOR UPDATE -TO "anon", "authenticated" -USING (false) -WITH CHECK (false); - -DROP POLICY IF EXISTS "Deny delete for org members" -ON "public"."usage_credit_grants"; - -CREATE POLICY "Deny delete for org members" -ON "public"."usage_credit_grants" -AS RESTRICTIVE -FOR DELETE -TO "anon", "authenticated" -USING (false); - -DROP POLICY IF EXISTS "Allow org members to select usage_credit_transactions" -ON "public"."usage_credit_transactions"; - -CREATE POLICY "Allow org members to select usage_credit_transactions" -ON "public"."usage_credit_transactions" -FOR SELECT -TO "anon", "authenticated" -USING ( - "org_id" = ANY( - COALESCE((SELECT "public"."usage_credit_readable_org_ids"()), '{}'::uuid[]) - ) -); - -DROP POLICY IF EXISTS "Deny insert for org members" -ON "public"."usage_credit_transactions"; - -CREATE POLICY "Deny insert for org members" -ON "public"."usage_credit_transactions" -AS RESTRICTIVE -FOR INSERT -TO "anon", "authenticated" -WITH CHECK (false); - -DROP POLICY IF EXISTS "Deny update for org members" -ON "public"."usage_credit_transactions"; - -CREATE POLICY "Deny update for org members" -ON "public"."usage_credit_transactions" -AS RESTRICTIVE -FOR UPDATE -TO "anon", "authenticated" -USING (false) -WITH CHECK (false); - -DROP POLICY IF EXISTS "Deny delete for org members" -ON "public"."usage_credit_transactions"; - -CREATE POLICY "Deny delete for org members" -ON "public"."usage_credit_transactions" -AS RESTRICTIVE -FOR DELETE -TO "anon", "authenticated" -USING (false); diff --git a/supabase/migrations/20260508122137_fix_app_versions_trigger_owner_org.sql b/supabase/migrations/20260508122137_fix_app_versions_trigger_owner_org.sql deleted file mode 100644 index b6a19d84d9..0000000000 --- a/supabase/migrations/20260508122137_fix_app_versions_trigger_owner_org.sql +++ /dev/null @@ -1,47 +0,0 @@ --- Fix app_versions BEFORE INSERT trigger returning NULL for owner_org. --- --- The trigger auto_owner_org_by_app_id calls get_user_main_org_id_by_app_id, --- which since migration 20260203150000 includes auth checks intended to prevent --- anonymous lookups. In a PostgREST trigger context (session_user = 'authenticator', --- auth.uid() = NULL, auth.role() = 'anon'), those checks can fail even for --- legitimately authorized inserts, causing owner_org to be set to NULL and --- violating the NOT NULL constraint (error code 23502). --- --- The RLS INSERT policy already verified the caller's rights before the trigger --- fires, so re-checking auth inside the trigger is redundant and harmful. --- Replace the call with a minimal SECURITY DEFINER helper that simply resolves --- owner_org from the apps table without any auth logic. - -CREATE OR REPLACE FUNCTION "public"."get_owner_org_by_app_id_internal"("p_app_id" "text") -RETURNS "uuid" -LANGUAGE "sql" SECURITY DEFINER STABLE -SET "search_path" TO '' -AS $$ - SELECT owner_org FROM public.apps WHERE apps.app_id = p_app_id LIMIT 1; -$$; - -ALTER FUNCTION "public"."get_owner_org_by_app_id_internal"("p_app_id" "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_owner_org_by_app_id_internal"("p_app_id" "text") FROM PUBLIC; - -COMMENT ON FUNCTION "public"."get_owner_org_by_app_id_internal"("p_app_id" "text") IS -'Internal helper for the auto_owner_org_by_app_id trigger only. Resolves the owning org for an app without performing auth checks — the trigger fires after RLS has already validated the caller.'; - --- The trigger runs as SECURITY DEFINER (owner = postgres) so it can call --- get_owner_org_by_app_id_internal without granting EXECUTE to anon/authenticated. -CREATE OR REPLACE FUNCTION "public"."auto_owner_org_by_app_id"() RETURNS "trigger" - LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO '' -AS $$ -BEGIN - IF NEW."app_id" IS DISTINCT FROM OLD."app_id" AND OLD."app_id" IS DISTINCT FROM NULL THEN - RAISE EXCEPTION 'changing the app_id is not allowed'; - END IF; - - NEW.owner_org = public.get_owner_org_by_app_id_internal(NEW."app_id"); - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."auto_owner_org_by_app_id"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."auto_owner_org_by_app_id"() FROM PUBLIC; diff --git a/supabase/migrations/20260508135918_enforce_channel_promotion_permission.sql b/supabase/migrations/20260508135918_enforce_channel_promotion_permission.sql deleted file mode 100644 index 1ed6e7e08e..0000000000 --- a/supabase/migrations/20260508135918_enforce_channel_promotion_permission.sql +++ /dev/null @@ -1,44 +0,0 @@ -CREATE OR REPLACE FUNCTION public.enforce_channel_version_promotion_permission() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_request_role text := COALESCE(auth.role(), session_user); -BEGIN - IF NEW.version IS NOT DISTINCT FROM OLD.version THEN - RETURN NEW; - END IF; - - IF v_request_role IN ('service_role', 'postgres') THEN - RETURN NEW; - END IF; - - IF v_request_role IS DISTINCT FROM 'anon' AND v_request_role IS DISTINCT FROM 'authenticated' THEN - RAISE EXCEPTION 'PERMISSION_DENIED_CHANNEL_PROMOTE_BUNDLE' - USING ERRCODE = '42501'; - END IF; - - IF NOT public.rbac_check_permission_request( - public.rbac_perm_channel_promote_bundle(), - OLD.owner_org, - OLD.app_id, - OLD.id - ) THEN - RAISE EXCEPTION 'PERMISSION_DENIED_CHANNEL_PROMOTE_BUNDLE' - USING ERRCODE = '42501'; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.enforce_channel_version_promotion_permission() OWNER TO postgres; -REVOKE ALL ON FUNCTION public.enforce_channel_version_promotion_permission() FROM PUBLIC; - -DROP TRIGGER IF EXISTS enforce_channel_version_promotion_permission ON public.channels; -CREATE TRIGGER enforce_channel_version_promotion_permission -BEFORE UPDATE OF version ON public.channels -FOR EACH ROW -EXECUTE FUNCTION public.enforce_channel_version_promotion_permission(); diff --git a/supabase/migrations/20260510103516_stats_health_events_metadata.sql b/supabase/migrations/20260510103516_stats_health_events_metadata.sql deleted file mode 100644 index bc00abd370..0000000000 --- a/supabase/migrations/20260510103516_stats_health_events_metadata.sql +++ /dev/null @@ -1,16 +0,0 @@ -ALTER TABLE public.stats ADD COLUMN IF NOT EXISTS metadata jsonb; - -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'app_crash'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'app_crash_native'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'app_anr'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'app_killed_low_memory'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'app_killed_excessive_resource_usage'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'app_initialization_failure'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'app_memory_warning'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'webview_javascript_error'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'webview_unhandled_rejection'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'webview_resource_error'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'webview_security_policy_violation'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'webview_unclean_restart'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'webview_render_process_gone'; -ALTER TYPE public.stats_action ADD VALUE IF NOT EXISTS 'webview_content_process_terminated'; diff --git a/supabase/migrations/20260510161104_build_timeout_seconds.sql b/supabase/migrations/20260510161104_build_timeout_seconds.sql deleted file mode 100644 index 35ad1a6e75..0000000000 --- a/supabase/migrations/20260510161104_build_timeout_seconds.sql +++ /dev/null @@ -1,43 +0,0 @@ -ALTER TABLE "public"."apps" -ADD COLUMN IF NOT EXISTS "build_timeout_seconds" bigint DEFAULT 900 NOT NULL; - -ALTER TABLE "public"."apps" -ADD COLUMN IF NOT EXISTS "build_timeout_updated_at" timestamp with time zone DEFAULT now() NOT NULL; - -ALTER TABLE "public"."apps" -ADD CONSTRAINT "apps_build_timeout_seconds_check" -CHECK ("build_timeout_seconds" >= 300 AND "build_timeout_seconds" <= 21600); - -COMMENT ON COLUMN "public"."apps"."build_timeout_seconds" IS 'Maximum native cloud build runtime in seconds before the job is cancelled and billable time is capped.'; - -COMMENT ON COLUMN "public"."apps"."build_timeout_updated_at" IS 'Timestamp when the native cloud build timeout setting last changed.'; - -CREATE OR REPLACE FUNCTION "public"."update_apps_build_timeout_updated_at"() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - IF TG_OP = 'INSERT' THEN - NEW."build_timeout_updated_at" := COALESCE(NEW."build_timeout_updated_at", now()); - ELSIF NEW."build_timeout_seconds" IS DISTINCT FROM OLD."build_timeout_seconds" THEN - NEW."build_timeout_updated_at" := now(); - ELSE - NEW."build_timeout_updated_at" := OLD."build_timeout_updated_at"; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."update_apps_build_timeout_updated_at"() OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."update_apps_build_timeout_updated_at"() FROM PUBLIC; - -DROP TRIGGER IF EXISTS "update_apps_build_timeout_updated_at" ON "public"."apps"; - -CREATE TRIGGER "update_apps_build_timeout_updated_at" -BEFORE INSERT OR UPDATE ON "public"."apps" -FOR EACH ROW -EXECUTE FUNCTION "public"."update_apps_build_timeout_updated_at"(); diff --git a/supabase/migrations/20260510171814_native_build_concurrency_plan_limit.sql b/supabase/migrations/20260510171814_native_build_concurrency_plan_limit.sql deleted file mode 100644 index 0c90e7c31c..0000000000 --- a/supabase/migrations/20260510171814_native_build_concurrency_plan_limit.sql +++ /dev/null @@ -1,85 +0,0 @@ -ALTER TABLE "public"."plans" -ADD COLUMN "native_build_concurrency" integer DEFAULT 2 NOT NULL; - -UPDATE "public"."plans" -SET "native_build_concurrency" = 2 -WHERE "name" = 'Solo'; - -UPDATE "public"."plans" -SET "native_build_concurrency" = 3 -WHERE "name" = 'Maker'; - -UPDATE "public"."plans" -SET "native_build_concurrency" = 4 -WHERE "name" = 'Team'; - -UPDATE "public"."plans" -SET "native_build_concurrency" = 6 -WHERE "name" = 'Enterprise'; - -ALTER TABLE "public"."plans" -ADD CONSTRAINT "plans_native_build_concurrency_positive" -CHECK ("native_build_concurrency" > 0); - -COMMENT ON COLUMN "public"."plans"."native_build_concurrency" IS 'Maximum number of active native builds allowed concurrently for this plan.'; - -DROP FUNCTION IF EXISTS "public"."get_current_plan_max_org"("orgid" "uuid"); - -CREATE OR REPLACE FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") -RETURNS TABLE( - "mau" bigint, - "bandwidth" bigint, - "storage" bigint, - "build_time_unit" bigint, - "native_build_concurrency" integer -) -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_request_user uuid; - v_request_role text; - v_is_internal boolean; -BEGIN - SELECT public.current_request_role() INTO v_request_role; - - v_is_internal := public.is_internal_request_role(v_request_role); - - IF NOT v_is_internal THEN - v_request_user := public.get_identity_org_allowed( - public.request_read_key_modes(), - get_current_plan_max_org.orgid - ); - - IF NOT public.request_has_org_read_access(get_current_plan_max_org.orgid) THEN - PERFORM public.pg_log( - 'deny: NO_RIGHTS', - pg_catalog.jsonb_build_object( - 'orgid', - get_current_plan_max_org.orgid, - 'uid', - v_request_user - ) - ); - RETURN; - END IF; - END IF; - - RETURN QUERY - SELECT - p.mau, - p.bandwidth, - p.storage, - p.build_time_unit, - p.native_build_concurrency - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - JOIN public.plans p ON si.product_id = p.stripe_id - WHERE o.id = orgid; -END; -$$; - -ALTER FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") TO "authenticated"; -GRANT ALL ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") TO "service_role"; diff --git a/supabase/migrations/20260510183000_add_build_runner_wait_seconds.sql b/supabase/migrations/20260510183000_add_build_runner_wait_seconds.sql deleted file mode 100644 index afb96a26ac..0000000000 --- a/supabase/migrations/20260510183000_add_build_runner_wait_seconds.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE public.build_requests -ADD COLUMN IF NOT EXISTS runner_wait_seconds bigint NOT NULL DEFAULT 0; - -COMMENT ON COLUMN public.build_requests.runner_wait_seconds IS 'Self-hosted runner wait time reported by builder, in seconds. Informational only; not used for billing.'; diff --git a/supabase/migrations/20260510190432_fix_apikey_rbac_password_policy_gate.sql b/supabase/migrations/20260510190432_fix_apikey_rbac_password_policy_gate.sql deleted file mode 100644 index 49d3afc45b..0000000000 --- a/supabase/migrations/20260510190432_fix_apikey_rbac_password_policy_gate.sql +++ /dev/null @@ -1,157 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean -LANGUAGE "plpgsql" -SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_perm text; - v_scope text; - v_apikey text; - v_use_rbac boolean; - v_effective_org_id uuid := org_id; - v_app_owner_org uuid; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; -BEGIN - -- Existing apps are always authorized in the app owner's org scope. - -- Keep nonexistent apps on the caller org so API handlers can still return their - -- own not-found errors after a valid org-level check. - IF app_id IS NOT NULL THEN - SELECT owner_org INTO v_app_owner_org - FROM public.apps - WHERE public.apps.app_id = check_min_rights.app_id - LIMIT 1; - - IF v_app_owner_org IS NOT NULL THEN - IF v_effective_org_id IS NOT NULL AND v_effective_org_id IS DISTINCT FROM v_app_owner_org THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APP_ORG_MISMATCH', jsonb_build_object( - 'org_id', v_effective_org_id, - 'app_owner_org', v_app_owner_org, - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - - v_effective_org_id := v_app_owner_org; - END IF; - END IF; - - -- Derive org from channel when not provided to honor org-level flag and scoping. - IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE public.channels.id = channel_id - LIMIT 1; - END IF; - - SELECT public.get_apikey_header() INTO v_apikey; - - -- RBAC-managed API keys have apikeys.mode = NULL, so get_identity_org_appid() - -- returns NULL and rbac_check_permission_direct() must resolve the key before - -- org identity gates can be evaluated. - IF v_effective_org_id IS NOT NULL AND NOT (v_apikey IS NOT NULL AND user_id IS NULL) THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (user_id IS NULL OR NOT public.has_2fa_enabled(user_id)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - - v_password_policy_ok := public.user_meets_password_policy(user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); - IF NOT v_use_rbac THEN - RETURN public.check_min_rights_legacy(min_right, user_id, COALESCE(org_id, v_effective_org_id), app_id, channel_id); - END IF; - - IF channel_id IS NOT NULL THEN - v_scope := public.rbac_scope_channel(); - ELSIF app_id IS NOT NULL THEN - v_scope := public.rbac_scope_app(); - ELSE - v_scope := public.rbac_scope_org(); - END IF; - - v_perm := public.rbac_permission_for_legacy(min_right, v_scope); - - -- Keep RLS authorization semantics aligned with explicit RBAC checks. In - -- particular, an API key with direct role bindings must be evaluated as the - -- API-key principal and must not inherit broader owner-user permissions. - RETURN public.rbac_check_permission_direct( - v_perm, - user_id, - v_effective_org_id, - app_id, - channel_id, - v_apikey - ); -END; -$$; - -ALTER FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) FROM PUBLIC; - -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "anon"; - -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "authenticated"; - -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) TO "service_role"; diff --git a/supabase/migrations/20260510191550_add_paid_product_activity_to_global_stats.sql b/supabase/migrations/20260510191550_add_paid_product_activity_to_global_stats.sql deleted file mode 100644 index b447be6901..0000000000 --- a/supabase/migrations/20260510191550_add_paid_product_activity_to_global_stats.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE public.global_stats -ADD COLUMN IF NOT EXISTS builder_active_paying_clients_60d integer DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS live_updates_active_paying_clients_60d integer DEFAULT 0 NOT NULL; - -COMMENT ON COLUMN public.global_stats.builder_active_paying_clients_60d IS 'Number of paying clients with Capgo Builder activity in the trailing 60 days for the UTC day.'; -COMMENT ON COLUMN public.global_stats.live_updates_active_paying_clients_60d IS 'Number of paying clients with Live Updates activity in the trailing 60 days for the UTC day.'; diff --git a/supabase/migrations/20260510214140_org_initial_plan_solo_mau_limit.sql b/supabase/migrations/20260510214140_org_initial_plan_solo_mau_limit.sql deleted file mode 100644 index 0b1c99f7e5..0000000000 --- a/supabase/migrations/20260510214140_org_initial_plan_solo_mau_limit.sql +++ /dev/null @@ -1,3 +0,0 @@ -UPDATE "public"."plans" -SET "mau" = 2000 -WHERE "name" = 'Solo'; diff --git a/supabase/migrations/20260510214806_add_plan_conversion_rates_to_global_stats.sql b/supabase/migrations/20260510214806_add_plan_conversion_rates_to_global_stats.sql deleted file mode 100644 index d89bff6890..0000000000 --- a/supabase/migrations/20260510214806_add_plan_conversion_rates_to_global_stats.sql +++ /dev/null @@ -1,10 +0,0 @@ -ALTER TABLE public.global_stats -ADD COLUMN IF NOT EXISTS plan_solo_conversion_rate double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS plan_maker_conversion_rate double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS plan_team_conversion_rate double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS plan_enterprise_conversion_rate double precision DEFAULT 0 NOT NULL; - -COMMENT ON COLUMN public.global_stats.plan_solo_conversion_rate IS 'Percentage of organizations converted to the Solo plan (plan_solo / orgs * 100)'; -COMMENT ON COLUMN public.global_stats.plan_maker_conversion_rate IS 'Percentage of organizations converted to the Maker plan (plan_maker / orgs * 100)'; -COMMENT ON COLUMN public.global_stats.plan_team_conversion_rate IS 'Percentage of organizations converted to the Team plan (plan_team / orgs * 100)'; -COMMENT ON COLUMN public.global_stats.plan_enterprise_conversion_rate IS 'Percentage of organizations converted to the Enterprise plan (plan_enterprise / orgs * 100)'; diff --git a/supabase/migrations/20260510235542_add_plan_total_conversion_rate.sql b/supabase/migrations/20260510235542_add_plan_total_conversion_rate.sql deleted file mode 100644 index 2fa7c9a15e..0000000000 --- a/supabase/migrations/20260510235542_add_plan_total_conversion_rate.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE public.global_stats -ADD COLUMN IF NOT EXISTS plan_total_conversion_rate double precision DEFAULT 0 NOT NULL; - -COMMENT ON COLUMN public.global_stats.plan_total_conversion_rate IS 'Percentage of organizations converted to any paid plan ((plan_solo + plan_maker + plan_team + plan_enterprise) / orgs * 100)'; diff --git a/supabase/migrations/20260511101826_add_ltv_global_stats.sql b/supabase/migrations/20260511101826_add_ltv_global_stats.sql deleted file mode 100644 index 344fd6c05c..0000000000 --- a/supabase/migrations/20260511101826_add_ltv_global_stats.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE public.global_stats -ADD COLUMN IF NOT EXISTS average_ltv double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS shortest_ltv double precision DEFAULT 0 NOT NULL, -ADD COLUMN IF NOT EXISTS longest_ltv double precision DEFAULT 0 NOT NULL; - -COMMENT ON COLUMN public.global_stats.average_ltv IS -'Average estimated customer LTV in dollars for the daily snapshot.'; -COMMENT ON COLUMN public.global_stats.shortest_ltv IS -'Lowest estimated customer LTV in dollars for the daily snapshot.'; -COMMENT ON COLUMN public.global_stats.longest_ltv IS -'Highest estimated customer LTV in dollars for the daily snapshot.'; diff --git a/supabase/migrations/20260511151503_fix_get_organization_cli_warnings_rbac.sql b/supabase/migrations/20260511151503_fix_get_organization_cli_warnings_rbac.sql deleted file mode 100644 index c35a684a7d..0000000000 --- a/supabase/migrations/20260511151503_fix_get_organization_cli_warnings_rbac.sql +++ /dev/null @@ -1,45 +0,0 @@ --- Fix get_organization_cli_warnings so RBAC v2 API keys (NULL mode, permissions --- via role_bindings) pass the org.read check. Previously this function relied on --- get_identity_apikey_only({write,all,upload,read}) which returns NULL when --- apikeys.mode IS NULL, making check_min_rights return false even for keys with --- valid RBAC bindings. Swap to cli_check_permission, the canonical CLI-facing --- auth oracle that handles header read, expiry, and both legacy + RBAC keys. - -CREATE OR REPLACE FUNCTION "public"."get_organization_cli_warnings" ( - "orgid" uuid, - "cli_version" text -) RETURNS jsonb[] -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - messages jsonb[] := ARRAY[]::jsonb[]; -BEGIN - PERFORM cli_version; - - IF NOT public.cli_check_permission( - permission_key := public.rbac_perm_org_read(), - org_id := orgid - ) THEN - messages := array_append(messages, jsonb_build_object( - 'message', 'API key does not have read access to this organization', - 'fatal', true - )); - RETURN messages; - END IF; - - IF ( - public.is_paying_and_good_plan_org_action(orgid, ARRAY['mau']::public.action_type[]) = true - AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::public.action_type[]) = true - AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::public.action_type[]) = false - ) THEN - messages := array_append(messages, jsonb_build_object( - 'message', 'You have exceeded your storage limit.\nUpload will fail, but you can still download your data.\nMAU and bandwidth limits are not exceeded.\nIn order to upload your plan, please upgrade your plan here: https://console.capgo.app/settings/plans.', - 'fatal', true - )); - END IF; - - RETURN messages; -END; -$$; diff --git a/supabase/migrations/20260513000348_add_audit_log_retention_cron.sql b/supabase/migrations/20260513000348_add_audit_log_retention_cron.sql deleted file mode 100644 index 91c650d6f3..0000000000 --- a/supabase/migrations/20260513000348_add_audit_log_retention_cron.sql +++ /dev/null @@ -1,71 +0,0 @@ --- Ensure audit_logs retention stays at 90 days and is registered in the --- table-driven cron runner. -CREATE OR REPLACE FUNCTION public.cleanup_old_audit_logs() -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - DELETE FROM "public"."audit_logs" - WHERE created_at < pg_catalog.now() - INTERVAL '90 days'; -END; -$$; - -ALTER FUNCTION public.cleanup_old_audit_logs() OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.cleanup_old_audit_logs() FROM public; -REVOKE ALL ON FUNCTION public.cleanup_old_audit_logs() FROM anon; -REVOKE ALL ON FUNCTION public.cleanup_old_audit_logs() FROM authenticated; -GRANT ALL ON FUNCTION public.cleanup_old_audit_logs() TO service_role; - -INSERT INTO public.cron_tasks ( - name, - description, - task_type, - target, - batch_size, - payload, - second_interval, - minute_interval, - hour_interval, - run_at_hour, - run_at_minute, - run_at_second, - run_on_dow, - run_on_day, - enabled -) -VALUES ( - 'cleanup_old_audit_logs', - 'Delete audit_logs older than 90 days', - 'function'::public.cron_task_type, - 'public.cleanup_old_audit_logs()', - NULL, - NULL, - NULL, - NULL, - NULL, - 3, - 0, - 0, - NULL, - NULL, - TRUE -) -ON CONFLICT (name) DO UPDATE SET - description = excluded.description, - task_type = excluded.task_type, - target = excluded.target, - batch_size = excluded.batch_size, - payload = excluded.payload, - second_interval = excluded.second_interval, - minute_interval = excluded.minute_interval, - hour_interval = excluded.hour_interval, - run_at_hour = excluded.run_at_hour, - run_at_minute = excluded.run_at_minute, - run_at_second = excluded.run_at_second, - run_on_dow = excluded.run_on_dow, - run_on_day = excluded.run_on_day, - enabled = excluded.enabled, - updated_at = pg_catalog.now(); diff --git a/supabase/migrations/20260513152636_replace_manifest_cleanup_index.sql b/supabase/migrations/20260513152636_replace_manifest_cleanup_index.sql deleted file mode 100644 index 31ba18cde9..0000000000 --- a/supabase/migrations/20260513152636_replace_manifest_cleanup_index.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Keep the manifest cleanup lookup indexed without carrying the app_version_id --- suffix from the old index. The cleanup path filters by file_hash and --- file_name only when checking whether a deleted manifest object is still --- referenced by another version. -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_manifest_file_hash -ON public.manifest USING btree (file_hash); - --- Supabase applies migrations through a pipeline that rejects DROP INDEX --- CONCURRENTLY, so this drop intentionally uses the regular form. -DROP INDEX IF EXISTS public.idx_manifest_file_name_hash_version; diff --git a/supabase/migrations/20260514093535_app_versions_r2_path_index.sql b/supabase/migrations/20260514093535_app_versions_r2_path_index.sql deleted file mode 100644 index 5454cf900b..0000000000 --- a/supabase/migrations/20260514093535_app_versions_r2_path_index.sql +++ /dev/null @@ -1,36 +0,0 @@ -CREATE INDEX CONCURRENTLY IF NOT EXISTS app_versions_r2_path_idx ON public.app_versions USING btree (r2_path); - -ALTER TABLE public.app_versions -SET - ( - autovacuum_vacuum_scale_factor = 0.05, - autovacuum_analyze_scale_factor = 0.02 - ); - -ALTER TABLE public.daily_mau -SET - ( - autovacuum_vacuum_scale_factor = 0.05, - autovacuum_analyze_scale_factor = 0.02 - ); - -ALTER TABLE public.daily_storage -SET - ( - autovacuum_vacuum_scale_factor = 0.05, - autovacuum_analyze_scale_factor = 0.02 - ); - -ALTER TABLE public.daily_bandwidth -SET - ( - autovacuum_vacuum_scale_factor = 0.05, - autovacuum_analyze_scale_factor = 0.02 - ); - -ALTER TABLE public.daily_version -SET - ( - autovacuum_vacuum_scale_factor = 0.05, - autovacuum_analyze_scale_factor = 0.02 - ); diff --git a/supabase/migrations/20260514102952_enforce_90_day_deleted_versions_cleanup.sql b/supabase/migrations/20260514102952_enforce_90_day_deleted_versions_cleanup.sql deleted file mode 100644 index fe97d0eb2f..0000000000 --- a/supabase/migrations/20260514102952_enforce_90_day_deleted_versions_cleanup.sql +++ /dev/null @@ -1,78 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."delete_old_deleted_versions"() -RETURNS "void" -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - deleted_count bigint; -BEGIN - DELETE FROM "public"."app_versions" - WHERE "app_versions"."deleted" = true - AND "app_versions"."deleted_at" IS NOT NULL - AND "app_versions"."deleted_at" <= pg_catalog.now() - INTERVAL '90 days' - AND "app_versions"."name" NOT IN ('builtin', 'unknown') - AND "app_versions"."manifest_count" = 0 - AND ( - "app_versions"."r2_path" IS NULL - OR EXISTS ( - SELECT 1 - FROM "public"."app_versions_meta" - WHERE "app_versions_meta"."id" = "app_versions"."id" - AND "app_versions_meta"."size" = 0 - ) - ) - AND NOT EXISTS ( - SELECT 1 - FROM "public"."channels" - WHERE "channels"."version" = "app_versions"."id" - ); - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - IF deleted_count > 0 THEN - RAISE NOTICE 'delete_old_deleted_versions: permanently deleted % app versions', deleted_count; - END IF; -END; -$$; - -ALTER FUNCTION "public"."delete_old_deleted_versions"() OWNER TO "postgres"; - -COMMENT ON FUNCTION "public"."delete_old_deleted_versions"() IS - 'Permanently deletes app_versions that have been soft-deleted for at least 90 days after storage cleanup is reflected in app_versions_meta and app_versions.manifest_count.'; - -REVOKE ALL ON FUNCTION "public"."delete_old_deleted_versions"() FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."delete_old_deleted_versions"() FROM anon; -REVOKE ALL ON FUNCTION "public"."delete_old_deleted_versions"() FROM authenticated; -GRANT EXECUTE ON FUNCTION "public"."delete_old_deleted_versions"() TO service_role; - -DO $$ -DECLARE - updated_count bigint; -BEGIN - UPDATE "public"."cron_tasks" - SET - "description" = 'Permanently delete app versions 90 days after soft delete', - "task_type" = 'function'::"public"."cron_task_type", - "target" = 'public.delete_old_deleted_versions()', - "batch_size" = NULL, - "payload" = NULL, - "second_interval" = NULL, - "minute_interval" = NULL, - "hour_interval" = NULL, - "run_at_hour" = 3, - "run_at_minute" = 0, - "run_at_second" = 0, - "run_on_dow" = NULL, - "run_on_day" = NULL, - "enabled" = true, - "updated_at" = pg_catalog.now() - WHERE "name" = 'delete_old_versions'; - - GET DIAGNOSTICS updated_count = ROW_COUNT; - - IF updated_count = 0 THEN - RAISE EXCEPTION 'cron_tasks row with name = delete_old_versions not found'; - END IF; -END; -$$; diff --git a/supabase/migrations/20260515170516_drop_redundant_channel_devices_unique_constraint.sql b/supabase/migrations/20260515170516_drop_redundant_channel_devices_unique_constraint.sql deleted file mode 100644 index d28150c0cb..0000000000 --- a/supabase/migrations/20260515170516_drop_redundant_channel_devices_unique_constraint.sql +++ /dev/null @@ -1,4 +0,0 @@ --- channel_devices_app_id_device_id_key already enforces one device per app. --- Keep channel_devices_device_id_idx for device_id-only lookup paths. -ALTER TABLE "public"."channel_devices" - DROP CONSTRAINT IF EXISTS "unique_device_app"; diff --git a/supabase/migrations/20260516151507_fix_cli_warnings_app_scoped_apikeys.sql b/supabase/migrations/20260516151507_fix_cli_warnings_app_scoped_apikeys.sql deleted file mode 100644 index 85b7751c43..0000000000 --- a/supabase/migrations/20260516151507_fix_cli_warnings_app_scoped_apikeys.sql +++ /dev/null @@ -1,90 +0,0 @@ --- App-scoped API keys are allowed to upload only when every permission check is --- evaluated with an app context. Existing CLIs call this warning RPC with only --- the org id, so bridge that org-level warning check through one allowed app in --- the same org when the request key has limited_to_apps. - -CREATE OR REPLACE FUNCTION public.get_organization_cli_warnings( - orgid uuid, - cli_version text -) RETURNS jsonb [] -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - messages jsonb[] := ARRAY[]::jsonb[]; - request_apikey text; - api_key public.apikeys%ROWTYPE; - fallback_app_id text; - has_org_read boolean; -BEGIN - PERFORM cli_version; - - has_org_read := public.cli_check_permission( - permission_key := public.rbac_perm_org_read(), - org_id := orgid - ); - - IF NOT has_org_read THEN - SELECT public.get_apikey_header() INTO request_apikey; - - IF request_apikey IS NOT NULL AND request_apikey <> '' THEN - SELECT * INTO api_key - FROM public.find_apikey_by_value(request_apikey) - LIMIT 1; - - IF api_key.id IS NOT NULL - AND COALESCE(array_length(api_key.limited_to_apps, 1), 0) > 0 - THEN - SELECT public.apps.app_id INTO fallback_app_id - FROM public.apps - WHERE public.apps.owner_org = orgid - AND public.apps.app_id = ANY(api_key.limited_to_apps) - ORDER BY public.apps.app_id - LIMIT 1; - - IF fallback_app_id IS NOT NULL THEN - has_org_read := public.cli_check_permission( - permission_key := public.rbac_perm_org_read(), - org_id := orgid, - app_id := fallback_app_id - ); - END IF; - END IF; - END IF; - END IF; - - IF NOT has_org_read THEN - messages := array_append(messages, jsonb_build_object( - 'message', 'API key does not have read access to this organization', - 'fatal', true - )); - RETURN messages; - END IF; - - IF ( - public.is_paying_and_good_plan_org_action(orgid, ARRAY['mau']::public.action_type[]) = true - AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::public.action_type[]) = true - AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::public.action_type[]) = false - ) THEN - messages := array_append(messages, jsonb_build_object( - 'message', 'You have exceeded your storage limit.\nUpload will fail, but you can still download your data.\nMAU and bandwidth limits are not exceeded.\nIn order to upload your plan, please upgrade your plan here: https://console.capgo.app/settings/plans.', - 'fatal', true - )); - END IF; - - RETURN messages; -END; -$$; - -ALTER FUNCTION public.get_organization_cli_warnings(uuid, text) -OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.get_organization_cli_warnings(uuid, text) -FROM public; -GRANT EXECUTE ON FUNCTION public.get_organization_cli_warnings(uuid, text) -TO anon; -GRANT EXECUTE ON FUNCTION public.get_organization_cli_warnings(uuid, text) -TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_organization_cli_warnings(uuid, text) -TO service_role; diff --git a/supabase/migrations/20260517102815_enforce_webhook_created_by.sql b/supabase/migrations/20260517102815_enforce_webhook_created_by.sql deleted file mode 100644 index 50dead9907..0000000000 --- a/supabase/migrations/20260517102815_enforce_webhook_created_by.sql +++ /dev/null @@ -1,105 +0,0 @@ --- Existing webhooks created from the dashboard could omit created_by because the --- column was nullable and the direct Supabase insert path did not populate it. -UPDATE "public"."webhooks" AS "webhook" -SET "created_by" = "orgs"."created_by" -FROM "public"."orgs" AS "orgs" -WHERE "webhook"."org_id" = "orgs"."id" - AND "webhook"."created_by" IS NULL; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM "public"."webhooks" - WHERE "created_by" IS NULL - ) THEN - RAISE EXCEPTION 'Cannot enforce webhooks.created_by NOT NULL while null rows remain'; - END IF; -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."set_webhook_created_by"() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - "creator_id" uuid; -BEGIN - IF (SELECT "public"."get_apikey_header"()) IS NOT NULL THEN - "creator_id" := "public"."get_identity_org_allowed_apikey_only"( - '{all,write,upload}'::"public"."key_mode"[], - NEW."org_id" - ); - ELSE - "creator_id" := "auth"."uid"(); - END IF; - - IF "creator_id" IS NOT NULL THEN - NEW."created_by" := "creator_id"; - ELSIF NEW."created_by" IS NULL THEN - SELECT "orgs"."created_by" - INTO "creator_id" - FROM "public"."orgs" AS "orgs" - WHERE "orgs"."id" = NEW."org_id"; - - NEW."created_by" := "creator_id"; - END IF; - - IF NEW."created_by" IS NULL THEN - RAISE EXCEPTION 'webhooks.created_by cannot be null'; - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."set_webhook_created_by"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."set_webhook_created_by"() FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."set_webhook_created_by"() TO "service_role"; - -DROP TRIGGER IF EXISTS "set_webhook_created_by" ON "public"."webhooks"; -CREATE TRIGGER "set_webhook_created_by" -BEFORE INSERT ON "public"."webhooks" -FOR EACH ROW -EXECUTE FUNCTION "public"."set_webhook_created_by"(); - -CREATE OR REPLACE FUNCTION "public"."reassign_webhook_created_by_before_user_delete"() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- Preserve org-owned webhooks when a non-owner creator deletes their account. - UPDATE "public"."webhooks" AS "webhook" - SET "created_by" = "orgs"."created_by" - FROM "public"."orgs" AS "orgs" - WHERE "webhook"."org_id" = "orgs"."id" - AND "webhook"."created_by" = OLD."id" - AND "orgs"."created_by" <> OLD."id"; - - RETURN OLD; -END; -$$; - -ALTER FUNCTION "public"."reassign_webhook_created_by_before_user_delete"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."reassign_webhook_created_by_before_user_delete"() FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."reassign_webhook_created_by_before_user_delete"() TO "service_role"; - -DROP TRIGGER IF EXISTS "reassign_webhook_created_by_before_user_delete" ON "public"."users"; -CREATE TRIGGER "reassign_webhook_created_by_before_user_delete" -BEFORE DELETE ON "public"."users" -FOR EACH ROW -EXECUTE FUNCTION "public"."reassign_webhook_created_by_before_user_delete"(); - -ALTER TABLE "public"."webhooks" -DROP CONSTRAINT IF EXISTS "webhooks_created_by_fkey"; - -ALTER TABLE "public"."webhooks" -ALTER COLUMN "created_by" SET NOT NULL; - -ALTER TABLE "public"."webhooks" -ADD CONSTRAINT "webhooks_created_by_fkey" -FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE CASCADE; diff --git a/supabase/migrations/20260518120000_add_ai_analyzed_to_build_requests.sql b/supabase/migrations/20260518120000_add_ai_analyzed_to_build_requests.sql deleted file mode 100644 index 6477da3270..0000000000 --- a/supabase/migrations/20260518120000_add_ai_analyzed_to_build_requests.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE public.build_requests - ADD COLUMN ai_analyzed BOOLEAN NOT NULL DEFAULT FALSE; - -COMMENT ON COLUMN public.build_requests.ai_analyzed IS - 'Set true after a successful AI analysis of this failed build. Enforces one-analysis-per-job for cost control.'; diff --git a/supabase/migrations/20260518121000_standard_webhook_secrets.sql b/supabase/migrations/20260518121000_standard_webhook_secrets.sql deleted file mode 100644 index 1c47b1381d..0000000000 --- a/supabase/migrations/20260518121000_standard_webhook_secrets.sql +++ /dev/null @@ -1,170 +0,0 @@ --- Standard Webhooks compatibility and API-only table access. - -ALTER TABLE public.webhooks -ALTER COLUMN secret SET DEFAULT ( - 'whsec_'::text || encode(extensions.gen_random_bytes(32), 'base64') -); - -COMMENT ON COLUMN public.webhooks.secret IS -'Standard Webhooks HMAC-SHA256 secret in whsec_ base64 format.'; - -ALTER TABLE public.webhooks -ADD COLUMN IF NOT EXISTS delivery_version text NOT NULL DEFAULT 'legacy'; - -ALTER TABLE public.webhooks -DROP CONSTRAINT IF EXISTS webhooks_delivery_version_check; - -ALTER TABLE public.webhooks -ADD CONSTRAINT webhooks_delivery_version_check -CHECK (delivery_version ~ '^(legacy|standard)$'); - -COMMENT ON COLUMN public.webhooks.delivery_version IS -'Webhook delivery format version. legacy preserves existing Capgo payloads; standard uses Standard Webhooks payload and headers.'; - -ALTER TABLE public.webhook_deliveries -ADD COLUMN IF NOT EXISTS delivery_version text NOT NULL DEFAULT 'legacy'; - -ALTER TABLE public.webhook_deliveries -DROP CONSTRAINT IF EXISTS webhook_deliveries_delivery_version_check; - -ALTER TABLE public.webhook_deliveries -ADD CONSTRAINT webhook_deliveries_delivery_version_check -CHECK (delivery_version ~ '^(legacy|standard)$'); - -COMMENT ON COLUMN public.webhook_deliveries.delivery_version IS -'Delivery format version used for this webhook attempt.'; - -ALTER TABLE public.webhook_deliveries -ALTER COLUMN max_attempts SET DEFAULT 10; - -UPDATE public.webhook_deliveries -SET max_attempts = 10 -WHERE status = 'pending' - AND (max_attempts IS NULL OR max_attempts < 10); - --- Webhook secrets and delivery payloads must be accessed only through the API. --- Service-role jobs keep access for dispatch, delivery, and API handlers. -REVOKE ALL ON TABLE public.webhooks FROM anon; -REVOKE ALL ON TABLE public.webhooks FROM authenticated; -REVOKE ALL ON TABLE public.webhooks FROM public; -GRANT ALL ON TABLE public.webhooks TO service_role; - -REVOKE ALL ON TABLE public.webhook_deliveries FROM anon; -REVOKE ALL ON TABLE public.webhook_deliveries FROM authenticated; -REVOKE ALL ON TABLE public.webhook_deliveries FROM public; -GRANT ALL ON TABLE public.webhook_deliveries TO service_role; - -DROP POLICY IF EXISTS "Allow org members to select webhooks" -- noqa: RF05 -ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to select webhooks" -- noqa: RF05 -ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to insert webhooks" -- noqa: RF05 -ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to update webhooks" -- noqa: RF05 -ON public.webhooks; -DROP POLICY IF EXISTS "Allow admin to delete webhooks" -- noqa: RF05 -ON public.webhooks; -DROP POLICY IF EXISTS "Deny direct select on webhooks" -- noqa: RF05 -ON public.webhooks; -DROP POLICY IF EXISTS "Deny direct insert on webhooks" -- noqa: RF05 -ON public.webhooks; -DROP POLICY IF EXISTS "Deny direct update on webhooks" -- noqa: RF05 -ON public.webhooks; -DROP POLICY IF EXISTS "Deny direct delete on webhooks" -- noqa: RF05 -ON public.webhooks; -DROP POLICY IF EXISTS deny_direct_select_on_webhooks ON public.webhooks; -DROP POLICY IF EXISTS deny_direct_insert_on_webhooks ON public.webhooks; -DROP POLICY IF EXISTS deny_direct_update_on_webhooks ON public.webhooks; -DROP POLICY IF EXISTS deny_direct_delete_on_webhooks ON public.webhooks; - -CREATE POLICY deny_direct_select_on_webhooks -ON public.webhooks -AS RESTRICTIVE -FOR SELECT -TO anon, authenticated -USING (false); - -CREATE POLICY deny_direct_insert_on_webhooks -ON public.webhooks -AS RESTRICTIVE -FOR INSERT -TO anon, authenticated -WITH CHECK (false); - -CREATE POLICY deny_direct_update_on_webhooks -ON public.webhooks -AS RESTRICTIVE -FOR UPDATE -TO anon, authenticated -USING (false) -WITH CHECK (false); - -CREATE POLICY deny_direct_delete_on_webhooks -ON public.webhooks -AS RESTRICTIVE -FOR DELETE -TO anon, authenticated -USING (false); - -DROP POLICY IF EXISTS -"Allow org members to select webhook_deliveries" -- noqa: RF05 -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -"Allow admin to insert webhook_deliveries" -- noqa: RF05 -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -"Allow admin to update webhook_deliveries" -- noqa: RF05 -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -"Deny direct select on webhook_deliveries" -- noqa: RF05 -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -"Deny direct insert on webhook_deliveries" -- noqa: RF05 -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -"Deny direct update on webhook_deliveries" -- noqa: RF05 -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -"Deny direct delete on webhook_deliveries" -- noqa: RF05 -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -deny_direct_select_on_webhook_deliveries -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -deny_direct_insert_on_webhook_deliveries -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -deny_direct_update_on_webhook_deliveries -ON public.webhook_deliveries; -DROP POLICY IF EXISTS -deny_direct_delete_on_webhook_deliveries -ON public.webhook_deliveries; - -CREATE POLICY deny_direct_select_on_webhook_deliveries -ON public.webhook_deliveries -AS RESTRICTIVE -FOR SELECT -TO anon, authenticated -USING (false); - -CREATE POLICY deny_direct_insert_on_webhook_deliveries -ON public.webhook_deliveries -AS RESTRICTIVE -FOR INSERT -TO anon, authenticated -WITH CHECK (false); - -CREATE POLICY deny_direct_update_on_webhook_deliveries -ON public.webhook_deliveries -AS RESTRICTIVE -FOR UPDATE -TO anon, authenticated -USING (false) -WITH CHECK (false); - -CREATE POLICY deny_direct_delete_on_webhook_deliveries -ON public.webhook_deliveries -AS RESTRICTIVE -FOR DELETE -TO anon, authenticated -USING (false); diff --git a/supabase/migrations/20260518130000_plan_check_passthrough_appid.sql b/supabase/migrations/20260518130000_plan_check_passthrough_appid.sql deleted file mode 100644 index 2694ed2e29..0000000000 --- a/supabase/migrations/20260518130000_plan_check_passthrough_appid.sql +++ /dev/null @@ -1,143 +0,0 @@ --- Pass app_id through the plan-check RPC chain so RBAC's app-scope restriction --- in rbac_check_permission_direct gets the app context it needs when an API --- key has limited_to_apps set. Without this, an org-scope read by a key --- restricted to an app fails RBAC and surfaces in the CLI as the misleading --- "Plan upgrade required for upload" error, even when the plan is healthy. --- --- Strategy: add new 3-arg overloads alongside the existing 2-arg versions. --- Existing callers keep working unchanged; new callers (e.g. CLI upload path) --- can pass appid to thread it into check_min_rights. - -CREATE OR REPLACE FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -DECLARE - caller_role text; - org_customer_id text; - result boolean; - has_credits boolean; -BEGIN - SELECT current_setting('role', true) INTO caller_role; - - IF COALESCE(caller_role, '') NOT IN ('service_role', 'postgres', 'supabase_admin') THEN - IF NOT (public.check_min_rights( - 'read'::public.user_min_right, - (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_paying_and_good_plan_org_action.orgid)), - is_paying_and_good_plan_org_action.orgid, - is_paying_and_good_plan_org_action.appid, - NULL::bigint - )) THEN - RETURN false; - END IF; - END IF; - - SELECT EXISTS ( - SELECT 1 - FROM public.usage_credit_balances ucb - WHERE ucb.org_id = orgid - AND COALESCE(ucb.available_credits, 0) > 0 - ) INTO has_credits; - - IF has_credits THEN - RETURN true; - END IF; - - SELECT o.customer_id INTO org_customer_id - FROM public.orgs o - WHERE o.id = orgid; - - SELECT (si.trial_at > now()) OR (si.status = 'succeeded' AND NOT ( - (si.mau_exceeded AND 'mau' = ANY(actions)) - OR (si.storage_exceeded AND 'storage' = ANY(actions)) - OR (si.bandwidth_exceeded AND 'bandwidth' = ANY(actions)) - OR (si.build_time_exceeded AND 'build_time' = ANY(actions)) - )) - INTO result - FROM public.stripe_info si - WHERE si.customer_id = org_customer_id - LIMIT 1; - - RETURN COALESCE(result, false); -END; -$$; - -ALTER FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) FROM public; -REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) FROM anon; -REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) FROM authenticated; -GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) TO service_role; - -CREATE OR REPLACE FUNCTION public.is_allowed_action_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' AS $$ -BEGIN - RETURN public.is_paying_and_good_plan_org_action(orgid, actions, appid); -END; -$$; - -ALTER FUNCTION public.is_allowed_action_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) OWNER TO "postgres"; - -REVOKE ALL ON FUNCTION public.is_allowed_action_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) FROM public; --- The CLI connects with the Supabase anon key and authenticates per-call via --- the capgkey header, so PostgREST sets role = anon. The existing 2-arg --- overload grants EXECUTE to anon (see 20260427105151); the new 3-arg overload --- must match or the CLI upload path will fail with a permission error. -GRANT EXECUTE ON FUNCTION public.is_allowed_action_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) TO anon; -GRANT EXECUTE ON FUNCTION public.is_allowed_action_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) TO authenticated; -GRANT EXECUTE ON FUNCTION public.is_allowed_action_org_action( - "orgid" uuid, - "actions" public.action_type [], - "appid" character varying -) TO service_role; diff --git a/supabase/migrations/20260518131054_complete_onboarding_after_first_upload.sql b/supabase/migrations/20260518131054_complete_onboarding_after_first_upload.sql deleted file mode 100644 index 5321ad4871..0000000000 --- a/supabase/migrations/20260518131054_complete_onboarding_after_first_upload.sql +++ /dev/null @@ -1,254 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_app_id text; - v_owner_org uuid; - v_last_version text; - v_manifest_bundle_count bigint := 0; - v_channel_device_count bigint := 0; -BEGIN - SELECT app_id, owner_org - INTO v_app_id, v_owner_org - FROM public.apps - WHERE id = p_app_uuid; - - IF v_app_id IS NULL THEN - RETURN; - END IF; - - DELETE FROM public.channel_devices - WHERE app_id = v_app_id - AND ( - p_preserve_app_version_id IS NULL - OR NOT EXISTS ( - SELECT 1 - FROM public.channels - WHERE channels.id = channel_devices.channel_id - AND channels.version = p_preserve_app_version_id - ) - ); - - DELETE FROM public.deploy_history - WHERE app_id = v_app_id - AND ( - p_preserve_app_version_id IS NULL - OR version_id IS DISTINCT FROM p_preserve_app_version_id - ); - - DELETE FROM public.channels - WHERE app_id = v_app_id - AND ( - p_preserve_app_version_id IS NULL - OR version IS DISTINCT FROM p_preserve_app_version_id - ); - - DELETE FROM public.devices - WHERE app_id = v_app_id; - - DELETE FROM public.app_versions_meta - WHERE app_id = v_app_id - AND ( - p_preserve_app_version_id IS NULL - OR id IS DISTINCT FROM p_preserve_app_version_id - ); - - DELETE FROM public.daily_version - WHERE app_id = v_app_id; - - DELETE FROM public.daily_bandwidth - WHERE app_id = v_app_id; - - DELETE FROM public.daily_storage - WHERE app_id = v_app_id; - - DELETE FROM public.daily_mau - WHERE app_id = v_app_id; - - DELETE FROM public.daily_build_time - WHERE app_id = v_app_id; - - DELETE FROM public.build_requests - WHERE app_id = v_app_id; - - DELETE FROM public.app_versions - WHERE app_id = v_app_id - AND name NOT IN ('builtin', 'unknown') - AND ( - p_preserve_app_version_id IS NULL - OR id IS DISTINCT FROM p_preserve_app_version_id - ); - - INSERT INTO public.app_versions ( - owner_org, - deleted, - name, - app_id, - created_at - ) - VALUES - (v_owner_org, true, 'builtin', v_app_id, now()), - (v_owner_org, true, 'unknown', v_app_id, now()) - ON CONFLICT (name, app_id) DO UPDATE - SET - owner_org = EXCLUDED.owner_org, - deleted = true, - deleted_at = NULL, - checksum = NULL, - session_key = NULL, - r2_path = NULL, - link = NULL, - comment = NULL, - updated_at = now(); - - IF p_preserve_app_version_id IS NOT NULL THEN - SELECT name, CASE WHEN manifest_count > 0 THEN 1 ELSE 0 END - INTO v_last_version, v_manifest_bundle_count - FROM public.app_versions - WHERE id = p_preserve_app_version_id - AND app_id = v_app_id - AND deleted IS FALSE; - - SELECT COUNT(*)::bigint - INTO v_channel_device_count - FROM public.channel_devices - INNER JOIN public.channels - ON channels.id = channel_devices.channel_id - WHERE channel_devices.app_id = v_app_id - AND channels.version = p_preserve_app_version_id; - END IF; - - UPDATE public.apps - SET - channel_device_count = v_channel_device_count, - manifest_bundle_count = v_manifest_bundle_count, - last_version = v_last_version - WHERE id = p_app_uuid; - - IF v_owner_org IS NOT NULL THEN - DELETE FROM public.app_metrics_cache - WHERE org_id = v_owner_org; - END IF; -END; -$$; - -ALTER FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - PERFORM public.clear_onboarding_app_data(p_app_uuid, NULL::bigint); -END; -$$; - -ALTER FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() -RETURNS trigger -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_preserve_setting text; - v_preserve_app_version_id bigint; -BEGIN - IF OLD.need_onboarding IS TRUE AND NEW.need_onboarding IS FALSE THEN - v_preserve_setting := current_setting('capgo.onboarding_preserve_app_version_id', true); - v_preserve_app_version_id := NULLIF(v_preserve_setting, '')::bigint; - - PERFORM public.clear_onboarding_app_data(NEW.id, v_preserve_app_version_id); - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() FROM PUBLIC; - -DROP TRIGGER IF EXISTS "cleanup_onboarding_app_data_on_complete" ON "public"."apps"; - -CREATE TRIGGER "cleanup_onboarding_app_data_on_complete" -AFTER UPDATE OF "need_onboarding" -ON "public"."apps" -FOR EACH ROW -WHEN (OLD.need_onboarding IS TRUE AND NEW.need_onboarding IS FALSE) -EXECUTE FUNCTION "public"."cleanup_onboarding_app_data_on_complete"(); - -CREATE OR REPLACE FUNCTION "public"."complete_onboarding_after_first_upload"() -RETURNS trigger -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_app_uuid uuid; -BEGIN - IF NEW.name IN ('builtin', 'unknown') THEN - RETURN NEW; - END IF; - - IF COALESCE(NEW.deleted, false) IS TRUE THEN - RETURN NEW; - END IF; - - IF NOT ( - (NEW.storage_provider = 'external' AND NULLIF(BTRIM(COALESCE(NEW.external_url, '')), '') IS NOT NULL) - OR (NEW.storage_provider <> 'r2-direct' AND NULLIF(BTRIM(COALESCE(NEW.r2_path, '')), '') IS NOT NULL) - ) THEN - RETURN NEW; - END IF; - - SELECT id - INTO v_app_uuid - FROM public.apps - WHERE app_id = NEW.app_id - AND owner_org = NEW.owner_org - AND need_onboarding IS TRUE - LIMIT 1; - - IF v_app_uuid IS NULL THEN - RETURN NEW; - END IF; - - PERFORM set_config('capgo.onboarding_preserve_app_version_id', NEW.id::text, true); - - UPDATE public.apps - SET need_onboarding = false - WHERE id = v_app_uuid - AND need_onboarding IS TRUE; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."complete_onboarding_after_first_upload"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."complete_onboarding_after_first_upload"() FROM PUBLIC; - -DROP TRIGGER IF EXISTS "complete_onboarding_after_first_upload" ON "public"."app_versions"; - -CREATE TRIGGER "complete_onboarding_after_first_upload" -AFTER INSERT OR UPDATE OF "deleted", "external_url", "r2_path", "storage_provider", "name", "app_id", "owner_org" -ON "public"."app_versions" -FOR EACH ROW -WHEN ( - NEW.name NOT IN ('builtin', 'unknown') - AND COALESCE(NEW.deleted, false) IS FALSE - AND ( - (NEW.storage_provider = 'external' AND NULLIF(BTRIM(COALESCE(NEW.external_url, '')), '') IS NOT NULL) - OR (NEW.storage_provider <> 'r2-direct' AND NULLIF(BTRIM(COALESCE(NEW.r2_path, '')), '') IS NOT NULL) - ) -) -EXECUTE FUNCTION "public"."complete_onboarding_after_first_upload"(); diff --git a/supabase/migrations/20260519065534_revert_complete_onboarding_after_first_upload_trigger.sql b/supabase/migrations/20260519065534_revert_complete_onboarding_after_first_upload_trigger.sql deleted file mode 100644 index 21e565b3b7..0000000000 --- a/supabase/migrations/20260519065534_revert_complete_onboarding_after_first_upload_trigger.sql +++ /dev/null @@ -1,199 +0,0 @@ --- Closes the onboarding-cleanup data-loss cascade introduced by --- 20260518131054 (PR #2291). See issue #2295 for the full incident report. --- --- Two changes: --- --- 1. Drop the `complete_onboarding_after_first_upload` trigger and its --- function. That trigger flipped `apps.need_onboarding = FALSE` on the --- first real bundle upload, which fires the pre-existing --- `cleanup_onboarding_app_data_on_complete` trigger and cascades into --- `clear_onboarding_app_data()` -- wiping channels, bundles, devices, --- deploy history, and daily metrics for the app. Any app where --- `need_onboarding` was still TRUE (i.e. provisioned via dashboard or --- CI without ever running `capgo init`) was silently armed. --- --- 2. Add a production-safety guard to `clear_onboarding_app_data` so it --- refuses to delete data for apps that show any sign of real --- production use. This is defense-in-depth: even if some other code --- path (now or in the future) flips `need_onboarding` -> FALSE on a --- real app (e.g. `capgo init` against a long-lived dashboard-created --- app), the cleanup itself is now safe -- it will RAISE WARNING and --- return without deleting anything. --- --- The guard checks four independent signals; any one of them means the --- app is not a fresh onboarding placeholder: --- * any row in `public.devices` (a real client registered) --- * any row in `public.channel_devices` (a device is on a channel) --- * any row in `public.deploy_history` beyond the preserved version --- * any channel whose `version` points at a bundle other than the --- preserved one (a real channel is wired to bundle history) - -DROP TRIGGER IF EXISTS "complete_onboarding_after_first_upload" ON "public"."app_versions"; - -DROP FUNCTION IF EXISTS "public"."complete_onboarding_after_first_upload"(); - -CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_app_id text; - v_owner_org uuid; - v_last_version text; - v_manifest_bundle_count bigint := 0; - v_channel_device_count bigint := 0; -BEGIN - SELECT app_id, owner_org - INTO v_app_id, v_owner_org - FROM public.apps - WHERE id = p_app_uuid; - - IF v_app_id IS NULL THEN - RETURN; - END IF; - - -- Production-safety guard (issue #2295). Refuse to delete data for - -- apps that look like real production. Any of these indicates the app - -- is not a fresh onboarding placeholder. - IF EXISTS ( - SELECT 1 FROM public.devices WHERE app_id = v_app_id - ) OR EXISTS ( - SELECT 1 FROM public.channel_devices WHERE app_id = v_app_id - ) OR EXISTS ( - SELECT 1 FROM public.deploy_history - WHERE app_id = v_app_id - AND ( - p_preserve_app_version_id IS NULL - OR version_id IS DISTINCT FROM p_preserve_app_version_id - ) - ) OR EXISTS ( - SELECT 1 FROM public.channels - WHERE app_id = v_app_id - AND version IS NOT NULL - AND ( - p_preserve_app_version_id IS NULL - OR version IS DISTINCT FROM p_preserve_app_version_id - ) - ) THEN - RAISE WARNING - 'clear_onboarding_app_data: refusing to clear app % -- production indicators present (see issue #2295)', - v_app_id; - RETURN; - END IF; - - DELETE FROM public.channel_devices - WHERE app_id = v_app_id - AND ( - p_preserve_app_version_id IS NULL - OR NOT EXISTS ( - SELECT 1 - FROM public.channels - WHERE channels.id = channel_devices.channel_id - AND channels.version = p_preserve_app_version_id - ) - ); - - DELETE FROM public.deploy_history - WHERE app_id = v_app_id - AND ( - p_preserve_app_version_id IS NULL - OR version_id IS DISTINCT FROM p_preserve_app_version_id - ); - - DELETE FROM public.channels - WHERE app_id = v_app_id - AND ( - p_preserve_app_version_id IS NULL - OR version IS DISTINCT FROM p_preserve_app_version_id - ); - - DELETE FROM public.devices - WHERE app_id = v_app_id; - - DELETE FROM public.app_versions_meta - WHERE app_id = v_app_id - AND ( - p_preserve_app_version_id IS NULL - OR id IS DISTINCT FROM p_preserve_app_version_id - ); - - DELETE FROM public.daily_version - WHERE app_id = v_app_id; - - DELETE FROM public.daily_bandwidth - WHERE app_id = v_app_id; - - DELETE FROM public.daily_storage - WHERE app_id = v_app_id; - - DELETE FROM public.daily_mau - WHERE app_id = v_app_id; - - DELETE FROM public.daily_build_time - WHERE app_id = v_app_id; - - DELETE FROM public.build_requests - WHERE app_id = v_app_id; - - DELETE FROM public.app_versions - WHERE app_id = v_app_id - AND name NOT IN ('builtin', 'unknown') - AND ( - p_preserve_app_version_id IS NULL - OR id IS DISTINCT FROM p_preserve_app_version_id - ); - - INSERT INTO public.app_versions ( - owner_org, - deleted, - name, - app_id, - created_at - ) - VALUES - (v_owner_org, true, 'builtin', v_app_id, now()), - (v_owner_org, true, 'unknown', v_app_id, now()) - ON CONFLICT (name, app_id) DO UPDATE - SET - owner_org = EXCLUDED.owner_org, - deleted = true, - deleted_at = NULL, - checksum = NULL, - session_key = NULL, - r2_path = NULL, - link = NULL, - comment = NULL, - updated_at = now(); - - IF p_preserve_app_version_id IS NOT NULL THEN - SELECT name, CASE WHEN manifest_count > 0 THEN 1 ELSE 0 END - INTO v_last_version, v_manifest_bundle_count - FROM public.app_versions - WHERE id = p_preserve_app_version_id - AND app_id = v_app_id - AND deleted IS FALSE; - - SELECT COUNT(*)::bigint - INTO v_channel_device_count - FROM public.channel_devices - INNER JOIN public.channels - ON channels.id = channel_devices.channel_id - WHERE channel_devices.app_id = v_app_id - AND channels.version = p_preserve_app_version_id; - END IF; - - UPDATE public.apps - SET - channel_device_count = v_channel_device_count, - manifest_bundle_count = v_manifest_bundle_count, - last_version = v_last_version - WHERE id = p_app_uuid; - - IF v_owner_org IS NOT NULL THEN - DELETE FROM public.app_metrics_cache - WHERE org_id = v_owner_org; - END IF; -END; -$$; diff --git a/supabase/migrations/20260519123613_safe_demo_data_reset.sql b/supabase/migrations/20260519123613_safe_demo_data_reset.sql deleted file mode 100644 index 1c0347a79d..0000000000 --- a/supabase/migrations/20260519123613_safe_demo_data_reset.sql +++ /dev/null @@ -1,907 +0,0 @@ --- Make onboarding demo resets provenance-based. The reset path must only delete --- rows that were explicitly created by demo seeding; app-wide cleanup is too --- dangerous because real apps can stay in need_onboarding=true while already --- containing production data. - -DROP TRIGGER IF EXISTS "complete_onboarding_after_first_upload" ON "public"."app_versions"; - -DROP FUNCTION IF EXISTS "public"."complete_onboarding_after_first_upload"(); - -CREATE TABLE IF NOT EXISTS "public"."onboarding_demo_data" ( - "id" uuid DEFAULT "gen_random_uuid"() NOT NULL, - "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, - "app_id" character varying NOT NULL, - "owner_org" uuid NOT NULL, - "relation_name" text NOT NULL, - "row_key" text NOT NULL, - "seed_id" uuid NOT NULL, - CONSTRAINT "onboarding_demo_data_relation_name_check" CHECK ( - "relation_name" = ANY (ARRAY[ - 'app_versions'::text, - 'app_versions_meta'::text, - 'manifest'::text, - 'channels'::text, - 'channel_devices'::text, - 'deploy_history'::text, - 'devices'::text, - 'build_requests'::text - ]) - ) -); - -ALTER TABLE "public"."onboarding_demo_data" OWNER TO "postgres"; - -COMMENT ON TABLE "public"."onboarding_demo_data" IS 'Tracks rows created by onboarding demo seeding so demo resets can delete only demo-owned data.'; -COMMENT ON COLUMN "public"."onboarding_demo_data"."row_key" IS 'Primary-row identifier as text. Only exact rows created or confidently fingerprinted by onboarding demo seeding are tracked.'; - -ALTER TABLE ONLY "public"."onboarding_demo_data" - ADD CONSTRAINT "onboarding_demo_data_pkey" PRIMARY KEY ("id"); - -ALTER TABLE ONLY "public"."onboarding_demo_data" - ADD CONSTRAINT "onboarding_demo_data_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; - -ALTER TABLE ONLY "public"."onboarding_demo_data" - ADD CONSTRAINT "onboarding_demo_data_owner_org_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; - -CREATE UNIQUE INDEX IF NOT EXISTS "onboarding_demo_data_app_relation_row_key_idx" - ON "public"."onboarding_demo_data" USING "btree" ("app_id", "relation_name", "row_key"); - -CREATE INDEX IF NOT EXISTS "onboarding_demo_data_seed_id_idx" - ON "public"."onboarding_demo_data" USING "btree" ("seed_id"); - -ALTER TABLE "public"."onboarding_demo_data" ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Deny user access to onboarding demo data" ON "public"."onboarding_demo_data"; - -CREATE POLICY "Deny user access to onboarding demo data" -ON "public"."onboarding_demo_data" -AS RESTRICTIVE -FOR ALL -TO "anon", "authenticated" -USING (false) -WITH CHECK (false); - -REVOKE ALL ON TABLE "public"."onboarding_demo_data" FROM PUBLIC; -REVOKE ALL ON TABLE "public"."onboarding_demo_data" FROM "anon"; -REVOKE ALL ON TABLE "public"."onboarding_demo_data" FROM "authenticated"; -GRANT ALL ON TABLE "public"."onboarding_demo_data" TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."track_onboarding_demo_data"( - "p_app_id" text, - "p_owner_org" uuid, - "p_relation_name" text, - "p_row_keys" text[], - "p_seed_id" uuid -) -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - IF p_app_id IS NULL OR btrim(p_app_id) = '' THEN - RAISE EXCEPTION 'track_onboarding_demo_data: app_id is required'; - END IF; - - IF p_owner_org IS NULL THEN - RAISE EXCEPTION 'track_onboarding_demo_data: owner_org is required'; - END IF; - - IF p_seed_id IS NULL THEN - RAISE EXCEPTION 'track_onboarding_demo_data: seed_id is required'; - END IF; - - IF p_relation_name IS NULL OR NOT ( - p_relation_name = ANY (ARRAY[ - 'app_versions'::text, - 'app_versions_meta'::text, - 'manifest'::text, - 'channels'::text, - 'channel_devices'::text, - 'deploy_history'::text, - 'devices'::text, - 'build_requests'::text - ]) - ) THEN - RAISE EXCEPTION 'track_onboarding_demo_data: unsupported relation %', p_relation_name; - END IF; - - INSERT INTO "public"."onboarding_demo_data" ( - "app_id", - "owner_org", - "relation_name", - "row_key", - "seed_id" - ) - SELECT - p_app_id, - p_owner_org, - p_relation_name, - key_value, - p_seed_id - FROM "unnest"(p_row_keys) AS keys("key_value") - WHERE "key_value" IS NOT NULL - AND "btrim"("key_value") <> '' - ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE - SET - "owner_org" = EXCLUDED."owner_org", - "seed_id" = EXCLUDED."seed_id", - "created_at" = "now"(); -END; -$$; - -ALTER FUNCTION "public"."track_onboarding_demo_data"(text, uuid, text, text[], uuid) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."track_onboarding_demo_data"(text, uuid, text, text[], uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."track_onboarding_demo_data"(text, uuid, text, text[], uuid) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."track_onboarding_demo_data"(text, uuid, text, text[], uuid) FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."track_onboarding_demo_data"(text, uuid, text, text[], uuid) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."claim_legacy_onboarding_demo_data"("p_app_uuid" uuid) -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_app_id text; - v_owner_org uuid; - v_can_claim_full_seed boolean := false; -BEGIN - SELECT "app_id", "owner_org" - INTO v_app_id, v_owner_org - FROM "public"."apps" - WHERE "id" = p_app_uuid - AND "need_onboarding" IS TRUE; - - IF v_app_id IS NULL THEN - RETURN; - END IF; - - -- Legacy demo rows created before this provenance table had no durable owner - -- marker. Only claim rows with hard demo storage/build markers. Names alone - -- are not enough because customers can create normal 1.0.0/production rows. - INSERT INTO "public"."onboarding_demo_data" ( - "app_id", - "owner_org", - "relation_name", - "row_key", - "seed_id" - ) - SELECT - v_app_id, - v_owner_org, - 'manifest', - m."id"::text, - p_app_uuid - FROM "public"."manifest" m - INNER JOIN "public"."app_versions" av - ON av."id" = m."app_version_id" - WHERE av."app_id" = v_app_id - AND m."s3_path" LIKE ('demo/' || v_app_id || '/%') - ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE - SET - "owner_org" = EXCLUDED."owner_org", - "seed_id" = EXCLUDED."seed_id", - "created_at" = "now"(); - - INSERT INTO "public"."onboarding_demo_data" ( - "app_id", - "owner_org", - "relation_name", - "row_key", - "seed_id" - ) - SELECT - v_app_id, - v_owner_org, - 'build_requests', - br."id"::text, - p_app_uuid - FROM "public"."build_requests" br - WHERE br."app_id" = v_app_id - AND br."upload_session_key" LIKE 'demo-session-%' - AND br."upload_path" LIKE ('builds/' || v_app_id || '/%') - AND br."upload_url" LIKE ('https://demo-builds.example.com/' || v_app_id || '/%') - AND COALESCE(br."build_config"->>'bundleId', '') = v_app_id - AND ( - br."builder_job_id" LIKE 'demo-job-%' - OR br."builder_job_id" IS NULL - ) - ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE - SET - "owner_org" = EXCLUDED."owner_org", - "seed_id" = EXCLUDED."seed_id", - "created_at" = "now"(); - - SELECT - EXISTS ( - SELECT 1 - FROM "public"."manifest" m - INNER JOIN "public"."app_versions" av - ON av."id" = m."app_version_id" - WHERE av."app_id" = v_app_id - AND m."s3_path" LIKE ('demo/' || v_app_id || '/%') - ) - AND EXISTS ( - SELECT 1 - FROM "public"."build_requests" br - WHERE br."app_id" = v_app_id - AND br."upload_session_key" LIKE 'demo-session-%' - AND br."upload_url" LIKE ('https://demo-builds.example.com/' || v_app_id || '/%') - ) - AND NOT EXISTS ( - SELECT 1 - FROM "public"."app_versions" av - WHERE av."app_id" = v_app_id - AND av."name" NOT IN ('unknown', 'builtin', '1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0') - ) - AND NOT EXISTS ( - SELECT 1 - FROM "public"."manifest" m - INNER JOIN "public"."app_versions" av - ON av."id" = m."app_version_id" - WHERE av."app_id" = v_app_id - AND m."s3_path" NOT LIKE ('demo/' || v_app_id || '/%') - ) - AND NOT EXISTS ( - SELECT 1 - FROM "public"."channels" c - INNER JOIN "public"."app_versions" av - ON av."id" = c."version" - WHERE c."app_id" = v_app_id - AND NOT ( - c."disable_auto_update_under_native" IS TRUE - AND c."disable_auto_update" = 'major'::"public"."disable_update" - AND c."ios" IS TRUE - AND c."android" IS TRUE - AND c."electron" IS TRUE - AND c."allow_emulator" IS TRUE - AND c."allow_device" IS TRUE - AND c."allow_prod" IS TRUE - AND ( - ( - c."name" = 'production' - AND c."public" IS TRUE - AND c."allow_device_self_set" IS FALSE - AND c."allow_dev" IS FALSE - AND av."name" = '1.1.1' - ) - OR ( - c."name" = 'development' - AND c."public" IS FALSE - AND c."allow_device_self_set" IS FALSE - AND c."allow_dev" IS TRUE - AND av."name" = '1.2.0' - ) - OR ( - c."name" = 'pr-123' - AND c."public" IS FALSE - AND c."allow_device_self_set" IS TRUE - AND c."allow_dev" IS TRUE - AND av."name" = '1.2.0' - ) - ) - ) - ) - AND NOT EXISTS ( - SELECT 1 - FROM "public"."channel_devices" cd - WHERE cd."app_id" = v_app_id - ) - AND NOT EXISTS ( - SELECT 1 - FROM "public"."devices" d - WHERE d."app_id" = v_app_id - AND NOT ( - d."plugin_version" = '6.0.0' - AND d."version_name" = '1.1.1' - AND COALESCE(d."version_build", '') = '1' - AND d."platform" IN ('ios'::"public"."platform_os", 'android'::"public"."platform_os") - AND COALESCE(d."os_version", '') IN ('17.0', '14') - AND COALESCE(d."is_prod", false) IS TRUE - AND COALESCE(d."is_emulator", true) IS FALSE - ) - ) - AND NOT EXISTS ( - SELECT 1 - FROM "public"."build_requests" br - WHERE br."app_id" = v_app_id - AND NOT ( - br."upload_session_key" LIKE 'demo-session-%' - AND br."upload_path" LIKE ('builds/' || v_app_id || '/%') - AND br."upload_url" LIKE ('https://demo-builds.example.com/' || v_app_id || '/%') - AND COALESCE(br."build_config"->>'bundleId', '') = v_app_id - AND ( - br."builder_job_id" LIKE 'demo-job-%' - OR br."builder_job_id" IS NULL - ) - ) - ) - AND NOT EXISTS ( - WITH expected_deploys AS ( - SELECT * - FROM (VALUES - ('production'::text, '1.0.0'::text), - ('development'::text, '1.0.1'::text), - ('production'::text, '1.0.1'::text), - ('development'::text, '1.1.0'::text), - ('production'::text, '1.1.0'::text), - ('development'::text, '1.1.1'::text), - ('production'::text, '1.1.1'::text), - ('pr-123'::text, '1.2.0'::text), - ('development'::text, '1.2.0'::text) - ) AS expected("channel_name", "version_name") - ) - SELECT 1 - FROM "public"."deploy_history" dh - INNER JOIN "public"."channels" c - ON c."id" = dh."channel_id" - INNER JOIN "public"."app_versions" av - ON av."id" = dh."version_id" - WHERE dh."app_id" = v_app_id - AND NOT EXISTS ( - SELECT 1 - FROM expected_deploys expected - WHERE expected."channel_name" = c."name" - AND expected."version_name" = av."name" - ) - ) - INTO v_can_claim_full_seed; - - IF NOT v_can_claim_full_seed THEN - RETURN; - END IF; - - INSERT INTO "public"."onboarding_demo_data" ( - "app_id", - "owner_org", - "relation_name", - "row_key", - "seed_id" - ) - SELECT - v_app_id, - v_owner_org, - 'app_versions', - av."id"::text, - p_app_uuid - FROM "public"."app_versions" av - WHERE av."app_id" = v_app_id - AND av."name" IN ('1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0') - ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE - SET - "owner_org" = EXCLUDED."owner_org", - "seed_id" = EXCLUDED."seed_id", - "created_at" = "now"(); - - INSERT INTO "public"."onboarding_demo_data" ( - "app_id", - "owner_org", - "relation_name", - "row_key", - "seed_id" - ) - SELECT - v_app_id, - v_owner_org, - 'app_versions_meta', - avm."id"::text, - p_app_uuid - FROM "public"."app_versions_meta" avm - INNER JOIN "public"."app_versions" av - ON av."id" = avm."id" - WHERE av."app_id" = v_app_id - AND av."name" IN ('1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0') - ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE - SET - "owner_org" = EXCLUDED."owner_org", - "seed_id" = EXCLUDED."seed_id", - "created_at" = "now"(); - - INSERT INTO "public"."onboarding_demo_data" ( - "app_id", - "owner_org", - "relation_name", - "row_key", - "seed_id" - ) - SELECT - v_app_id, - v_owner_org, - 'channels', - c."id"::text, - p_app_uuid - FROM "public"."channels" c - WHERE c."app_id" = v_app_id - AND c."name" IN ('production', 'development', 'pr-123') - ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE - SET - "owner_org" = EXCLUDED."owner_org", - "seed_id" = EXCLUDED."seed_id", - "created_at" = "now"(); - - INSERT INTO "public"."onboarding_demo_data" ( - "app_id", - "owner_org", - "relation_name", - "row_key", - "seed_id" - ) - SELECT - v_app_id, - v_owner_org, - 'deploy_history', - dh."id"::text, - p_app_uuid - FROM "public"."deploy_history" dh - WHERE dh."app_id" = v_app_id - ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE - SET - "owner_org" = EXCLUDED."owner_org", - "seed_id" = EXCLUDED."seed_id", - "created_at" = "now"(); - - INSERT INTO "public"."onboarding_demo_data" ( - "app_id", - "owner_org", - "relation_name", - "row_key", - "seed_id" - ) - SELECT - v_app_id, - v_owner_org, - 'devices', - d."id"::text, - p_app_uuid - FROM "public"."devices" d - WHERE d."app_id" = v_app_id - ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE - SET - "owner_org" = EXCLUDED."owner_org", - "seed_id" = EXCLUDED."seed_id", - "created_at" = "now"(); -END; -$$; - -ALTER FUNCTION "public"."claim_legacy_onboarding_demo_data"(uuid) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."claim_legacy_onboarding_demo_data"(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."claim_legacy_onboarding_demo_data"(uuid) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."claim_legacy_onboarding_demo_data"(uuid) FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."claim_legacy_onboarding_demo_data"(uuid) TO "service_role"; - -DO $$ -DECLARE - v_app_uuid uuid; -BEGIN - FOR v_app_uuid IN - SELECT "id" - FROM "public"."apps" - WHERE "need_onboarding" IS TRUE - LOOP - PERFORM "public"."claim_legacy_onboarding_demo_data"(v_app_uuid); - END LOOP; -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."refresh_app_rollups_after_demo_reset"("p_app_uuid" uuid, "p_app_id" text, "p_owner_org" uuid) -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_last_version text; - v_manifest_bundle_count bigint := 0; - v_channel_device_count bigint := 0; -BEGIN - SELECT "name" - INTO v_last_version - FROM "public"."app_versions" - WHERE "app_id" = p_app_id - AND "deleted" IS FALSE - ORDER BY "created_at" DESC, "id" DESC - LIMIT 1; - - SELECT COUNT(*)::bigint - INTO v_manifest_bundle_count - FROM "public"."app_versions" - WHERE "app_id" = p_app_id - AND "deleted" IS FALSE - AND COALESCE("manifest_count", 0) > 0; - - SELECT COUNT(*)::bigint - INTO v_channel_device_count - FROM "public"."channel_devices" - WHERE "app_id" = p_app_id; - - UPDATE "public"."apps" - SET - "last_version" = v_last_version, - "manifest_bundle_count" = v_manifest_bundle_count, - "channel_device_count" = v_channel_device_count - WHERE "id" = p_app_uuid; - - IF p_owner_org IS NOT NULL THEN - DELETE FROM "public"."app_metrics_cache" - WHERE "org_id" = p_owner_org; - END IF; -END; -$$; - -ALTER FUNCTION "public"."refresh_app_rollups_after_demo_reset"(uuid, text, uuid) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."refresh_app_rollups_after_demo_reset"(uuid, text, uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."refresh_app_rollups_after_demo_reset"(uuid, text, uuid) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."refresh_app_rollups_after_demo_reset"(uuid, text, uuid) FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."refresh_app_rollups_after_demo_reset"(uuid, text, uuid) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."reset_onboarding_demo_app_data"("p_app_uuid" uuid) -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_app_id text; - v_owner_org uuid; -BEGIN - SELECT "app_id", "owner_org" - INTO v_app_id, v_owner_org - FROM "public"."apps" - WHERE "id" = p_app_uuid; - - IF v_app_id IS NULL THEN - RETURN; - END IF; - - PERFORM "public"."claim_legacy_onboarding_demo_data"(p_app_uuid); - - -- unknown/builtin are system placeholders maintained by app creation. They - -- are allowed in demo-shaped legacy apps, but must never be demo-owned rows. - DELETE FROM "public"."onboarding_demo_data" odd - USING "public"."app_versions" av - WHERE odd."app_id" = v_app_id - AND odd."relation_name" IN ('app_versions', 'app_versions_meta') - AND odd."row_key" = av."id"::text - AND av."app_id" = v_app_id - AND av."name" IN ('unknown', 'builtin'); - - -- Refuse to delete tracked parents when any untracked child row points at - -- them. Without these guards, ON DELETE CASCADE could remove real data that - -- a user attached to a demo-created version or channel. - IF EXISTS ( - WITH tracked_versions AS ( - SELECT "row_key"::bigint AS "id" - FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id - AND "relation_name" = 'app_versions' - ) - SELECT 1 - FROM "public"."channels" c - INNER JOIN tracked_versions tv ON tv."id" = c."version" - WHERE NOT EXISTS ( - SELECT 1 - FROM "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'channels' - AND odd."row_key" = c."id"::text - ) - ) THEN - RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into untracked channels for app %', v_app_id; - END IF; - - IF EXISTS ( - WITH tracked_versions AS ( - SELECT "row_key"::bigint AS "id" - FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id - AND "relation_name" = 'app_versions' - ) - SELECT 1 - FROM "public"."deploy_history" dh - INNER JOIN tracked_versions tv ON tv."id" = dh."version_id" - WHERE NOT EXISTS ( - SELECT 1 - FROM "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'deploy_history' - AND odd."row_key" = dh."id"::text - ) - ) THEN - RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into untracked deploy history for app %', v_app_id; - END IF; - - IF EXISTS ( - WITH tracked_versions AS ( - SELECT "row_key"::bigint AS "id" - FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id - AND "relation_name" = 'app_versions' - ) - SELECT 1 - FROM "public"."manifest" m - INNER JOIN tracked_versions tv ON tv."id" = m."app_version_id" - WHERE NOT EXISTS ( - SELECT 1 - FROM "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'manifest' - AND odd."row_key" = m."id"::text - ) - ) THEN - RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into untracked manifest rows for app %', v_app_id; - END IF; - - IF EXISTS ( - WITH tracked_versions AS ( - SELECT "row_key"::bigint AS "id" - FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id - AND "relation_name" = 'app_versions' - ) - SELECT 1 - FROM "public"."app_versions_meta" avm - INNER JOIN tracked_versions tv ON tv."id" = avm."id" - WHERE NOT EXISTS ( - SELECT 1 - FROM "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'app_versions_meta' - AND odd."row_key" = avm."id"::text - ) - ) THEN - RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into untracked version metadata for app %', v_app_id; - END IF; - - IF EXISTS ( - WITH tracked_versions AS ( - SELECT "row_key"::bigint AS "id" - FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id - AND "relation_name" = 'app_versions' - ) - SELECT 1 - FROM "public"."permissions" p - INNER JOIN tracked_versions tv ON tv."id" = p."bundle_id" - ) OR EXISTS ( - WITH tracked_versions AS ( - SELECT "row_key"::bigint AS "id" - FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id - AND "relation_name" = 'app_versions' - ) - SELECT 1 - FROM "public"."role_bindings" rb - INNER JOIN tracked_versions tv ON tv."id" = rb."bundle_id" - ) THEN - RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into RBAC rows for app %', v_app_id; - END IF; - - IF EXISTS ( - WITH tracked_versions AS ( - SELECT "row_key"::bigint AS "id" - FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id - AND "relation_name" = 'app_versions' - ) - SELECT 1 - FROM "public"."version_meta" vm - INNER JOIN tracked_versions tv ON tv."id" = vm."version_id" - WHERE vm."app_id" = v_app_id - ) THEN - RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to delete demo versions with non-nullable version metrics for app %', v_app_id; - END IF; - - IF EXISTS ( - WITH tracked_channels AS ( - SELECT "row_key"::bigint AS "id" - FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id - AND "relation_name" = 'channels' - ) - SELECT 1 - FROM "public"."deploy_history" dh - INNER JOIN tracked_channels tc ON tc."id" = dh."channel_id" - WHERE NOT EXISTS ( - SELECT 1 - FROM "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'deploy_history' - AND odd."row_key" = dh."id"::text - ) - ) THEN - RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo channels into untracked deploy history for app %', v_app_id; - END IF; - - IF EXISTS ( - WITH tracked_channels AS ( - SELECT "row_key"::bigint AS "id" - FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id - AND "relation_name" = 'channels' - ) - SELECT 1 - FROM "public"."channel_devices" cd - INNER JOIN tracked_channels tc ON tc."id" = cd."channel_id" - WHERE NOT EXISTS ( - SELECT 1 - FROM "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'channel_devices' - AND odd."row_key" = cd."id"::text - ) - ) THEN - RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to delete demo channels with untracked channel devices for app %', v_app_id; - END IF; - - IF EXISTS ( - WITH tracked_channels AS ( - SELECT c."id", c."rbac_id" - FROM "public"."channels" c - INNER JOIN "public"."onboarding_demo_data" odd - ON odd."app_id" = v_app_id - AND odd."relation_name" = 'channels' - AND odd."row_key" = c."id"::text - ) - SELECT 1 - FROM "public"."channel_permission_overrides" cpo - INNER JOIN tracked_channels tc ON tc."id" = cpo."channel_id" - ) OR EXISTS ( - WITH tracked_channels AS ( - SELECT c."id", c."rbac_id" - FROM "public"."channels" c - INNER JOIN "public"."onboarding_demo_data" odd - ON odd."app_id" = v_app_id - AND odd."relation_name" = 'channels' - AND odd."row_key" = c."id"::text - ) - SELECT 1 - FROM "public"."org_users" ou - INNER JOIN tracked_channels tc ON tc."id" = ou."channel_id" - ) OR EXISTS ( - WITH tracked_channels AS ( - SELECT c."id", c."rbac_id" - FROM "public"."channels" c - INNER JOIN "public"."onboarding_demo_data" odd - ON odd."app_id" = v_app_id - AND odd."relation_name" = 'channels' - AND odd."row_key" = c."id"::text - ) - SELECT 1 - FROM "public"."role_bindings" rb - INNER JOIN tracked_channels tc ON tc."rbac_id" = rb."channel_id" - ) THEN - RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo channels into access-control rows for app %', v_app_id; - END IF; - - DELETE FROM "public"."channel_devices" cd - USING "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'channel_devices' - AND odd."row_key" = cd."id"::text; - - DELETE FROM "public"."deploy_history" dh - USING "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'deploy_history' - AND odd."row_key" = dh."id"::text; - - DELETE FROM "public"."manifest" m - USING "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'manifest' - AND odd."row_key" = m."id"::text; - - DELETE FROM "public"."build_requests" br - USING "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'build_requests' - AND odd."row_key" = br."id"::text; - - DELETE FROM "public"."devices" d - USING "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'devices' - AND odd."row_key" = d."id"::text; - - DELETE FROM "public"."channels" c - USING "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'channels' - AND odd."row_key" = c."id"::text; - - DELETE FROM "public"."app_versions_meta" avm - USING "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'app_versions_meta' - AND odd."row_key" = avm."id"::text; - - UPDATE "public"."devices" d - SET "version" = NULL - WHERE d."app_id" = v_app_id - AND EXISTS ( - SELECT 1 - FROM "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'app_versions' - AND odd."row_key" = d."version"::text - ); - - UPDATE "public"."daily_version" dv - SET "version_id" = NULL - WHERE dv."app_id" = v_app_id - AND EXISTS ( - SELECT 1 - FROM "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'app_versions' - AND odd."row_key" = dv."version_id"::text - ); - - UPDATE "public"."version_usage" vu - SET "version_id" = NULL - WHERE vu."app_id" = v_app_id - AND EXISTS ( - SELECT 1 - FROM "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'app_versions' - AND odd."row_key" = vu."version_id"::text - ); - - DELETE FROM "public"."app_versions" av - USING "public"."onboarding_demo_data" odd - WHERE odd."app_id" = v_app_id - AND odd."relation_name" = 'app_versions' - AND odd."row_key" = av."id"::text; - - DELETE FROM "public"."onboarding_demo_data" - WHERE "app_id" = v_app_id; - - PERFORM "public"."refresh_app_rollups_after_demo_reset"(p_app_uuid, v_app_id, v_owner_org); -END; -$$; - -ALTER FUNCTION "public"."reset_onboarding_demo_app_data"(uuid) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."reset_onboarding_demo_app_data"(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."reset_onboarding_demo_app_data"(uuid) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."reset_onboarding_demo_app_data"(uuid) FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."reset_onboarding_demo_app_data"(uuid) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- This legacy helper used to delete broad app data. Keep the name for older - -- callers, but make it provenance-based so completing/resetting onboarding - -- can never wipe untracked production rows. - PERFORM p_preserve_app_version_id; - PERFORM "public"."reset_onboarding_demo_app_data"(p_app_uuid); -END; -$$; - -ALTER FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) FROM "anon"; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") -RETURNS void -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - PERFORM "public"."reset_onboarding_demo_app_data"(p_app_uuid); -END; -$$; - -ALTER FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") TO "service_role"; diff --git a/supabase/migrations/20260519151250_remove_builtin_unknown_app_versions.sql b/supabase/migrations/20260519151250_remove_builtin_unknown_app_versions.sql deleted file mode 100644 index d337507976..0000000000 --- a/supabase/migrations/20260519151250_remove_builtin_unknown_app_versions.sql +++ /dev/null @@ -1,76 +0,0 @@ --- Stop storing synthetic native/no-bundle markers as rows in app_versions. --- A NULL channels.version now represents a channel pointing at the app's builtin/native bundle. - -ALTER TABLE "public"."channels" - DROP CONSTRAINT IF EXISTS "channels_version_fkey"; - -ALTER TABLE "public"."channels" - ALTER COLUMN "version" DROP NOT NULL; - -ALTER TABLE "public"."channels" - ADD CONSTRAINT "channels_version_fkey" - FOREIGN KEY ("version") - REFERENCES "public"."app_versions"("id") - ON DELETE SET NULL; - -UPDATE "public"."channels" AS "channels" -SET "version" = NULL -FROM "public"."app_versions" AS "app_versions" -WHERE "channels"."version" = "app_versions"."id" - AND "app_versions"."name" IN ('builtin', 'unknown'); - -DELETE FROM "public"."app_versions" -WHERE "name" IN ('builtin', 'unknown'); - -CREATE OR REPLACE FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) -RETURNS integer -LANGUAGE "plpgsql" -SET search_path = '' -AS $$ -BEGIN - PERFORM appid; - RETURN NULL::integer; -END; -$$; - -ALTER FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) TO "service_role"; - -COMMENT ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) IS -'Legacy RPC kept for older clients. Native/builtin channel targets are represented by channels.version = NULL and this function must not recreate app_versions rows.'; - -CREATE OR REPLACE FUNCTION "public"."record_deployment_history"() -RETURNS trigger -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - -- Native/builtin channel targets are stored as NULL and cannot be represented - -- in deploy_history.version_id. Record only concrete bundle deployments. - IF OLD.version IS DISTINCT FROM NEW.version AND NEW.version IS NOT NULL THEN - INSERT INTO public.deploy_history ( - channel_id, - app_id, - version_id, - owner_org, - created_by - ) - VALUES ( - NEW.id, - NEW.app_id, - NEW.version, - NEW.owner_org, - COALESCE(public.get_identity()::uuid, NEW.created_by) - ); - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."record_deployment_history"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."record_deployment_history"() FROM PUBLIC; diff --git a/supabase/migrations/20260521210531_cron_hyperping_healthchecks.sql b/supabase/migrations/20260521210531_cron_hyperping_healthchecks.sql deleted file mode 100644 index 804592f12e..0000000000 --- a/supabase/migrations/20260521210531_cron_hyperping_healthchecks.sql +++ /dev/null @@ -1,216 +0,0 @@ -ALTER TABLE public.cron_tasks -ADD COLUMN IF NOT EXISTS healthcheck_url text; - -CREATE OR REPLACE FUNCTION public.process_queue_with_healthcheck( - queue_names text [], - batch_size integer, - healthcheck_url text -) -RETURNS void -LANGUAGE plpgsql -SET search_path = '' -AS $$ -DECLARE - calls_needed int; - headers jsonb; - queue_name text; - queue_size bigint; - url text; -BEGIN - IF batch_size IS NULL OR batch_size <= 0 THEN - RAISE EXCEPTION 'batch_size must be positive'; - END IF; - - headers := pg_catalog.jsonb_build_object( - 'Content-Type', 'application/json', - 'apisecret', public.get_apikey() - ); - url := public.get_db_url() || '/functions/v1/triggers/queue_consumer/sync'; - - FOREACH queue_name IN ARRAY queue_names LOOP - BEGIN - EXECUTE pg_catalog.format('SELECT count(*) FROM pgmq.%I', 'q_' || queue_name) - INTO queue_size; - - IF queue_size > 0 THEN - calls_needed := LEAST( - pg_catalog.ceil(queue_size / batch_size::double precision)::int, - 10 - ); - ELSE - calls_needed := 1; - END IF; - - FOR i IN 1..calls_needed LOOP - PERFORM net.http_post( - url := url, - headers := headers, - body := pg_catalog.jsonb_strip_nulls(pg_catalog.jsonb_build_object( - 'queue_name', queue_name, - 'batch_size', batch_size, - 'healthcheck_url', healthcheck_url - )), - timeout_milliseconds := 8000 - ); - END LOOP; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'process_queue_with_healthcheck failed for queue "%": %', queue_name, SQLERRM; - END; - END LOOP; -END; -$$; - -ALTER FUNCTION public.process_queue_with_healthcheck( - text [], integer, text -) OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.process_queue_with_healthcheck( - text [], integer, text -) FROM public; -REVOKE ALL ON FUNCTION public.process_queue_with_healthcheck( - text [], integer, text -) FROM anon; -REVOKE ALL ON FUNCTION public.process_queue_with_healthcheck( - text [], integer, text -) FROM authenticated; -REVOKE ALL ON FUNCTION public.process_queue_with_healthcheck( - text [], integer, text -) FROM service_role; -GRANT EXECUTE ON FUNCTION public.process_queue_with_healthcheck( - text [], integer, text -) TO service_role; - -CREATE OR REPLACE FUNCTION public.process_all_cron_tasks() -RETURNS void -LANGUAGE plpgsql -SET search_path = '' -AS $$ -DECLARE - current_hour int; - current_minute int; - current_second int; - current_dow int; - current_day int; - task RECORD; - queue_names text[]; - should_run boolean; - lock_acquired boolean; -BEGIN - -- Try to acquire an advisory lock (non-blocking) - -- Lock ID 1 is reserved for process_all_cron_tasks - -- pg_try_advisory_lock returns true if lock acquired, false if already held - lock_acquired := pg_try_advisory_lock(1); - - IF NOT lock_acquired THEN - -- Another instance is already running, skip this execution - RAISE NOTICE 'process_all_cron_tasks: skipped, another instance is already running'; - RETURN; - END IF; - - -- Wrap everything in a block so we can ensure the lock is released - BEGIN - -- Get current time components in UTC - current_hour := EXTRACT(HOUR FROM NOW()); - current_minute := EXTRACT(MINUTE FROM NOW()); - current_second := EXTRACT(SECOND FROM NOW()); - current_dow := EXTRACT(DOW FROM NOW()); - current_day := EXTRACT(DAY FROM NOW()); - - -- Loop through all enabled tasks - FOR task IN SELECT * FROM public.cron_tasks WHERE enabled = true LOOP - should_run := false; - - -- Check if task should run based on its schedule - IF task.second_interval IS NOT NULL THEN - -- Run every N seconds - -- Since pg_cron interval is not clock-aligned, we run on every invocation - -- for second_interval tasks (the cron job itself runs every 10 seconds) - should_run := true; - ELSIF task.minute_interval IS NOT NULL THEN - -- Run every N minutes - -- Use current_second < 10 to catch first run of each minute (works with any cron offset) - should_run := (current_minute % task.minute_interval = 0) - AND (current_second < 10); - ELSIF task.hour_interval IS NOT NULL THEN - -- Run every N hours at specific minute - -- Use current_second < 10 to catch first run - should_run := (current_hour % task.hour_interval = 0) - AND (current_minute = COALESCE(task.run_at_minute, 0)) - AND (current_second < 10); - ELSIF task.run_at_hour IS NOT NULL THEN - -- Run at specific time - -- Use current_second < 10 to catch first run - should_run := (current_hour = task.run_at_hour) - AND (current_minute = COALESCE(task.run_at_minute, 0)) - AND (current_second < 10); - - -- Check day of week constraint - IF should_run AND task.run_on_dow IS NOT NULL THEN - should_run := (current_dow = task.run_on_dow); - END IF; - - -- Check day of month constraint - IF should_run AND task.run_on_day IS NOT NULL THEN - should_run := (current_day = task.run_on_day); - END IF; - END IF; - - -- Execute the task if it should run - IF should_run THEN - BEGIN - CASE task.task_type - WHEN 'function' THEN - EXECUTE 'SELECT ' || task.target; - - WHEN 'queue' THEN - PERFORM pgmq.send( - task.target, - COALESCE(task.payload, jsonb_build_object('function_name', task.target)) - ); - - WHEN 'function_queue' THEN - -- Parse JSON array of queue names - SELECT array_agg(value::text) INTO queue_names - FROM jsonb_array_elements_text(task.target::jsonb); - - IF task.healthcheck_url IS NOT NULL THEN - PERFORM public.process_queue_with_healthcheck( - COALESCE(queue_names, ARRAY[]::text[]), - COALESCE(task.batch_size, 950), - task.healthcheck_url - ); - ELSIF task.batch_size IS NOT NULL THEN - PERFORM public.process_function_queue(queue_names, task.batch_size); - ELSE - PERFORM public.process_function_queue(queue_names); - END IF; - END CASE; - EXCEPTION WHEN OTHERS THEN - RAISE WARNING 'cron task "%" failed: %', task.name, SQLERRM; - END; - END IF; - END LOOP; - - EXCEPTION WHEN OTHERS THEN - -- Release the lock even if an error occurred - PERFORM pg_advisory_unlock(1); - RAISE; - END; - - -- Release the advisory lock - PERFORM pg_advisory_unlock(1); -END; -$$; - -ALTER FUNCTION public.process_all_cron_tasks() OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.process_all_cron_tasks() FROM public; -REVOKE ALL ON FUNCTION public.process_all_cron_tasks() FROM anon; -REVOKE ALL ON FUNCTION public.process_all_cron_tasks() FROM authenticated; -REVOKE ALL ON FUNCTION public.process_all_cron_tasks() FROM service_role; -GRANT EXECUTE ON FUNCTION public.process_all_cron_tasks() TO service_role; - -COMMENT ON FUNCTION public.process_all_cron_tasks() IS -$$Consolidated cron task processor that runs every 10 seconds. Uses advisory -lock (ID=1) to prevent concurrent execution - if a previous run is still -executing, the new invocation will skip.$$; diff --git a/supabase/migrations/20260524123635_drop_channel_devices_owner_org_index.sql b/supabase/migrations/20260524123635_drop_channel_devices_owner_org_index.sql deleted file mode 100644 index ea401e6b78..0000000000 --- a/supabase/migrations/20260524123635_drop_channel_devices_owner_org_index.sql +++ /dev/null @@ -1,3 +0,0 @@ --- channel_devices runtime lookups use app_id/device_id or channel_id. --- Supabase migrations run in a transaction, so this cannot use DROP INDEX CONCURRENTLY. -DROP INDEX IF EXISTS "public"."finx_channel_devices_owner_org"; diff --git a/supabase/migrations/20260526133000_migrate_apikeys_to_v2_timestamp_fix.sql b/supabase/migrations/20260526133000_migrate_apikeys_to_v2_timestamp_fix.sql deleted file mode 100644 index 1b72e22e8e..0000000000 --- a/supabase/migrations/20260526133000_migrate_apikeys_to_v2_timestamp_fix.sql +++ /dev/null @@ -1,3116 +0,0 @@ --- Move every existing API key to RBAC-backed bindings and remove the old key scope columns. - -CREATE OR REPLACE FUNCTION pg_temp.exec_ddl_with_retry(p_sql text, p_attempts integer DEFAULT 20) -RETURNS void -LANGUAGE plpgsql -SET search_path = '' -AS $$ -DECLARE - v_attempt integer := 0; -BEGIN - LOOP - v_attempt := v_attempt + 1; - PERFORM pg_catalog.set_config('lock_timeout', '5s', true); - - BEGIN - EXECUTE p_sql; - PERFORM pg_catalog.set_config('lock_timeout', '0', true); - RETURN; - EXCEPTION - WHEN deadlock_detected OR lock_not_available THEN - PERFORM pg_catalog.set_config('lock_timeout', '0', true); - - IF v_attempt >= p_attempts THEN - RAISE; - END IF; - - RAISE NOTICE 'Retrying migration DDL after lock conflict on attempt %', v_attempt; - PERFORM pg_catalog.pg_sleep(pg_catalog.least(0.25 * v_attempt, 3.0)); - END; - END LOOP; -END; -$$; - -SELECT pg_temp.exec_ddl_with_retry($lock$ - LOCK TABLE - "public"."apikeys", - "public"."apps", - "public"."app_versions", - "public"."channel_devices", - "public"."daily_bandwidth", - "public"."daily_mau", - "public"."daily_storage", - "public"."daily_version", - "public"."group_members", - "public"."groups", - "public"."org_users", - "public"."orgs", - "public"."permissions", - "public"."role_bindings", - "public"."role_permissions", - "public"."roles", - "public"."stats", - "public"."users", - "public"."webhook_deliveries", - "public"."webhooks" - IN ACCESS EXCLUSIVE MODE -$lock$); - -DO $$ -DECLARE - v_org_id uuid; -BEGIN - FOR v_org_id IN - SELECT id FROM public.orgs - LOOP - PERFORM public.rbac_migrate_org_users_to_bindings(v_org_id, NULL::uuid); - END LOOP; -END; -$$; - --- Existing-user invites used to create role_bindings before the user accepted --- the invitation. Pending invites must not grant active RBAC access. -DELETE FROM public.role_bindings -WHERE principal_type = public.rbac_principal_user() - AND reason = 'Invited via invite_user_to_org_rbac'; - -UPDATE public.orgs -SET use_new_rbac = true -WHERE use_new_rbac IS DISTINCT FROM true; - -ALTER TABLE public.orgs - ALTER COLUMN use_new_rbac SET DEFAULT true; - -CREATE OR REPLACE FUNCTION public.rbac_is_enabled_for_org(p_org_id uuid) RETURNS boolean -LANGUAGE plpgsql -STABLE -SET search_path = '' -AS $$ -BEGIN - PERFORM p_org_id; - RETURN true; -END; -$$; - -ALTER FUNCTION public.rbac_is_enabled_for_org(uuid) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION public.rbac_is_enabled_for_org(uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.rbac_is_enabled_for_org(uuid) TO "authenticated"; -GRANT EXECUTE ON FUNCTION public.rbac_is_enabled_for_org(uuid) TO "service_role"; - -COMMENT ON FUNCTION public.rbac_is_enabled_for_org(uuid) IS 'Compatibility helper retained for old callers. RBAC is always enabled.'; - -CREATE OR REPLACE FUNCTION public.rbac_role_apikey_org_reader() RETURNS text -LANGUAGE sql -IMMUTABLE -PARALLEL SAFE -SET search_path = '' -AS $$ SELECT 'apikey_org_reader'::text $$; - -ALTER FUNCTION public.rbac_role_apikey_org_reader() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION public.rbac_role_apikey_org_reader() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.rbac_role_apikey_org_reader() TO "anon"; -GRANT EXECUTE ON FUNCTION public.rbac_role_apikey_org_reader() TO "authenticated"; -GRANT EXECUTE ON FUNCTION public.rbac_role_apikey_org_reader() TO "service_role"; - -INSERT INTO public.roles (name, scope_type, description, priority_rank, is_assignable, created_by) -VALUES ( - public.rbac_role_apikey_org_reader(), - public.rbac_scope_org(), - 'API key compatibility role: org metadata read only', - 10, - false, - NULL -) -ON CONFLICT (name) DO UPDATE -SET - scope_type = EXCLUDED.scope_type, - description = EXCLUDED.description, - priority_rank = EXCLUDED.priority_rank, - is_assignable = EXCLUDED.is_assignable; - -INSERT INTO public.role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM public.roles r -JOIN public.permissions p ON p.key = public.rbac_perm_org_read() -WHERE r.name = public.rbac_role_apikey_org_reader() -ON CONFLICT DO NOTHING; - -CREATE TEMP TABLE _apikey_v2_current_orgs ON COMMIT DROP AS -SELECT DISTINCT source.user_id, source.org_id -FROM ( - SELECT ou.user_id, ou.org_id - FROM public.org_users ou - WHERE ou.user_right IS NULL OR ou.user_right::text NOT LIKE 'invite_%' - - UNION - - SELECT rb.principal_id AS user_id, rb.org_id - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT gm.user_id, g.org_id - FROM public.group_members gm - JOIN public.groups g ON g.id = gm.group_id - JOIN public.role_bindings rb - ON rb.principal_type = public.rbac_principal_group() - AND rb.principal_id = gm.group_id - AND rb.org_id = g.org_id - WHERE rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) -) source -WHERE source.user_id IS NOT NULL - AND source.org_id IS NOT NULL; - -CREATE TEMP TABLE _apikey_v2_seed ON COMMIT DROP AS -SELECT - ak.id, - ak.user_id, - ak.rbac_id, - ak.mode, - COALESCE(ak.limited_to_orgs, '{}'::uuid[]) AS limited_to_orgs, - COALESCE(ak.limited_to_apps, '{}'::text[]) AS limited_to_apps, - COALESCE(array_length(ak.limited_to_orgs, 1), 0) > 0 AS has_org_limit, - COALESCE(array_length(ak.limited_to_apps, 1), 0) > 0 AS has_app_limit -FROM public.apikeys ak; - -CREATE TEMP TABLE _apikey_v2_target_orgs ON COMMIT DROP AS -SELECT DISTINCT - keys.id AS key_id, - keys.user_id, - keys.rbac_id, - orgs.org_id -FROM _apikey_v2_seed keys -JOIN _apikey_v2_current_orgs orgs ON orgs.user_id = keys.user_id -WHERE NOT keys.has_org_limit - OR orgs.org_id = ANY(keys.limited_to_orgs); - -CREATE TEMP TABLE _apikey_v2_target_apps ON COMMIT DROP AS -SELECT DISTINCT - keys.id AS key_id, - keys.user_id, - keys.rbac_id, - apps.owner_org, - apps.id AS app_uuid, - apps.app_id -FROM _apikey_v2_seed keys -JOIN _apikey_v2_target_orgs orgs ON orgs.key_id = keys.id -JOIN public.apps apps ON apps.owner_org = orgs.org_id -WHERE NOT keys.has_app_limit - OR apps.app_id::text = ANY(keys.limited_to_apps); - -INSERT INTO public.role_bindings ( - principal_type, - principal_id, - role_id, - scope_type, - org_id, - granted_by, - reason, - is_direct -) -SELECT - public.rbac_principal_apikey(), - bindings.rbac_id, - roles.id, - public.rbac_scope_org(), - bindings.org_id, - bindings.user_id, - 'Migrated API key to RBAC bindings', - true -FROM ( - SELECT - keys.id AS key_id, - keys.user_id, - keys.rbac_id, - orgs.org_id, - CASE - WHEN keys.mode = 'all'::public.key_mode THEN public.rbac_role_org_super_admin() - ELSE public.rbac_role_org_member() - END AS role_name - FROM _apikey_v2_seed keys - JOIN _apikey_v2_target_orgs orgs ON orgs.key_id = keys.id - WHERE NOT keys.has_app_limit - - UNION - - SELECT - keys.id AS key_id, - keys.user_id, - keys.rbac_id, - apps.owner_org AS org_id, - public.rbac_role_apikey_org_reader() AS role_name - FROM _apikey_v2_seed keys - JOIN _apikey_v2_target_apps apps ON apps.key_id = keys.id - WHERE keys.has_app_limit -) bindings -JOIN public.roles roles ON roles.name = bindings.role_name -ON CONFLICT DO NOTHING; - -INSERT INTO public.role_bindings ( - principal_type, - principal_id, - role_id, - scope_type, - org_id, - app_id, - granted_by, - reason, - is_direct -) -SELECT - public.rbac_principal_apikey(), - keys.rbac_id, - roles.id, - public.rbac_scope_app(), - apps.owner_org, - apps.app_uuid, - keys.user_id, - 'Migrated API key app binding', - true -FROM _apikey_v2_seed keys -JOIN _apikey_v2_target_apps apps ON apps.key_id = keys.id -JOIN public.roles roles - ON roles.name = CASE keys.mode - WHEN 'all'::public.key_mode THEN public.rbac_role_app_admin() - WHEN 'write'::public.key_mode THEN public.rbac_role_app_developer() - WHEN 'upload'::public.key_mode THEN public.rbac_role_app_uploader() - WHEN 'read'::public.key_mode THEN public.rbac_role_app_reader() - ELSE NULL - END -WHERE keys.mode IN ('read'::public.key_mode, 'upload'::public.key_mode, 'write'::public.key_mode) - OR (keys.mode = 'all'::public.key_mode AND keys.has_app_limit) -ON CONFLICT DO NOTHING; - -DO $$ -DECLARE - missing_count bigint; -BEGIN - SELECT count(*) - INTO missing_count - FROM ( - SELECT - keys.id AS key_id, - keys.rbac_id, - orgs.org_id - FROM _apikey_v2_seed keys - JOIN _apikey_v2_target_orgs orgs ON orgs.key_id = keys.id - WHERE NOT keys.has_app_limit - - UNION - - SELECT - keys.id AS key_id, - keys.rbac_id, - apps.owner_org AS org_id - FROM _apikey_v2_seed keys - JOIN _apikey_v2_target_apps apps ON apps.key_id = keys.id - WHERE keys.has_app_limit - ) expected_orgs - WHERE NOT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = expected_orgs.rbac_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = expected_orgs.org_id - ); - - IF missing_count > 0 THEN - RAISE EXCEPTION 'apikey_v2_migration_missing_org_bindings: %', missing_count; - END IF; - - SELECT count(*) - INTO missing_count - FROM _apikey_v2_seed keys - JOIN _apikey_v2_target_apps apps ON apps.key_id = keys.id - WHERE ( - keys.mode IN ('read'::public.key_mode, 'upload'::public.key_mode, 'write'::public.key_mode) - OR (keys.mode = 'all'::public.key_mode AND keys.has_app_limit) - ) - AND NOT EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = keys.rbac_id - AND rb.scope_type = public.rbac_scope_app() - AND rb.org_id = apps.owner_org - AND rb.app_id = apps.app_uuid - ); - - IF missing_count > 0 THEN - RAISE EXCEPTION 'apikey_v2_migration_missing_app_bindings: %', missing_count; - END IF; -END; -$$; - -CREATE OR REPLACE FUNCTION "public"."cleanup_apikey_role_bindings"() RETURNS "trigger" -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - DELETE FROM public.role_bindings - WHERE principal_type = public.rbac_principal_apikey() - AND principal_id = OLD.rbac_id; - - RETURN OLD; -END; -$$; - -ALTER FUNCTION "public"."cleanup_apikey_role_bindings"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."cleanup_apikey_role_bindings"() FROM PUBLIC; - -DROP TRIGGER IF EXISTS "cleanup_apikey_role_bindings_on_delete" ON "public"."apikeys"; -CREATE TRIGGER "cleanup_apikey_role_bindings_on_delete" -BEFORE DELETE ON "public"."apikeys" -FOR EACH ROW EXECUTE FUNCTION "public"."cleanup_apikey_role_bindings"(); - -CREATE OR REPLACE FUNCTION "public"."apikey_permission_for_keymode"( - "keymode" "public"."key_mode"[], - "scope_type" "text" -) RETURNS "text" -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - IF scope_type = public.rbac_scope_org() THEN - RETURN CASE - WHEN 'read'::public.key_mode = ANY(keymode) THEN public.rbac_perm_org_read() - WHEN 'upload'::public.key_mode = ANY(keymode) THEN public.rbac_perm_org_update_settings() - WHEN 'write'::public.key_mode = ANY(keymode) THEN public.rbac_perm_org_update_settings() - ELSE public.rbac_perm_org_update_user_roles() - END; - END IF; - - RETURN CASE - WHEN 'read'::public.key_mode = ANY(keymode) THEN public.rbac_perm_app_read() - WHEN 'upload'::public.key_mode = ANY(keymode) THEN public.rbac_perm_app_upload_bundle() - WHEN 'write'::public.key_mode = ANY(keymode) THEN public.rbac_perm_app_update_settings() - ELSE public.rbac_perm_app_update_user_roles() - END; -END; -$$; - -ALTER FUNCTION "public"."apikey_permission_for_keymode"("public"."key_mode"[], "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."apikey_permission_for_keymode"("public"."key_mode"[], "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."apikey_permission_for_keymode"("public"."key_mode"[], "text") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."apikey_permission_for_keymode"("public"."key_mode"[], "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."apikey_permission_for_keymode"("public"."key_mode"[], "text") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_identity"("keymode" "public"."key_mode"[]) RETURNS "uuid" -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key public.apikeys%ROWTYPE; -BEGIN - PERFORM keymode; - SELECT auth.uid() INTO auth_uid; - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT public.get_apikey_header() INTO api_key_text; - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; - IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN - RETURN NULL; - END IF; - - RETURN api_key.user_id; -END; -$$; - -ALTER FUNCTION "public"."get_identity"("keymode" "public"."key_mode"[]) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_identity"("public"."key_mode"[]) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_identity"("public"."key_mode"[]) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_identity"("public"."key_mode"[]) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_identity"("public"."key_mode"[]) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_identity_apikey_only"("keymode" "public"."key_mode"[]) RETURNS "uuid" -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key public.apikeys%ROWTYPE; -BEGIN - PERFORM keymode; - SELECT public.get_apikey_header() INTO api_key_text; - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; - IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN - RETURN NULL; - END IF; - - RETURN api_key.user_id; -END; -$$; - -ALTER FUNCTION "public"."get_identity_apikey_only"("keymode" "public"."key_mode"[]) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_identity_apikey_only"("public"."key_mode"[]) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_identity_apikey_only"("public"."key_mode"[]) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_identity_for_apikey_creation"() RETURNS "uuid" -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - auth_uid uuid; -BEGIN - SELECT auth.uid() INTO auth_uid; - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - PERFORM public.pg_log('deny: APIKEY_CREATE_WITH_API_KEY_DISABLED', '{}'::jsonb); - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."get_identity_for_apikey_creation"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_identity_for_apikey_creation"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_identity_for_apikey_creation"() TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_identity_for_apikey_creation"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_identity_for_apikey_creation"() TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_identity_org_allowed"("keymode" "public"."key_mode"[], "org_id" "uuid") RETURNS "uuid" -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key public.apikeys%ROWTYPE; - required_permission text; -BEGIN - SELECT auth.uid() INTO auth_uid; - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT public.get_apikey_header() INTO api_key_text; - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; - IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN - RETURN NULL; - END IF; - - required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_org()); - IF public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, org_id, NULL, NULL) THEN - RETURN api_key.user_id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."get_identity_org_allowed"("keymode" "public"."key_mode"[], "org_id" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_identity_org_allowed"("public"."key_mode"[], "uuid") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_identity_org_allowed"("public"."key_mode"[], "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_identity_org_allowed"("public"."key_mode"[], "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_identity_org_allowed"("public"."key_mode"[], "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_identity_org_allowed_apikey_only"("keymode" "public"."key_mode"[], "org_id" "uuid") RETURNS "uuid" -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key public.apikeys%ROWTYPE; - required_permission text; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; - IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN - RETURN NULL; - END IF; - - required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_org()); - IF public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, org_id, NULL, NULL) THEN - RETURN api_key.user_id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."get_identity_org_allowed_apikey_only"("keymode" "public"."key_mode"[], "org_id" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_identity_org_allowed_apikey_only"("public"."key_mode"[], "uuid") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_identity_org_allowed_apikey_only"("public"."key_mode"[], "uuid") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."get_identity_org_appid"("keymode" "public"."key_mode"[], "org_id" "uuid", "app_id" character varying) RETURNS "uuid" -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - auth_uid uuid; - api_key_text text; - api_key public.apikeys%ROWTYPE; - required_permission text; -BEGIN - SELECT auth.uid() INTO auth_uid; - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - SELECT public.get_apikey_header() INTO api_key_text; - IF api_key_text IS NULL THEN - RETURN NULL; - END IF; - - SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; - IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN - RETURN NULL; - END IF; - - required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_app()); - IF public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, org_id, app_id, NULL) THEN - RETURN api_key.user_id; - END IF; - - RETURN NULL; -END; -$$; - -ALTER FUNCTION "public"."get_identity_org_appid"("keymode" "public"."key_mode"[], "org_id" "uuid", "app_id" character varying) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_identity_org_appid"("public"."key_mode"[], "uuid", character varying) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_identity_org_appid"("public"."key_mode"[], "uuid", character varying) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_identity_org_appid"("public"."key_mode"[], "uuid", character varying) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_identity_org_appid"("public"."key_mode"[], "uuid", character varying) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[]) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key public.apikeys%ROWTYPE; - required_org_permission text; - required_app_permission text; -BEGIN - SELECT * INTO api_key FROM public.find_apikey_by_value(apikey) LIMIT 1; - IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN - RETURN false; - END IF; - - required_org_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_org()); - required_app_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_app()); - - RETURN EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = api_key.rbac_id - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - AND public.rbac_has_permission( - public.rbac_principal_apikey(), - api_key.rbac_id, - required_org_permission, - rb.org_id, - NULL::character varying, - NULL::bigint - ) - ) - OR EXISTS ( - SELECT 1 - FROM public.role_bindings rb - JOIN public.apps ON public.apps.id = rb.app_id - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = api_key.rbac_id - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - AND public.rbac_has_permission( - public.rbac_principal_apikey(), - api_key.rbac_id, - required_app_permission, - public.apps.owner_org, - public.apps.app_id, - NULL::bigint - ) - ); -END; -$$; - -ALTER FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[]) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."is_allowed_capgkey"("text", "public"."key_mode"[]) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."is_allowed_capgkey"("text", "public"."key_mode"[]) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_allowed_capgkey"("text", "public"."key_mode"[]) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_allowed_capgkey"("text", "public"."key_mode"[]) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[], "app_id" character varying) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key public.apikeys%ROWTYPE; - app_org_id uuid; - required_permission text; -BEGIN - SELECT * INTO api_key FROM public.find_apikey_by_value(apikey) LIMIT 1; - IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN - RETURN false; - END IF; - - SELECT owner_org INTO app_org_id - FROM public.apps - WHERE apps.app_id = is_allowed_capgkey.app_id - LIMIT 1; - - IF app_org_id IS NULL THEN - RETURN false; - END IF; - - required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_app()); - RETURN public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, app_org_id, app_id, NULL); -END; -$$; - -ALTER FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[], "app_id" character varying) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."is_allowed_capgkey"("text", "public"."key_mode"[], character varying) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."is_allowed_capgkey"("text", "public"."key_mode"[], character varying) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_allowed_capgkey"("text", "public"."key_mode"[], character varying) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_allowed_capgkey"("text", "public"."key_mode"[], character varying) TO "service_role"; - -CREATE OR REPLACE FUNCTION "capgo_private"."matches_app_storage_apikey_owner"("folder_user_id" "text", "target_app_id" character varying, "keymode" "public"."key_mode"[]) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key public.apikeys%ROWTYPE; - target_app record; - required_permission text; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - IF api_key_text IS NULL THEN - RETURN false; - END IF; - - SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; - IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN - RETURN false; - END IF; - - SELECT user_id, owner_org - INTO target_app - FROM public.apps - WHERE app_id = target_app_id - LIMIT 1; - - IF target_app.user_id IS NULL THEN - RETURN false; - END IF; - - IF api_key.user_id::text <> folder_user_id OR target_app.user_id <> api_key.user_id THEN - RETURN false; - END IF; - - required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_app()); - RETURN public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, target_app.owner_org, target_app_id, NULL); -END; -$$; - -ALTER FUNCTION "capgo_private"."matches_app_storage_apikey_owner"("folder_user_id" "text", "target_app_id" character varying, "keymode" "public"."key_mode"[]) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."has_app_right_apikey"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid", "apikey" "text") RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key public.apikeys%ROWTYPE; - org_id uuid; - permission_key text; -BEGIN - SELECT * INTO api_key FROM public.find_apikey_by_value(apikey) LIMIT 1; - IF api_key.id IS NULL OR api_key.user_id IS DISTINCT FROM userid THEN - RETURN false; - END IF; - - IF public.is_apikey_expired(api_key.expires_at) THEN - RETURN false; - END IF; - - SELECT owner_org INTO org_id - FROM public.apps - WHERE app_id = appid - LIMIT 1; - - IF org_id IS NULL THEN - RETURN false; - END IF; - - permission_key := CASE - WHEN "right" = 'read'::public.user_min_right THEN public.rbac_perm_app_read() - WHEN "right" = 'upload'::public.user_min_right THEN public.rbac_perm_app_upload_bundle() - WHEN "right" = 'write'::public.user_min_right THEN public.rbac_perm_app_update_settings() - ELSE public.rbac_perm_app_update_user_roles() - END; - - RETURN public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, permission_key, org_id, appid, NULL); -END; -$$; - -ALTER FUNCTION "public"."has_app_right_apikey"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid", "apikey" "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."has_app_right_apikey"(character varying, "public"."user_min_right", "uuid", "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."has_app_right_apikey"(character varying, "public"."user_min_right", "uuid", "text") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."has_app_right_apikey"(character varying, "public"."user_min_right", "uuid", "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."has_app_right_apikey"(character varying, "public"."user_min_right", "uuid", "text") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"( - "p_permission_key" "text", - "p_user_id" "uuid", - "p_org_id" "uuid", - "p_app_id" character varying, - "p_channel_id" bigint, - "p_apikey" "text" DEFAULT NULL::"text" -) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_allowed boolean := false; - v_effective_org_id uuid := p_org_id; - v_effective_user_id uuid := p_user_id; - v_effective_app_id character varying := p_app_id; - v_api_key public.apikeys%ROWTYPE; - v_channel_org_id uuid; - v_channel_app_id character varying; - v_channel_scope boolean := p_channel_id IS NOT NULL; - v_override boolean; -BEGIN - IF p_permission_key IS NULL OR p_permission_key = '' THEN - RETURN false; - END IF; - - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF p_channel_id IS NOT NULL THEN - SELECT owner_org, app_id - INTO v_channel_org_id, v_channel_app_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_org_id IS NOT NULL THEN - v_effective_org_id := v_channel_org_id; - v_effective_app_id := v_channel_app_id; - END IF; - END IF; - - IF p_apikey IS NOT NULL THEN - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL - OR (p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id) - OR v_effective_org_id IS NULL - THEN - RETURN false; - END IF; - - IF public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN false; - END IF; - - v_effective_user_id := v_api_key.user_id; - - IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id) - AND NOT public.has_2fa_enabled(v_effective_user_id) - THEN - RETURN false; - END IF; - - IF public.user_meets_password_policy(v_effective_user_id, v_effective_org_id) = false THEN - RETURN false; - END IF; - - v_allowed := public.rbac_has_permission( - public.rbac_principal_apikey(), - v_api_key.rbac_id, - p_permission_key, - v_effective_org_id, - v_effective_app_id, - p_channel_id - ); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_apikey() - AND o.principal_id = v_api_key.rbac_id - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - END IF; - END IF; - - RETURN v_allowed; - END IF; - - IF v_effective_org_id IS NOT NULL THEN - IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id) - AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) - THEN - RETURN false; - END IF; - - IF public.user_meets_password_policy(v_effective_user_id, v_effective_org_id) = false THEN - RETURN false; - END IF; - END IF; - - IF v_effective_user_id IS NULL THEN - RETURN false; - END IF; - - v_allowed := public.rbac_has_permission( - public.rbac_principal_user(), - v_effective_user_id, - p_permission_key, - v_effective_org_id, - v_effective_app_id, - p_channel_id - ); - - IF v_channel_scope THEN - SELECT o.is_allowed INTO v_override - FROM public.channel_permission_overrides o - WHERE o.principal_type = public.rbac_principal_user() - AND o.principal_id = v_effective_user_id - AND o.channel_id = p_channel_id - AND o.permission_key = p_permission_key - LIMIT 1; - - IF v_override IS NOT NULL THEN - v_allowed := v_override; - END IF; - END IF; - - RETURN v_allowed; -END; -$$; - -ALTER FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct_no_password_policy"( - "p_permission_key" "text", - "p_user_id" "uuid", - "p_org_id" "uuid", - "p_app_id" character varying, - "p_channel_id" bigint, - "p_apikey" "text" DEFAULT NULL::"text" -) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_effective_org_id uuid := p_org_id; - v_effective_user_id uuid := p_user_id; - v_effective_app_id character varying := p_app_id; - v_api_key public.apikeys%ROWTYPE; - v_channel_org_id uuid; - v_channel_app_id character varying; -BEGIN - IF p_permission_key IS NULL OR p_permission_key = '' THEN - RETURN false; - END IF; - - IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.apps - WHERE app_id = p_app_id - LIMIT 1; - END IF; - - IF p_channel_id IS NOT NULL THEN - SELECT owner_org, app_id - INTO v_channel_org_id, v_channel_app_id - FROM public.channels - WHERE id = p_channel_id - LIMIT 1; - - IF v_channel_org_id IS NOT NULL THEN - v_effective_org_id := v_channel_org_id; - v_effective_app_id := v_channel_app_id; - END IF; - END IF; - - IF p_apikey IS NOT NULL THEN - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL - OR (p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id) - OR v_effective_org_id IS NULL - THEN - RETURN false; - END IF; - - IF public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN false; - END IF; - - v_effective_user_id := v_api_key.user_id; - - IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id) - AND NOT public.has_2fa_enabled(v_effective_user_id) - THEN - RETURN false; - END IF; - - RETURN public.rbac_has_permission( - public.rbac_principal_apikey(), - v_api_key.rbac_id, - p_permission_key, - v_effective_org_id, - v_effective_app_id, - p_channel_id - ); - END IF; - - IF v_effective_org_id IS NOT NULL THEN - IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id) - AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) - THEN - RETURN false; - END IF; - END IF; - - IF v_effective_user_id IS NULL THEN - RETURN false; - END IF; - - RETURN public.rbac_has_permission( - public.rbac_principal_user(), - v_effective_user_id, - p_permission_key, - v_effective_org_id, - v_effective_app_id, - p_channel_id - ); -END; -$$; - -ALTER FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean -LANGUAGE "plpgsql" -SET search_path = '' -AS $$ -BEGIN - RETURN public.check_min_rights(min_right, (SELECT auth.uid()), org_id, app_id, channel_id); -END; -$$; - -ALTER FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."check_min_rights"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_perm text; - v_scope text; - v_apikey text; - v_effective_org_id uuid := org_id; - v_app_owner_org uuid; - v_org_enforcing_2fa boolean; - v_password_policy_ok boolean; -BEGIN - IF app_id IS NOT NULL THEN - SELECT owner_org INTO v_app_owner_org - FROM public.apps - WHERE public.apps.app_id = check_min_rights.app_id - LIMIT 1; - - IF v_app_owner_org IS NOT NULL THEN - IF v_effective_org_id IS NOT NULL AND v_effective_org_id IS DISTINCT FROM v_app_owner_org THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APP_ORG_MISMATCH', jsonb_build_object( - 'org_id', v_effective_org_id, - 'app_owner_org', v_app_owner_org, - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - - v_effective_org_id := v_app_owner_org; - END IF; - END IF; - - IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN - SELECT owner_org INTO v_effective_org_id - FROM public.channels - WHERE public.channels.id = channel_id - LIMIT 1; - END IF; - - SELECT public.get_apikey_header() INTO v_apikey; - - IF v_effective_org_id IS NOT NULL AND NOT (v_apikey IS NOT NULL AND user_id IS NULL) THEN - SELECT enforcing_2fa INTO v_org_enforcing_2fa - FROM public.orgs - WHERE id = v_effective_org_id; - - IF v_org_enforcing_2fa = true AND (user_id IS NULL OR NOT public.has_2fa_enabled(user_id)) THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - - v_password_policy_ok := public.user_meets_password_policy(user_id, v_effective_org_id); - IF v_password_policy_ok = false THEN - PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( - 'org_id', COALESCE(org_id, v_effective_org_id), - 'app_id', app_id, - 'channel_id', channel_id, - 'min_right', min_right::text, - 'user_id', user_id - )); - RETURN false; - END IF; - END IF; - - IF channel_id IS NOT NULL THEN - v_scope := public.rbac_scope_channel(); - ELSIF app_id IS NOT NULL THEN - v_scope := public.rbac_scope_app(); - ELSE - v_scope := public.rbac_scope_org(); - END IF; - - v_perm := public.rbac_permission_for_legacy(min_right, v_scope); - RETURN public.rbac_check_permission_direct(v_perm, user_id, v_effective_org_id, app_id, channel_id, v_apikey); -END; -$$; - -ALTER FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."check_min_rights_legacy"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - RETURN public.check_min_rights(min_right, user_id, org_id, app_id, channel_id); -END; -$$; - -ALTER FUNCTION "public"."check_min_rights_legacy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."check_min_rights_legacy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."check_min_rights_legacy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."check_min_rights_legacy_no_password_policy"( - "min_right" "public"."user_min_right", - "user_id" "uuid", - "org_id" "uuid", - "app_id" character varying, - "channel_id" bigint -) RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_perm text; - v_scope text; -BEGIN - IF channel_id IS NOT NULL THEN - v_scope := public.rbac_scope_channel(); - ELSIF app_id IS NOT NULL THEN - v_scope := public.rbac_scope_app(); - ELSE - v_scope := public.rbac_scope_org(); - END IF; - - v_perm := public.rbac_permission_for_legacy(min_right, v_scope); - RETURN public.rbac_check_permission_direct_no_password_policy(v_perm, user_id, org_id, app_id, channel_id, NULL); -END; -$$; - -ALTER FUNCTION "public"."check_min_rights_legacy_no_password_policy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."check_min_rights_legacy_no_password_policy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."check_min_rights_legacy_no_password_policy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."app_versions_readable_app_ids"() RETURNS character varying[] -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; - v_allowed character varying[] := '{}'::character varying[]; -BEGIN - SELECT auth.uid() INTO v_user_id; - SELECT public.get_apikey_header() INTO v_api_key_text; - - IF v_user_id IS NULL AND v_api_key_text IS NULL THEN - RETURN v_allowed; - END IF; - - IF v_api_key_text IS NOT NULL THEN - SELECT * INTO v_api_key FROM public.find_apikey_by_value(v_api_key_text) LIMIT 1; - IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN v_allowed; - END IF; - v_user_id := v_api_key.user_id; - END IF; - - SELECT COALESCE(array_agg(DISTINCT apps.app_id), '{}'::character varying[]) - INTO v_allowed - FROM public.apps - WHERE CASE - WHEN v_api_key.id IS NOT NULL THEN public.rbac_check_permission_direct(public.rbac_perm_app_read(), v_user_id, apps.owner_org, apps.app_id, NULL, v_api_key_text) - ELSE public.check_min_rights('read'::public.user_min_right, v_user_id, apps.owner_org, apps.app_id, NULL::bigint) - END; - - RETURN v_allowed; -END; -$$; - -ALTER FUNCTION "public"."app_versions_readable_app_ids"() OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."audit_logs_allowed_orgs"() RETURNS "uuid"[] -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; - v_permission text := public.rbac_permission_for_legacy(public.rbac_right_super_admin(), public.rbac_scope_org()); - v_allowed uuid[] := '{}'::uuid[]; -BEGIN - SELECT auth.uid() INTO v_user_id; - SELECT public.get_apikey_header() INTO v_api_key_text; - - IF v_user_id IS NULL AND v_api_key_text IS NULL THEN - RETURN v_allowed; - END IF; - - IF v_api_key_text IS NOT NULL THEN - SELECT * INTO v_api_key FROM public.find_apikey_by_value(v_api_key_text) LIMIT 1; - IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN v_allowed; - END IF; - v_user_id := v_api_key.user_id; - END IF; - - SELECT COALESCE(array_agg(DISTINCT orgs.id), '{}'::uuid[]) - INTO v_allowed - FROM public.orgs - WHERE CASE - WHEN v_api_key.id IS NOT NULL THEN public.rbac_check_permission_direct(v_permission, v_user_id, orgs.id, NULL, NULL, v_api_key_text) - ELSE public.rbac_check_permission_direct(v_permission, v_user_id, orgs.id, NULL, NULL, NULL) - END; - - RETURN v_allowed; -END; -$$; - -ALTER FUNCTION "public"."audit_logs_allowed_orgs"() OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."usage_credit_readable_org_ids"() RETURNS "uuid"[] -LANGUAGE "plpgsql" STABLE SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; - v_permission text := public.rbac_permission_for_legacy(public.rbac_right_admin(), public.rbac_scope_org()); - v_allowed uuid[] := '{}'::uuid[]; -BEGIN - SELECT auth.uid() INTO v_user_id; - SELECT public.get_apikey_header() INTO v_api_key_text; - - IF v_user_id IS NULL AND v_api_key_text IS NULL THEN - RETURN v_allowed; - END IF; - - IF v_api_key_text IS NOT NULL THEN - SELECT * INTO v_api_key FROM public.find_apikey_by_value(v_api_key_text) LIMIT 1; - IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN v_allowed; - END IF; - v_user_id := v_api_key.user_id; - END IF; - - SELECT COALESCE(array_agg(DISTINCT orgs.id), '{}'::uuid[]) - INTO v_allowed - FROM public.orgs - WHERE CASE - WHEN v_api_key.id IS NOT NULL THEN public.rbac_check_permission_direct(v_permission, v_user_id, orgs.id, NULL, NULL, v_api_key_text) - ELSE public.check_min_rights('admin'::public.user_min_right, v_user_id, orgs.id, NULL::character varying, NULL::bigint) - END; - - RETURN v_allowed; -END; -$$; - -ALTER FUNCTION "public"."usage_credit_readable_org_ids"() OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_user_org_ids"() RETURNS TABLE("org_id" "uuid") -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key public.apikeys%ROWTYPE; - v_user_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - - IF api_key_text IS NOT NULL THEN - SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; - IF api_key.id IS NULL THEN - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - IF public.is_apikey_expired(api_key.expires_at) THEN - RAISE EXCEPTION 'API key has expired'; - END IF; - - RETURN QUERY - SELECT DISTINCT scoped.org_uuid - FROM ( - SELECT rb.org_id AS org_uuid - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = api_key.rbac_id - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.apps ON apps.id = rb.app_id - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = api_key.rbac_id - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.channels ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = api_key.rbac_id - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ) scoped - WHERE scoped.org_uuid IS NOT NULL; - RETURN; - END IF; - - SELECT public.get_identity() INTO v_user_id; - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - - RETURN QUERY - SELECT DISTINCT scoped.org_uuid - FROM ( - SELECT rb.org_id AS org_uuid - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_user_id - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT apps.owner_org AS org_uuid - FROM public.role_bindings rb - JOIN public.apps ON apps.id = rb.app_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = v_user_id - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT rb.org_id AS org_uuid - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = v_user_id - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT ou.org_id AS org_uuid - FROM public.org_users ou - WHERE ou.user_id = v_user_id - AND ou.user_right::text LIKE 'invite_%' - ) scoped - WHERE scoped.org_uuid IS NOT NULL; -END; -$$; - -ALTER FUNCTION "public"."get_user_org_ids"() OWNER TO "postgres"; -COMMENT ON FUNCTION "public"."get_user_org_ids"() IS 'Org id list for authenticated users or RBAC-scoped API keys.'; - -DROP FUNCTION IF EXISTS "public"."get_orgs_v6"(); -DROP FUNCTION IF EXISTS "public"."get_orgs_v6"("userid" "uuid"); - -CREATE OR REPLACE FUNCTION "public"."get_orgs_v6"("userid" "uuid") RETURNS TABLE("gid" "uuid", "created_by" "uuid", "logo" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "use_new_rbac" boolean) -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - RETURN QUERY - SELECT - orgs.gid, - orgs.created_by, - orgs.logo, - orgs.name, - orgs.role, - orgs.paying, - orgs.trial_left, - orgs.can_use_more, - orgs.is_canceled, - orgs.app_count, - orgs.subscription_start, - orgs.subscription_end, - orgs.management_email, - orgs.is_yearly, - orgs.use_new_rbac - FROM public.get_orgs_v7(userid) orgs; -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v6"("userid" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_orgs_v6"("userid" "uuid") FROM PUBLIC; -REVOKE ALL ON FUNCTION "public"."get_orgs_v6"("userid" "uuid") FROM "anon"; -REVOKE ALL ON FUNCTION "public"."get_orgs_v6"("userid" "uuid") FROM "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_orgs_v6"("userid" "uuid") TO "postgres"; -GRANT EXECUTE ON FUNCTION "public"."get_orgs_v6"("userid" "uuid") TO "service_role"; -COMMENT ON FUNCTION "public"."get_orgs_v6"("userid" "uuid") IS 'Legacy V6 organization shape for service-role compatibility. Authorization is backed by RBAC via get_orgs_v7.'; - -CREATE OR REPLACE FUNCTION "public"."get_orgs_v6"() RETURNS TABLE("gid" "uuid", "created_by" "uuid", "logo" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "use_new_rbac" boolean) -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; -BEGIN - SELECT public.get_apikey_header() INTO v_api_key_text; - IF v_api_key_text IS NOT NULL THEN - SELECT * INTO v_api_key FROM public.find_apikey_by_value(v_api_key_text) LIMIT 1; - IF v_api_key.id IS NULL THEN - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - IF public.is_apikey_expired(v_api_key.expires_at) THEN - RAISE EXCEPTION 'API key has expired'; - END IF; - v_user_id := v_api_key.user_id; - ELSE - SELECT public.get_identity() INTO v_user_id; - END IF; - - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - - RETURN QUERY - SELECT - orgs.gid, - orgs.created_by, - orgs.logo, - orgs.name, - orgs.role, - orgs.paying, - orgs.trial_left, - orgs.can_use_more, - orgs.is_canceled, - orgs.app_count, - orgs.subscription_start, - orgs.subscription_end, - orgs.management_email, - orgs.is_yearly, - orgs.use_new_rbac - FROM public.get_orgs_v7(v_user_id) orgs - JOIN public.get_user_org_ids() allowed_orgs ON allowed_orgs.org_id = orgs.gid; -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v6"() OWNER TO "postgres"; -GRANT ALL ON FUNCTION "public"."get_orgs_v6"() TO "anon"; -GRANT ALL ON FUNCTION "public"."get_orgs_v6"() TO "authenticated"; -GRANT ALL ON FUNCTION "public"."get_orgs_v6"() TO "service_role"; -COMMENT ON FUNCTION "public"."get_orgs_v6"() IS 'Legacy V6 organization shape for old CLI compatibility. Authorization is backed by RBAC.'; - -CREATE OR REPLACE FUNCTION "public"."get_orgs_v7"("userid" "uuid") RETURNS TABLE("gid" "uuid", "created_by" "uuid", "created_at" timestamp with time zone, "logo" "text", "website" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "stats_updated_at" timestamp without time zone, "stats_refresh_requested_at" timestamp without time zone, "next_stats_update_at" timestamp with time zone, "credit_available" numeric, "credit_total" numeric, "credit_next_expiration" timestamp with time zone, "enforcing_2fa" boolean, "2fa_has_access" boolean, "enforce_hashed_api_keys" boolean, "password_policy_config" "jsonb", "password_has_access" boolean, "require_apikey_expiration" boolean, "max_apikey_expiration_days" integer, "enforce_encrypted_bundles" boolean, "required_encryption_key" character varying, "use_new_rbac" boolean) -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - RETURN QUERY - WITH app_counts AS ( - SELECT owner_org, COUNT(*) AS cnt - FROM public.apps - GROUP BY owner_org - ), - rbac_role_candidates AS ( - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION ALL - SELECT rb.org_id, r.name, r.priority_rank - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.roles r ON rb.role_id = r.id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - rbac_org_roles AS ( - SELECT org_id, (ARRAY_AGG(rbac_role_candidates.name ORDER BY rbac_role_candidates.priority_rank DESC))[1] AS role_name - FROM rbac_role_candidates - GROUP BY org_id - ), - rbac_org_ids AS ( - SELECT org_id - FROM rbac_org_roles - UNION - SELECT apps.owner_org - FROM public.role_bindings rb - JOIN public.apps ON apps.id = rb.app_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - SELECT apps.owner_org - FROM public.role_bindings rb - JOIN public.channels ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = userid - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - SELECT rb.org_id - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - SELECT apps.owner_org - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.apps ON apps.id = rb.app_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - UNION - SELECT apps.owner_org - FROM public.role_bindings rb - JOIN public.group_members gm ON gm.group_id = rb.principal_id - JOIN public.channels ch ON ch.rbac_id = rb.channel_id - JOIN public.apps ON apps.app_id = ch.app_id - WHERE rb.principal_type = public.rbac_principal_group() - AND gm.user_id = userid - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - user_orgs AS ( - SELECT rbac_org_ids.org_id - FROM rbac_org_ids - WHERE rbac_org_ids.org_id IS NOT NULL - UNION - SELECT ou.org_id - FROM public.org_users ou - WHERE ou.user_id = userid - AND ou.user_right::text LIKE 'invite_%' - ), - time_constants AS ( - SELECT - NOW() AS current_time, - date_trunc('MONTH', NOW()) AS current_month_start, - '0 DAYS'::INTERVAL AS zero_day_interval - ), - paying_orgs_ordered AS ( - SELECT - o.id, - ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 AS preceding_count - FROM public.orgs o - JOIN public.stripe_info si ON o.customer_id = si.customer_id - CROSS JOIN time_constants tc - WHERE ( - (si.status = 'succeeded' - AND (si.canceled_at IS NULL OR si.canceled_at > tc.current_time) - AND si.subscription_anchor_end > tc.current_time) - OR si.trial_at > tc.current_time - ) - ), - billing_cycles AS ( - SELECT - o.id AS org_id, - CASE - WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) - > tc.current_time - tc.current_month_start - THEN date_trunc('MONTH', tc.current_time - INTERVAL '1 MONTH') - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) - ELSE tc.current_month_start - + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) - END AS cycle_start - FROM public.orgs o - CROSS JOIN time_constants tc - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - ), - two_fa_access AS ( - SELECT - o.id AS org_id, - o.enforcing_2fa, - CASE - WHEN o.enforcing_2fa = false THEN true - ELSE public.has_2fa_enabled(userid) - END AS "2fa_has_access", - (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ), - password_policy_access AS ( - SELECT - o.id AS org_id, - o.password_policy_config, - public.user_meets_password_policy(userid, o.id) AS password_has_access, - NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - ) - SELECT - o.id AS gid, - o.created_by, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE o.created_at - END AS created_at, - o.logo, - o.website, - o.name, - COALESCE(ou.user_right::varchar, ror.role_name::varchar, public.rbac_role_org_member()::varchar) AS role, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'succeeded', false) - END AS paying, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 - ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer - END AS trial_left, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE((si.status = 'succeeded' AND si.is_good_plan = true) - OR (si.trial_at::date - NOW()::date > 0) - OR COALESCE(ucb.available_credits, 0) > 0, false) - END AS can_use_more, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.status = 'canceled', false) - END AS is_canceled, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint - ELSE COALESCE(ac.cnt, 0) - END AS app_count, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE bc.cycle_start - END AS subscription_start, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE (bc.cycle_start + INTERVAL '1 MONTH') - END AS subscription_end, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text - ELSE o.management_email - END AS management_email, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE COALESCE(si.price_id = p.price_y_id, false) - END AS is_yearly, - o.stats_updated_at, - o.stats_refresh_requested_at, - CASE - WHEN poo.id IS NOT NULL THEN - public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) - ELSE NULL - END AS next_stats_update_at, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.available_credits, 0) - END AS credit_available, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric - ELSE COALESCE(ucb.total_credits, 0) - END AS credit_total, - CASE - WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz - ELSE ucb.next_expiration - END AS credit_next_expiration, - tfa.enforcing_2fa, - tfa."2fa_has_access", - o.enforce_hashed_api_keys, - ppa.password_policy_config, - ppa.password_has_access, - o.require_apikey_expiration, - o.max_apikey_expiration_days, - o.enforce_encrypted_bundles, - o.required_encryption_key, - true AS use_new_rbac - FROM public.orgs o - JOIN user_orgs uo ON uo.org_id = o.id - LEFT JOIN public.org_users ou - ON ou.user_id = userid - AND o.id = ou.org_id - AND ou.user_right::text LIKE 'invite_%' - LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id - LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id - LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans p ON si.product_id = p.stripe_id - LEFT JOIN app_counts ac ON ac.owner_org = o.id - LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id - LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id - LEFT JOIN billing_cycles bc ON bc.org_id = o.id; -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v7"("userid" "uuid") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."get_orgs_v7"() RETURNS TABLE("gid" "uuid", "created_by" "uuid", "created_at" timestamp with time zone, "logo" "text", "website" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "stats_updated_at" timestamp without time zone, "stats_refresh_requested_at" timestamp without time zone, "next_stats_update_at" timestamp with time zone, "credit_available" numeric, "credit_total" numeric, "credit_next_expiration" timestamp with time zone, "enforcing_2fa" boolean, "2fa_has_access" boolean, "enforce_hashed_api_keys" boolean, "password_policy_config" "jsonb", "password_has_access" boolean, "require_apikey_expiration" boolean, "max_apikey_expiration_days" integer, "enforce_encrypted_bundles" boolean, "required_encryption_key" character varying, "use_new_rbac" boolean) -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; -BEGIN - SELECT public.get_apikey_header() INTO v_api_key_text; - IF v_api_key_text IS NOT NULL THEN - SELECT * INTO v_api_key FROM public.find_apikey_by_value(v_api_key_text) LIMIT 1; - IF v_api_key.id IS NULL THEN - RAISE EXCEPTION 'Invalid API key provided'; - END IF; - IF public.is_apikey_expired(v_api_key.expires_at) THEN - RAISE EXCEPTION 'API key has expired'; - END IF; - v_user_id := v_api_key.user_id; - ELSE - SELECT public.get_identity() INTO v_user_id; - END IF; - - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'No authentication provided - API key or valid session required'; - END IF; - - RETURN QUERY - SELECT orgs.* - FROM public.get_orgs_v7(v_user_id) orgs - JOIN public.get_user_org_ids() allowed_orgs ON allowed_orgs.org_id = orgs.gid; -END; -$$; - -ALTER FUNCTION "public"."get_orgs_v7"() OWNER TO "postgres"; - -DROP FUNCTION IF EXISTS "public"."get_org_apikeys"("p_org_id" "uuid"); -CREATE OR REPLACE FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") RETURNS TABLE( - "id" bigint, - "rbac_id" "uuid", - "name" "text", - "user_id" "uuid", - "owner_email" character varying, - "created_at" timestamp with time zone, - "expires_at" timestamp with time zone -) -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -BEGIN - IF NOT public.rbac_check_permission_direct( - public.rbac_perm_org_update_user_roles(), - auth.uid(), - p_org_id, - NULL, - NULL, - NULL - ) THEN - RAISE EXCEPTION 'NO_RIGHTS'; - END IF; - - RETURN QUERY - SELECT DISTINCT - ak.id, - ak.rbac_id, - ak.name::text, - ak.user_id, - users.email, - ak.created_at, - ak.expires_at - FROM public.apikeys ak - JOIN public.users users ON users.id = ak.user_id - JOIN public.role_bindings rb - ON rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = ak.rbac_id - AND rb.org_id = p_org_id - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ORDER BY ak.created_at DESC; -END; -$$; - -ALTER FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") TO "service_role"; -GRANT EXECUTE ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") TO "authenticated"; - -CREATE OR REPLACE FUNCTION "public"."rbac_org_role_for_legacy_right"("legacy_right" "public"."user_min_right") -RETURNS text -LANGUAGE "plpgsql" -IMMUTABLE -SET search_path = '' -AS $$ -BEGIN - IF legacy_right >= public.rbac_right_super_admin()::public.user_min_right THEN - RETURN public.rbac_role_org_super_admin(); - ELSIF legacy_right >= public.rbac_right_admin()::public.user_min_right THEN - RETURN public.rbac_role_org_admin(); - END IF; - - RETURN public.rbac_role_org_member(); -END; -$$; - -ALTER FUNCTION "public"."rbac_org_role_for_legacy_right"("public"."user_min_right") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."rbac_org_role_for_legacy_right"("public"."user_min_right") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."rbac_org_role_for_legacy_right"("public"."user_min_right") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") RETURNS character varying -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - org record; - invited_user record; - current_record record; - current_tmp_user record; - role_id uuid; - role_priority integer; - caller_max_priority integer := 0; - legacy_right public.user_min_right; - invite_right public.user_min_right; - api_key_text text; - api_key_row public.apikeys%ROWTYPE; - v_granted_by uuid; - v_principal_type text; - v_principal_id uuid; -BEGIN - SELECT * INTO org FROM public.orgs WHERE public.orgs.id = invite_user_to_org_rbac.org_id; - IF org IS NULL THEN - RETURN 'NO_ORG'; - END IF; - - SELECT r.id, r.priority_rank INTO role_id, role_priority - FROM public.roles r - WHERE r.name = invite_user_to_org_rbac.role_name - AND r.scope_type = public.rbac_scope_org() - AND r.is_assignable = true - LIMIT 1; - - IF role_id IS NULL THEN - RETURN 'ROLE_NOT_FOUND'; - END IF; - - SELECT public.get_apikey_header() INTO api_key_text; - IF api_key_text IS NOT NULL THEN - SELECT * INTO api_key_row FROM public.find_apikey_by_value(api_key_text) LIMIT 1; - v_granted_by := api_key_row.user_id; - v_principal_type := public.rbac_principal_apikey(); - v_principal_id := api_key_row.rbac_id; - ELSE - v_granted_by := auth.uid(); - v_principal_type := public.rbac_principal_user(); - v_principal_id := auth.uid(); - END IF; - - IF invite_user_to_org_rbac.role_name = public.rbac_role_org_super_admin() THEN - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN - RETURN 'NO_RIGHTS'; - END IF; - ELSE - IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_invite_user(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN - RETURN 'NO_RIGHTS'; - END IF; - END IF; - - IF v_principal_id IS NULL THEN - RETURN 'NO_RIGHTS'; - END IF; - - SELECT COALESCE(MAX(r.priority_rank), 0) INTO caller_max_priority - FROM public.role_bindings rb - JOIN public.roles r - ON r.id = rb.role_id - AND r.scope_type = rb.scope_type - WHERE rb.principal_type = v_principal_type - AND rb.principal_id = v_principal_id - AND rb.org_id = invite_user_to_org_rbac.org_id - AND (rb.expires_at IS NULL OR rb.expires_at > now()); - - IF caller_max_priority < role_priority THEN - RETURN 'NO_RIGHTS'; - END IF; - - legacy_right := public.rbac_legacy_right_for_org_role(invite_user_to_org_rbac.role_name); - invite_right := public.transform_role_to_invite(legacy_right); - - SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email = invite_user_to_org_rbac.email; - - IF invited_user IS NOT NULL THEN - SELECT public.org_users.id INTO current_record - FROM public.org_users - WHERE public.org_users.user_id = invited_user.id - AND public.org_users.org_id = invite_user_to_org_rbac.org_id; - - IF current_record IS NOT NULL THEN - RETURN 'ALREADY_INVITED'; - ELSE - INSERT INTO public.org_users (user_id, org_id, user_right, rbac_role_name) - VALUES (invited_user.id, invite_user_to_org_rbac.org_id, invite_right, invite_user_to_org_rbac.role_name); - - INSERT INTO public.role_bindings ( - principal_type, principal_id, role_id, scope_type, org_id, - granted_by, granted_at, expires_at, reason, is_direct - ) VALUES ( - public.rbac_principal_user(), invited_user.id, role_id, public.rbac_scope_org(), invite_user_to_org_rbac.org_id, - COALESCE(v_granted_by, invited_user.id), now(), now() - INTERVAL '1 second', 'Pending invitation', true - ) ON CONFLICT DO NOTHING; - - RETURN 'OK'; - END IF; - ELSE - SELECT * INTO current_tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email = invite_user_to_org_rbac.email - AND public.tmp_users.org_id = invite_user_to_org_rbac.org_id; - - IF current_tmp_user IS NOT NULL THEN - IF current_tmp_user.cancelled_at IS NOT NULL THEN - IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN - RETURN 'TOO_RECENT_INVITATION_CANCELATION'; - ELSE - RETURN 'NO_EMAIL'; - END IF; - ELSE - RETURN 'ALREADY_INVITED'; - END IF; - ELSE - RETURN 'NO_EMAIL'; - END IF; - END IF; -END; -$$; - -ALTER FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."invite_user_to_org"( - "email" character varying, - "org_id" "uuid", - "invite_type" "public"."user_min_right" -) RETURNS character varying -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - legacy_right public.user_min_right; - role_name text; -BEGIN - legacy_right := public.transform_role_to_non_invite(invite_type); - role_name := public.rbac_org_role_for_legacy_right(legacy_right); - - RETURN public.invite_user_to_org_rbac(email, org_id, role_name); -END; -$$; - -ALTER FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") TO "service_role"; - -COMMENT ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") IS 'Compatibility wrapper for old invite callers. Legacy role inputs are converted to RBAC roles.'; - -CREATE OR REPLACE FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") RETURNS character varying -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -SET row_security = off -AS $$ -DECLARE - invite public.org_users%ROWTYPE; - invite_user_id uuid; - invite_org_id uuid; - legacy_right public.user_min_right; - role_name text; - role_id uuid; -BEGIN - SELECT public.org_users.* - INTO invite - FROM public.org_users - WHERE public.org_users.org_id = accept_invitation_to_org.org_id - AND public.org_users.user_id = (SELECT auth.uid()) - ORDER BY (public.org_users.user_right::text LIKE 'invite_%') DESC, - public.org_users.created_at DESC NULLS LAST, - public.org_users.id DESC - LIMIT 1; - - IF invite.id IS NOT NULL AND invite.user_right::text NOT LIKE 'invite_%' THEN - RETURN 'INVALID_ROLE'; - END IF; - - IF invite.id IS NOT NULL THEN - invite_user_id := invite.user_id; - invite_org_id := invite.org_id; - legacy_right := public.transform_role_to_non_invite(invite.user_right); - role_name := COALESCE(invite.rbac_role_name, public.rbac_org_role_for_legacy_right(legacy_right)); - ELSE - SELECT rb.principal_id, rb.org_id, r.name - INTO invite_user_id, invite_org_id, role_name - FROM public.role_bindings rb - JOIN public.roles r - ON r.id = rb.role_id - AND r.scope_type = rb.scope_type - WHERE rb.principal_type = public.rbac_principal_user() - AND rb.principal_id = (SELECT auth.uid()) - AND rb.org_id = accept_invitation_to_org.org_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.reason IN ('Pending invitation', 'Invited via invite_user_to_org_rbac') - ORDER BY rb.granted_at DESC NULLS LAST - LIMIT 1; - - IF invite_user_id IS NULL THEN - RETURN 'NO_INVITE'; - END IF; - - legacy_right := public.rbac_legacy_right_for_org_role(role_name); - END IF; - - IF role_name IS NULL THEN - RETURN 'ROLE_NOT_FOUND'; - END IF; - - SELECT public.roles.id INTO role_id - FROM public.roles - WHERE public.roles.name = role_name - AND public.roles.scope_type = public.rbac_scope_org() - AND public.roles.is_assignable = true - LIMIT 1; - - IF role_id IS NULL THEN - RETURN 'ROLE_NOT_FOUND'; - END IF; - - UPDATE public.org_users - SET user_right = legacy_right, - rbac_role_name = role_name, - updated_at = CURRENT_TIMESTAMP - WHERE public.org_users.id = invite.id; - - IF invite.id IS NULL THEN - INSERT INTO public.org_users (user_id, org_id, user_right, rbac_role_name) - VALUES (invite_user_id, invite_org_id, legacy_right, role_name); - END IF; - - DELETE FROM public.role_bindings - WHERE public.role_bindings.principal_type = public.rbac_principal_user() - AND public.role_bindings.principal_id = invite_user_id - AND public.role_bindings.scope_type = public.rbac_scope_org() - AND public.role_bindings.org_id = invite_org_id; - - INSERT INTO public.role_bindings ( - principal_type, - principal_id, - role_id, - scope_type, - org_id, - app_id, - channel_id, - granted_by, - granted_at, - reason, - is_direct - ) VALUES ( - public.rbac_principal_user(), - invite_user_id, - role_id, - public.rbac_scope_org(), - invite_org_id, - NULL, - NULL, - auth.uid(), - now(), - 'Accepted invitation', - true - ) ON CONFLICT DO NOTHING; - - RETURN 'OK'; -END; -$$; - -ALTER FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") TO "service_role"; - -COMMENT ON FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") IS 'Accepts a pending org invite and creates the active RBAC binding. Kept for old clients.'; - -CREATE OR REPLACE FUNCTION "public"."modify_permissions_tmp"( - "email" "text", - "org_id" "uuid", - "new_role" "public"."user_min_right" -) RETURNS character varying -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - tmp_user record; - non_invite_role public.user_min_right; - v_rbac_role_name text; -BEGIN - non_invite_role := public.transform_role_to_non_invite(new_role); - v_rbac_role_name := public.rbac_org_role_for_legacy_right(non_invite_role); - - PERFORM 1 FROM public.orgs WHERE public.orgs.id = modify_permissions_tmp.org_id; - IF NOT FOUND THEN - RETURN 'NO_ORG'; - END IF; - - IF NOT public.check_min_rights( - 'admin'::public.user_min_right, - (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], modify_permissions_tmp.org_id)), - modify_permissions_tmp.org_id, - NULL::varchar, - NULL::bigint - ) THEN - RETURN 'NO_RIGHTS'; - END IF; - - IF non_invite_role = 'super_admin'::public.user_min_right - AND NOT public.check_min_rights( - 'super_admin'::public.user_min_right, - (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], modify_permissions_tmp.org_id)), - modify_permissions_tmp.org_id, - NULL::varchar, - NULL::bigint - ) - THEN - RETURN 'NO_RIGHTS_FOR_SUPER_ADMIN'; - END IF; - - SELECT * INTO tmp_user - FROM public.tmp_users - WHERE public.tmp_users.email = modify_permissions_tmp.email - AND public.tmp_users.org_id = modify_permissions_tmp.org_id; - - IF NOT FOUND THEN - RETURN 'NO_INVITATION'; - END IF; - IF tmp_user.cancelled_at IS NOT NULL THEN - RETURN 'INVITATION_CANCELLED'; - END IF; - - UPDATE public.tmp_users - SET role = non_invite_role, - rbac_role_name = v_rbac_role_name, - updated_at = CURRENT_TIMESTAMP - WHERE public.tmp_users.id = tmp_user.id; - - RETURN 'OK'; -END; -$$; - -ALTER FUNCTION "public"."modify_permissions_tmp"("email" "text", "org_id" "uuid", "new_role" "public"."user_min_right") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."modify_permissions_tmp"("email" "text", "org_id" "uuid", "new_role" "public"."user_min_right") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."modify_permissions_tmp"("email" "text", "org_id" "uuid", "new_role" "public"."user_min_right") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."modify_permissions_tmp"("email" "text", "org_id" "uuid", "new_role" "public"."user_min_right") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."enforce_apikey_expiration_policy"() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - scoped_org record; -BEGIN - IF TG_OP = 'UPDATE' - AND NEW.expires_at IS NOT DISTINCT FROM OLD.expires_at THEN - RETURN NEW; - END IF; - - FOR scoped_org IN - SELECT DISTINCT - public.orgs.id, - public.orgs.require_apikey_expiration, - public.orgs.max_apikey_expiration_days - FROM public.role_bindings - JOIN public.orgs ON public.orgs.id = public.role_bindings.org_id - WHERE public.role_bindings.principal_type = public.rbac_principal_apikey() - AND public.role_bindings.principal_id = NEW.rbac_id - AND public.role_bindings.org_id IS NOT NULL - LOOP - IF scoped_org.require_apikey_expiration AND NEW.expires_at IS NULL THEN - RAISE EXCEPTION USING - ERRCODE = 'P0001', - MESSAGE = 'expiration_required', - DETAIL = 'This organization requires API keys to have an expiration date'; - END IF; - - IF scoped_org.max_apikey_expiration_days IS NOT NULL - AND NEW.expires_at IS NOT NULL - AND NEW.expires_at > clock_timestamp() + make_interval(days => scoped_org.max_apikey_expiration_days) - THEN - RAISE EXCEPTION USING - ERRCODE = 'P0001', - MESSAGE = 'expiration_exceeds_max', - DETAIL = format('API key expiration cannot exceed %s days for this organization', scoped_org.max_apikey_expiration_days); - END IF; - END LOOP; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."enforce_apikey_expiration_policy"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."enforce_apikey_expiration_policy"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."enforce_apikey_expiration_policy"() TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_row public.apikeys%ROWTYPE; - scoped_org record; -BEGIN - IF NEW.principal_type <> public.rbac_principal_apikey() - OR NEW.org_id IS NULL - OR (NEW.expires_at IS NOT NULL AND NEW.expires_at <= now()) THEN - RETURN NEW; - END IF; - - SELECT * - INTO api_key_row - FROM public.apikeys - WHERE public.apikeys.rbac_id = NEW.principal_id - LIMIT 1; - - IF api_key_row.id IS NULL THEN - RETURN NEW; - END IF; - - SELECT - public.orgs.id, - public.orgs.require_apikey_expiration, - public.orgs.max_apikey_expiration_days - INTO scoped_org - FROM public.orgs - WHERE public.orgs.id = NEW.org_id - LIMIT 1; - - IF scoped_org.id IS NULL THEN - RETURN NEW; - END IF; - - IF scoped_org.require_apikey_expiration AND api_key_row.expires_at IS NULL THEN - RAISE EXCEPTION USING - ERRCODE = 'P0001', - MESSAGE = 'expiration_required', - DETAIL = 'This organization requires API keys to have an expiration date'; - END IF; - - IF scoped_org.max_apikey_expiration_days IS NOT NULL - AND api_key_row.expires_at IS NOT NULL - AND api_key_row.expires_at > clock_timestamp() + make_interval(days => scoped_org.max_apikey_expiration_days) - THEN - RAISE EXCEPTION USING - ERRCODE = 'P0001', - MESSAGE = 'expiration_exceeds_max', - DETAIL = format('API key expiration cannot exceed %s days for this organization', scoped_org.max_apikey_expiration_days); - END IF; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"() TO "service_role"; - -DROP TRIGGER IF EXISTS "role_bindings_enforce_apikey_expiration_policy" ON "public"."role_bindings"; -CREATE TRIGGER "role_bindings_enforce_apikey_expiration_policy" -BEFORE INSERT OR UPDATE OF principal_type, principal_id, org_id, expires_at -ON "public"."role_bindings" -FOR EACH ROW -EXECUTE FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"(); - -CREATE OR REPLACE FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") -RETURNS boolean -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - scoped_enforced_org_exists boolean; -BEGIN - IF apikey_row.key IS NULL AND apikey_row.key_hash IS NOT NULL THEN - RETURN true; - END IF; - - WITH scoped_orgs AS ( - SELECT public.role_bindings.org_id - FROM public.role_bindings - WHERE apikey_row.rbac_id IS NOT NULL - AND public.role_bindings.principal_type = public.rbac_principal_apikey() - AND public.role_bindings.principal_id = apikey_row.rbac_id - AND public.role_bindings.scope_type = public.rbac_scope_org() - AND public.role_bindings.org_id IS NOT NULL - AND (public.role_bindings.expires_at IS NULL OR public.role_bindings.expires_at > now()) - - UNION - - SELECT public.apps.owner_org - FROM public.role_bindings - JOIN public.apps ON public.apps.id = public.role_bindings.app_id - WHERE apikey_row.rbac_id IS NOT NULL - AND public.role_bindings.principal_type = public.rbac_principal_apikey() - AND public.role_bindings.principal_id = apikey_row.rbac_id - AND public.role_bindings.scope_type = public.rbac_scope_app() - AND public.role_bindings.app_id IS NOT NULL - AND (public.role_bindings.expires_at IS NULL OR public.role_bindings.expires_at > now()) - - UNION - - SELECT public.apps.owner_org - FROM public.role_bindings - JOIN public.channels ON public.channels.rbac_id = public.role_bindings.channel_id - JOIN public.apps ON public.apps.app_id = public.channels.app_id - WHERE apikey_row.rbac_id IS NOT NULL - AND public.role_bindings.principal_type = public.rbac_principal_apikey() - AND public.role_bindings.principal_id = apikey_row.rbac_id - AND public.role_bindings.scope_type = public.rbac_scope_channel() - AND public.role_bindings.channel_id IS NOT NULL - AND (public.role_bindings.expires_at IS NULL OR public.role_bindings.expires_at > now()) - ) - SELECT EXISTS ( - SELECT 1 - FROM scoped_orgs - JOIN public.orgs ON public.orgs.id = scoped_orgs.org_id - WHERE public.orgs.enforce_hashed_api_keys = true - ) - INTO scoped_enforced_org_exists; - - IF scoped_enforced_org_exists THEN - PERFORM public.pg_log( - 'deny: ORG_REQUIRES_HASHED_API_KEY', - jsonb_build_object('apikey_id', apikey_row.id, 'user_id', apikey_row.user_id) - ); - RETURN false; - END IF; - - RETURN true; -END; -$$; - -ALTER FUNCTION "public"."check_apikey_hashed_key_enforcement"("public"."apikeys") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."check_apikey_hashed_key_enforcement"("public"."apikeys") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."check_apikey_hashed_key_enforcement"("public"."apikeys") TO "service_role"; - -CREATE OR REPLACE FUNCTION "public"."find_apikey_by_value"("key_value" "text") RETURNS SETOF "public"."apikeys" - LANGUAGE "plpgsql" SECURITY DEFINER - SET search_path = '' - AS $$ -DECLARE - apikey_row public.apikeys%ROWTYPE; -BEGIN - SELECT public.apikeys.* - INTO apikey_row - FROM public.apikeys - WHERE public.apikeys.key = key_value - OR public.apikeys.key_hash = encode(extensions.digest(key_value, 'sha256'), 'hex') - LIMIT 1; - - IF apikey_row.id IS NULL THEN - RETURN; - END IF; - - IF NOT public.check_apikey_hashed_key_enforcement(apikey_row) THEN - RETURN; - END IF; - - RETURN NEXT apikey_row; -END; -$$; - -ALTER FUNCTION "public"."find_apikey_by_value"("key_value" "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") TO "service_role"; - -DROP POLICY IF EXISTS "Allow admin to select webhooks" ON "public"."webhooks"; -DROP POLICY IF EXISTS "Allow admin to insert webhooks" ON "public"."webhooks"; -DROP POLICY IF EXISTS "Allow admin to update webhooks" ON "public"."webhooks"; -DROP POLICY IF EXISTS "Allow admin to delete webhooks" ON "public"."webhooks"; - -CREATE POLICY "Allow admin to select webhooks" -ON "public"."webhooks" -FOR SELECT -TO "authenticated", "anon" -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhooks" -ON "public"."webhooks" -FOR INSERT -TO "authenticated", "anon" -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to update webhooks" -ON "public"."webhooks" -FOR UPDATE -TO "authenticated", "anon" -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -) -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to delete webhooks" -ON "public"."webhooks" -FOR DELETE -TO "authenticated", "anon" -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -DROP POLICY IF EXISTS "Allow org members to select webhook_deliveries" ON "public"."webhook_deliveries"; -DROP POLICY IF EXISTS "Allow admin to insert webhook_deliveries" ON "public"."webhook_deliveries"; -DROP POLICY IF EXISTS "Allow admin to update webhook_deliveries" ON "public"."webhook_deliveries"; - -CREATE POLICY "Allow org members to select webhook_deliveries" -ON "public"."webhook_deliveries" -FOR SELECT -TO "authenticated", "anon" -USING ( - public.check_min_rights( - 'read'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to insert webhook_deliveries" -ON "public"."webhook_deliveries" -FOR INSERT -TO "authenticated", "anon" -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -CREATE POLICY "Allow admin to update webhook_deliveries" -ON "public"."webhook_deliveries" -FOR UPDATE -TO "authenticated", "anon" -USING ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -) -WITH CHECK ( - public.check_min_rights( - 'admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - org_id, - NULL::character varying, - NULL::bigint - ) -); - -DROP POLICY IF EXISTS "Allow owner to insert own apikeys" ON "public"."apikeys"; -DROP POLICY IF EXISTS "Deny client insert on apikeys" ON "public"."apikeys"; -CREATE POLICY "Deny client insert on apikeys" ON "public"."apikeys" -AS RESTRICTIVE -FOR INSERT -TO "anon", "authenticated" -WITH CHECK (false); - -DROP POLICY IF EXISTS "Allow owner to update own apikeys" ON "public"."apikeys"; -DROP POLICY IF EXISTS "Allow owner to update own V2 apikeys" ON "public"."apikeys"; -CREATE POLICY "Allow owner to update own apikeys" ON "public"."apikeys" -FOR UPDATE -TO "anon", "authenticated" -USING ( - "user_id" = (SELECT public.get_identity_for_apikey_creation()) -) -WITH CHECK ( - "user_id" = (SELECT public.get_identity_for_apikey_creation()) -); - --- API-key compatibility identity functions are intentionally not authorization --- gates for owner-scoped user/account tables. Those rows stay JWT-only. -DROP POLICY IF EXISTS "Allow owner to select own apikeys" ON "public"."apikeys"; -CREATE POLICY "Allow owner to select own apikeys" ON "public"."apikeys" -FOR SELECT -TO "authenticated" -USING ( - "user_id" = (SELECT auth.uid()) -); - -DROP POLICY IF EXISTS "Allow owner to delete own apikeys" ON "public"."apikeys"; -CREATE POLICY "Allow owner to delete own apikeys" ON "public"."apikeys" -FOR DELETE -TO "authenticated" -USING ( - "user_id" = (SELECT auth.uid()) -); - -DROP POLICY IF EXISTS "Allow owner to insert own users" ON "public"."users"; -CREATE POLICY "Allow owner to insert own users" ON "public"."users" -FOR INSERT -TO "authenticated" -WITH CHECK ( - "id" = (SELECT auth.uid()) - AND (SELECT public.is_not_deleted("users"."email")) -); - -DROP POLICY IF EXISTS "Allow owner to select own user" ON "public"."users"; -CREATE POLICY "Allow owner to select own user" ON "public"."users" -FOR SELECT -TO "authenticated" -USING ( - "id" = (SELECT auth.uid()) - AND (SELECT public.is_not_deleted("users"."email")) -); - -DROP POLICY IF EXISTS "Allow owner to update own users" ON "public"."users"; -CREATE POLICY "Allow owner to update own users" ON "public"."users" -FOR UPDATE -TO "authenticated" -USING ( - "id" = (SELECT auth.uid()) - AND (SELECT public.is_not_deleted("users"."email")) -) -WITH CHECK ( - "id" = (SELECT auth.uid()) - AND (SELECT public.is_not_deleted("users"."email")) -); - -DROP POLICY IF EXISTS "Allow insert org for apikey or user" ON "public"."orgs"; -DROP POLICY IF EXISTS "Allow insert org for user" ON "public"."orgs"; -CREATE POLICY "Allow insert org for user" ON "public"."orgs" -FOR INSERT -TO "authenticated" -WITH CHECK ( - "created_by" = (SELECT auth.uid()) -); - -DROP POLICY IF EXISTS "Allow all for auth (super_admin+)" ON "public"."apps"; -CREATE POLICY "Allow all for auth (super_admin+)" ON "public"."apps" -FOR DELETE -TO "authenticated", "anon" -USING ( - public.check_min_rights( - 'super_admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - owner_org, - app_id, - NULL::bigint - ) -); - -DROP POLICY IF EXISTS "Allow all for auth (super_admin+)" ON "public"."app_versions"; -CREATE POLICY "Allow all for auth (super_admin+)" ON "public"."app_versions" -FOR DELETE -TO "authenticated", "anon" -USING ( - public.check_min_rights( - 'super_admin'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - owner_org, - app_id, - NULL::bigint - ) -); - -DROP POLICY IF EXISTS "Allow update for auth (write+)" ON "public"."app_versions"; -DROP POLICY IF EXISTS "Allow update for api keys (write,all,upload) (upload+)" ON "public"."app_versions"; -DROP POLICY IF EXISTS "Allow update for auth and api keys" ON "public"."app_versions"; -CREATE POLICY "Allow update for auth and api keys" -ON "public"."app_versions" -FOR UPDATE -TO "authenticated", "anon" -USING ( - EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid, public.get_apikey_header() AS apikey) AS identity - WHERE ( - identity.uid IS NOT NULL - AND public.check_min_rights( - 'write'::public.user_min_right, - identity.uid, - owner_org, - app_id, - NULL::bigint - ) - ) - OR ( - identity.uid IS NULL - AND identity.apikey IS NOT NULL - AND public.check_min_rights( - 'upload'::public.user_min_right, - NULL::uuid, - owner_org, - app_id, - NULL::bigint - ) - ) - ) -) -WITH CHECK ( - EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid, public.get_apikey_header() AS apikey) AS identity - WHERE ( - identity.uid IS NOT NULL - AND public.check_min_rights( - 'write'::public.user_min_right, - identity.uid, - owner_org, - app_id, - NULL::bigint - ) - ) - OR ( - identity.uid IS NULL - AND identity.apikey IS NOT NULL - AND public.check_min_rights( - 'upload'::public.user_min_right, - NULL::uuid, - owner_org, - app_id, - NULL::bigint - ) - ) - ) -); - -DROP POLICY IF EXISTS "Allow insert for auth (write+)" ON "public"."channel_devices"; -CREATE POLICY "Allow insert for auth (write+)" ON "public"."channel_devices" -FOR INSERT -TO "authenticated" -WITH CHECK ( - public.check_min_rights( - 'write'::public.user_min_right, - (SELECT auth.uid()), - owner_org, - app_id, - NULL::bigint - ) -); - -DROP POLICY IF EXISTS "Allow read for auth (read+)" ON "public"."daily_bandwidth"; -CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_bandwidth" -FOR SELECT -TO "authenticated", "anon" -USING ( - EXISTS ( - SELECT 1 - FROM public.apps - WHERE apps.app_id = daily_bandwidth.app_id - AND public.check_min_rights( - 'read'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - apps.owner_org, - daily_bandwidth.app_id, - NULL::bigint - ) - ) -); - -DROP POLICY IF EXISTS "Allow read for auth (read+)" ON "public"."daily_mau"; -CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_mau" -FOR SELECT -TO "authenticated", "anon" -USING ( - EXISTS ( - SELECT 1 - FROM public.apps - WHERE apps.app_id = daily_mau.app_id - AND public.check_min_rights( - 'read'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - apps.owner_org, - daily_mau.app_id, - NULL::bigint - ) - ) -); - -DROP POLICY IF EXISTS "Allow read for auth (read+)" ON "public"."daily_storage"; -CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_storage" -FOR SELECT -TO "authenticated", "anon" -USING ( - EXISTS ( - SELECT 1 - FROM public.apps - WHERE apps.app_id = daily_storage.app_id - AND public.check_min_rights( - 'read'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - apps.owner_org, - daily_storage.app_id, - NULL::bigint - ) - ) -); - -DROP POLICY IF EXISTS "Allow read for auth (read+)" ON "public"."daily_version"; -CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_version" -FOR SELECT -TO "authenticated", "anon" -USING ( - EXISTS ( - SELECT 1 - FROM public.apps - WHERE apps.app_id = daily_version.app_id - AND public.check_min_rights( - 'read'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - apps.owner_org, - daily_version.app_id, - NULL::bigint - ) - ) -); - -DROP POLICY IF EXISTS "Allow apikey to read" ON "public"."stats"; -DROP POLICY IF EXISTS "Allow read for auth (read+)" ON "public"."stats"; -CREATE POLICY "Allow read for auth (read+)" ON "public"."stats" -FOR SELECT -TO "authenticated", "anon" -USING ( - EXISTS ( - SELECT 1 - FROM public.apps - WHERE apps.app_id = stats.app_id - AND public.check_min_rights( - 'read'::public.user_min_right, - CASE - WHEN (SELECT public.get_apikey_header()) IS NOT NULL THEN NULL::uuid - ELSE (SELECT auth.uid()) - END, - apps.owner_org, - stats.app_id, - NULL::bigint - ) - ) -); - -CREATE OR REPLACE FUNCTION "public"."get_total_metrics"() RETURNS TABLE( - "mau" bigint, - "storage" bigint, - "bandwidth" bigint, - "build_time_unit" bigint, - "get" bigint, - "fail" bigint, - "install" bigint, - "uninstall" bigint -) -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_request_org_id uuid; - v_org_id_text text; - v_auth_uid uuid; - v_request_apikey text; -BEGIN - SELECT auth.uid() INTO v_auth_uid; - SELECT public.get_apikey_header() INTO v_request_apikey; - - IF v_auth_uid IS NULL AND (v_request_apikey IS NULL OR v_request_apikey = '') THEN - RETURN; - END IF; - - SELECT current_setting('request.jwt.claim.org_id', true) INTO v_org_id_text; - - IF v_org_id_text IS NOT NULL AND v_org_id_text <> '' THEN - BEGIN - v_request_org_id := v_org_id_text::uuid; - EXCEPTION WHEN invalid_text_representation THEN - v_request_org_id := NULL; - END; - END IF; - - IF v_request_org_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 - FROM public.get_user_org_ids() allowed_orgs - WHERE allowed_orgs.org_id = v_request_org_id - ) THEN - RETURN; - END IF; - - IF v_request_org_id IS NULL THEN - SELECT allowed_orgs.org_id - INTO v_request_org_id - FROM public.get_user_org_ids() allowed_orgs - ORDER BY allowed_orgs.org_id - LIMIT 1; - END IF; - - IF v_request_org_id IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - SELECT - metrics.mau, - metrics.storage, - metrics.bandwidth, - metrics.build_time_unit, - metrics.get, - metrics.fail, - metrics.install, - metrics.uninstall - FROM public.get_total_metrics(v_request_org_id) AS metrics; -END; -$$; - -ALTER FUNCTION "public"."get_total_metrics"() OWNER TO "postgres"; - -CREATE OR REPLACE FUNCTION "public"."regenerate_hashed_apikey"("p_apikey_id" bigint) RETURNS "public"."apikeys" -LANGUAGE "plpgsql" -SET search_path = '' -AS $$ -DECLARE - v_user_id uuid; -BEGIN - SELECT public.get_identity_for_apikey_creation() INTO v_user_id; - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'No authentication provided'; - END IF; - - RETURN public.regenerate_hashed_apikey_for_user(p_apikey_id, v_user_id); -END; -$$; - -ALTER FUNCTION "public"."regenerate_hashed_apikey"("p_apikey_id" bigint) OWNER TO "postgres"; - -DROP FUNCTION IF EXISTS "public"."create_hashed_apikey"("public"."key_mode", "text", "uuid"[], "text"[], timestamp with time zone); -DROP FUNCTION IF EXISTS "public"."create_hashed_apikey_for_user"("uuid", "public"."key_mode", "text", "uuid"[], "text"[], timestamp with time zone); - -ALTER TABLE "public"."apikeys" - DROP COLUMN IF EXISTS "mode", - DROP COLUMN IF EXISTS "limited_to_orgs", - DROP COLUMN IF EXISTS "limited_to_apps"; - -CREATE OR REPLACE FUNCTION "public"."get_accessible_apps_for_apikey_v2"( - "apikey" "text" DEFAULT NULL -) RETURNS SETOF "public"."apps" -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_request_apikey text; - v_api_key public.apikeys%ROWTYPE; -BEGIN - SELECT public.get_apikey_header() INTO v_request_apikey; - - IF v_request_apikey IS NULL OR v_request_apikey = '' THEN - RETURN; - END IF; - - IF apikey IS NOT NULL AND apikey <> '' AND apikey IS DISTINCT FROM v_request_apikey THEN - RETURN; - END IF; - - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(v_request_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN; - END IF; - - RETURN QUERY - SELECT apps.* - FROM public.apps - WHERE public.rbac_check_permission_direct( - public.rbac_perm_app_read(), - v_api_key.user_id, - apps.owner_org, - apps.app_id, - NULL, - v_request_apikey - ) - ORDER BY apps.created_at DESC; -END; -$$; - -ALTER FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") TO "service_role"; -COMMENT ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") IS 'Returns apps visible to the request capgkey using RBAC permission checks. The apikey argument is retained for CLI compatibility and must match the header when provided.'; - -CREATE OR REPLACE FUNCTION "public"."get_organization_cli_warnings"( - "orgid" "uuid", - "cli_version" "text" -) RETURNS "jsonb"[] -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - messages jsonb[] := ARRAY[]::jsonb[]; - request_apikey text; - api_key public.apikeys%ROWTYPE; - fallback_app_id text; - has_org_read boolean; -BEGIN - PERFORM cli_version; - - has_org_read := public.cli_check_permission( - permission_key := public.rbac_perm_org_read(), - org_id := orgid - ); - - IF NOT has_org_read THEN - SELECT public.get_apikey_header() INTO request_apikey; - - IF request_apikey IS NOT NULL AND request_apikey <> '' THEN - SELECT * - INTO api_key - FROM public.find_apikey_by_value(request_apikey) - LIMIT 1; - - IF api_key.id IS NOT NULL - AND NOT public.is_apikey_expired(api_key.expires_at) - THEN - SELECT public.apps.app_id - INTO fallback_app_id - FROM public.role_bindings rb - JOIN public.apps ON public.apps.id = rb.app_id - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = api_key.rbac_id - AND rb.scope_type = public.rbac_scope_app() - AND rb.app_id IS NOT NULL - AND public.apps.owner_org = orgid - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ORDER BY public.apps.app_id - LIMIT 1; - - IF fallback_app_id IS NOT NULL THEN - has_org_read := public.cli_check_permission( - permission_key := public.rbac_perm_app_read(), - org_id := orgid, - app_id := fallback_app_id - ); - END IF; - END IF; - END IF; - END IF; - - IF NOT has_org_read THEN - messages := array_append(messages, jsonb_build_object( - 'message', 'API key does not have read access to this organization', - 'fatal', true - )); - RETURN messages; - END IF; - - IF ( - public.is_paying_and_good_plan_org_action(orgid, ARRAY['mau']::public.action_type[]) = true - AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::public.action_type[]) = true - AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::public.action_type[]) = false - ) THEN - messages := array_append(messages, jsonb_build_object( - 'message', 'You have exceeded your storage limit.\nUpload will fail, but you can still download your data.\nMAU and bandwidth limits are not exceeded.\nIn order to upload your plan, please upgrade your plan here: https://console.capgo.app/settings/plans.', - 'fatal', true - )); - END IF; - - RETURN messages; -END; -$$; - -ALTER FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") TO "service_role"; -COMMENT ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") IS 'CLI compatibility warning helper backed by RBAC API key bindings. App-scoped V2 keys are accepted for old CLI warning checks when they can read at least one app in the requested org.'; diff --git a/supabase/migrations/20260528002613_fix_apikey_v2_app_versions_rls_perf.sql b/supabase/migrations/20260528002613_fix_apikey_v2_app_versions_rls_perf.sql deleted file mode 100644 index 782fe3c0ef..0000000000 --- a/supabase/migrations/20260528002613_fix_apikey_v2_app_versions_rls_perf.sql +++ /dev/null @@ -1,180 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."app_versions_readable_app_ids"() -RETURNS character varying[] -LANGUAGE "plpgsql" VOLATILE SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_user_id uuid; - v_api_key_text text; - v_api_key public.apikeys%ROWTYPE; - v_principal_type text; - v_principal_id uuid; - v_allowed character varying[] := '{}'::character varying[]; -BEGIN - SELECT auth.uid() INTO v_user_id; - SELECT public.get_apikey_header() INTO v_api_key_text; - - IF v_api_key_text IS NOT NULL THEN - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(v_api_key_text) - LIMIT 1; - - IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN v_allowed; - END IF; - - v_user_id := v_api_key.user_id; - v_principal_type := public.rbac_principal_apikey(); - v_principal_id := v_api_key.rbac_id; - ELSIF v_user_id IS NOT NULL THEN - v_principal_type := public.rbac_principal_user(); - v_principal_id := v_user_id; - ELSE - RETURN v_allowed; - END IF; - - IF v_principal_id IS NULL THEN - RETURN v_allowed; - END IF; - - WITH RECURSIVE direct_bindings AS ( - SELECT rb.role_id, rb.scope_type, rb.org_id, rb.app_id - FROM public.role_bindings rb - WHERE rb.principal_type = v_principal_type - AND rb.principal_id = v_principal_id - AND rb.scope_type IN (public.rbac_scope_org(), public.rbac_scope_app()) - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT rb.role_id, rb.scope_type, rb.org_id, rb.app_id - FROM public.group_members gm - INNER JOIN public.groups g ON g.id = gm.group_id - INNER JOIN public.role_bindings rb - ON rb.principal_type = public.rbac_principal_group() - AND rb.principal_id = gm.group_id - AND rb.org_id = g.org_id - WHERE v_principal_type = public.rbac_principal_user() - AND gm.user_id = v_principal_id - AND rb.scope_type IN (public.rbac_scope_org(), public.rbac_scope_app()) - AND rb.org_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - role_closure AS ( - SELECT - direct_bindings.role_id, - direct_bindings.role_id AS effective_role_id, - direct_bindings.scope_type, - direct_bindings.org_id, - direct_bindings.app_id - FROM direct_bindings - - UNION - - SELECT - role_closure.role_id, - role_hierarchy.child_role_id, - role_closure.scope_type, - role_closure.org_id, - role_closure.app_id - FROM role_closure - INNER JOIN public.role_hierarchy - ON role_hierarchy.parent_role_id = role_closure.effective_role_id - INNER JOIN public.roles child_role - ON child_role.id = role_hierarchy.child_role_id - AND child_role.scope_type = role_closure.scope_type - ), - readable_scopes AS ( - SELECT DISTINCT role_closure.scope_type, role_closure.org_id, role_closure.app_id - FROM role_closure - INNER JOIN public.role_permissions - ON role_permissions.role_id = role_closure.effective_role_id - INNER JOIN public.permissions - ON permissions.id = role_permissions.permission_id - WHERE permissions.key = public.rbac_perm_app_read() - ), - legacy_readable_scopes AS ( - SELECT - CASE - WHEN org_users.app_id IS NULL THEN public.rbac_scope_org() - ELSE public.rbac_scope_app() - END AS scope_type, - org_users.org_id, - apps.id AS app_id - FROM public.org_users - LEFT JOIN public.apps - ON apps.app_id = org_users.app_id - AND apps.owner_org = org_users.org_id - WHERE v_api_key_text IS NULL - AND v_user_id IS NOT NULL - AND org_users.user_id = v_user_id - AND org_users.user_right >= 'read'::public.user_min_right - AND org_users.channel_id IS NULL - ), - scoped_apps AS ( - SELECT apps.app_id, apps.owner_org - FROM readable_scopes - INNER JOIN public.apps - ON apps.owner_org = readable_scopes.org_id - WHERE readable_scopes.scope_type = public.rbac_scope_org() - - UNION - - SELECT apps.app_id, apps.owner_org - FROM readable_scopes - INNER JOIN public.apps - ON apps.id = readable_scopes.app_id - AND apps.owner_org = readable_scopes.org_id - WHERE readable_scopes.scope_type = public.rbac_scope_app() - AND readable_scopes.app_id IS NOT NULL - - UNION - - SELECT apps.app_id, apps.owner_org - FROM legacy_readable_scopes - INNER JOIN public.apps - ON apps.owner_org = legacy_readable_scopes.org_id - WHERE legacy_readable_scopes.scope_type = public.rbac_scope_org() - - UNION - - SELECT apps.app_id, apps.owner_org - FROM legacy_readable_scopes - INNER JOIN public.apps - ON apps.id = legacy_readable_scopes.app_id - AND apps.owner_org = legacy_readable_scopes.org_id - WHERE legacy_readable_scopes.scope_type = public.rbac_scope_app() - AND legacy_readable_scopes.app_id IS NOT NULL - ), - candidate_orgs AS ( - SELECT DISTINCT scoped_apps.owner_org - FROM scoped_apps - ), - readable_orgs AS ( - SELECT orgs.id - FROM candidate_orgs - INNER JOIN public.orgs ON orgs.id = candidate_orgs.owner_org - WHERE ( - orgs.enforcing_2fa IS NOT TRUE - OR (v_user_id IS NOT NULL AND public.has_2fa_enabled(v_user_id)) - ) - AND public.user_meets_password_policy(v_user_id, orgs.id) IS DISTINCT FROM false - ) - SELECT COALESCE(array_agg(DISTINCT scoped_apps.app_id), '{}'::character varying[]) - INTO v_allowed - FROM scoped_apps - INNER JOIN readable_orgs ON readable_orgs.id = scoped_apps.owner_org; - - RETURN v_allowed; -END; -$$; - -ALTER FUNCTION "public"."app_versions_readable_app_ids"() OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."app_versions_readable_app_ids"() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."app_versions_readable_app_ids"() TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."app_versions_readable_app_ids"() TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."app_versions_readable_app_ids"() TO "service_role"; - -COMMENT ON FUNCTION "public"."app_versions_readable_app_ids"() IS -'Returns app IDs whose bundle rows are readable by the current authenticated user or Capgo API key. The lookup starts from caller-scoped role bindings and expands role permissions set-wise so targeted app_versions updates do not scan every app through per-app RBAC checks.'; diff --git a/supabase/migrations/20260528013304_fix_app_version_upload_policies.sql b/supabase/migrations/20260528013304_fix_app_version_upload_policies.sql deleted file mode 100644 index 08363d07b3..0000000000 --- a/supabase/migrations/20260528013304_fix_app_version_upload_policies.sql +++ /dev/null @@ -1,342 +0,0 @@ -COMMENT ON FUNCTION "public"."app_versions_readable_app_ids"() IS -'Returns app IDs whose bundle rows are readable by the current authenticated user or Capgo API key. The lookup starts from caller-scoped role bindings and expands role permissions set-wise for compatibility; targeted app_versions RLS checks use app_versions_has_app_permission instead.'; - -CREATE OR REPLACE FUNCTION "public"."find_apikey_by_value"("key_value" "text") RETURNS SETOF "public"."apikeys" -LANGUAGE "plpgsql" SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - apikey_row public.apikeys%ROWTYPE; - key_value_hash text; -BEGIN - IF key_value IS NULL OR key_value = '' THEN - RETURN; - END IF; - - key_value_hash := encode(extensions.digest(key_value, 'sha256'), 'hex'); - - SELECT public.apikeys.* - INTO apikey_row - FROM public.apikeys - WHERE public.apikeys.key_hash = key_value_hash - LIMIT 1; - - IF apikey_row.id IS NULL THEN - SELECT public.apikeys.* - INTO apikey_row - FROM public.apikeys - WHERE public.apikeys.key = key_value - LIMIT 1; - END IF; - - IF apikey_row.id IS NULL THEN - RETURN; - END IF; - - IF NOT public.check_apikey_hashed_key_enforcement(apikey_row) THEN - RETURN; - END IF; - - RETURN NEXT apikey_row; -END; -$$; - -ALTER FUNCTION "public"."find_apikey_by_value"("key_value" "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") TO "service_role"; - -COMMENT ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") IS -'Resolves an API key by hashed key first and legacy plain key second. The two-step lookup keeps API-key RLS checks on indexed paths instead of a broad OR predicate.'; - -CREATE INDEX IF NOT EXISTS "idx_group_members_user_id_group_id" -ON "public"."group_members" ("user_id", "group_id"); - -CREATE OR REPLACE FUNCTION "public"."app_versions_has_app_permission"( - "p_min_right" "public"."user_min_right", - "p_owner_org" "uuid", - "p_app_id" character varying, - "p_user_id" "uuid", - "p_apikey" "text" -) -RETURNS boolean -LANGUAGE "plpgsql" VOLATILE SECURITY DEFINER -SET "search_path" TO '' -AS $$ -DECLARE - v_user_id uuid := p_user_id; - v_api_key public.apikeys%ROWTYPE; - v_principal_type text; - v_principal_id uuid; - v_app_uuid uuid; - v_app_owner_org uuid; - v_permission text; -BEGIN - IF p_min_right IS NULL OR p_owner_org IS NULL OR p_app_id IS NULL THEN - RETURN false; - END IF; - - SELECT apps.id, apps.owner_org - INTO v_app_uuid, v_app_owner_org - FROM public.apps - WHERE apps.app_id = p_app_id - LIMIT 1; - - IF v_app_uuid IS NULL OR v_app_owner_org IS DISTINCT FROM p_owner_org THEN - RETURN false; - END IF; - - IF p_apikey IS NOT NULL THEN - SELECT * INTO v_api_key - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL - OR public.is_apikey_expired(v_api_key.expires_at) - OR (p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id) - THEN - RETURN false; - END IF; - - v_user_id := v_api_key.user_id; - v_principal_type := public.rbac_principal_apikey(); - v_principal_id := v_api_key.rbac_id; - ELSE - IF v_user_id IS NULL THEN - RETURN false; - END IF; - - v_principal_type := public.rbac_principal_user(); - v_principal_id := v_user_id; - END IF; - - IF v_principal_id IS NULL OR v_user_id IS NULL THEN - RETURN false; - END IF; - - IF (SELECT orgs.enforcing_2fa FROM public.orgs WHERE orgs.id = v_app_owner_org) - AND NOT public.has_2fa_enabled(v_user_id) - THEN - RETURN false; - END IF; - - IF public.user_meets_password_policy(v_user_id, v_app_owner_org) IS FALSE THEN - RETURN false; - END IF; - - IF v_principal_type = public.rbac_principal_user() - AND EXISTS ( - SELECT 1 - FROM public.org_users - WHERE org_users.user_id = v_principal_id - AND org_users.org_id = v_app_owner_org - AND org_users.channel_id IS NULL - AND (org_users.app_id IS NULL OR org_users.app_id = p_app_id) - AND org_users.user_right >= p_min_right - LIMIT 1 - ) - THEN - RETURN true; - END IF; - - v_permission := public.rbac_permission_for_legacy(p_min_right, public.rbac_scope_app()); - IF v_permission IS NULL THEN - RETURN false; - END IF; - - RETURN EXISTS ( - WITH RECURSIVE direct_bindings AS ( - SELECT rb.role_id, rb.scope_type - FROM public.role_bindings rb - WHERE rb.principal_type = v_principal_type - AND rb.principal_id = v_principal_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = v_app_owner_org - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT rb.role_id, rb.scope_type - FROM public.role_bindings rb - WHERE rb.principal_type = v_principal_type - AND rb.principal_id = v_principal_id - AND rb.scope_type = public.rbac_scope_app() - AND rb.org_id = v_app_owner_org - AND rb.app_id = v_app_uuid - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - - UNION - - SELECT rb.role_id, rb.scope_type - FROM public.group_members gm - INNER JOIN public.groups g ON g.id = gm.group_id - INNER JOIN public.role_bindings rb - ON rb.principal_type = public.rbac_principal_group() - AND rb.principal_id = gm.group_id - AND rb.org_id = g.org_id - WHERE v_principal_type = public.rbac_principal_user() - AND gm.user_id = v_principal_id - AND g.org_id = v_app_owner_org - AND ( - ( - rb.scope_type = public.rbac_scope_org() - AND rb.org_id = v_app_owner_org - ) - OR ( - rb.scope_type = public.rbac_scope_app() - AND rb.org_id = v_app_owner_org - AND rb.app_id = v_app_uuid - ) - ) - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ), - role_closure AS ( - SELECT direct_bindings.role_id, direct_bindings.scope_type - FROM direct_bindings - - UNION - - SELECT role_hierarchy.child_role_id, role_closure.scope_type - FROM role_closure - INNER JOIN public.role_hierarchy - ON role_hierarchy.parent_role_id = role_closure.role_id - INNER JOIN public.roles child_role - ON child_role.id = role_hierarchy.child_role_id - AND child_role.scope_type = role_closure.scope_type - ) - SELECT 1 - FROM role_closure - INNER JOIN public.role_permissions - ON role_permissions.role_id = role_closure.role_id - INNER JOIN public.permissions - ON permissions.id = role_permissions.permission_id - WHERE permissions.key = v_permission - LIMIT 1 - ); -END; -$$; - -ALTER FUNCTION "public"."app_versions_has_app_permission"("public"."user_min_right", "uuid", character varying, "uuid", "text") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."app_versions_has_app_permission"("public"."user_min_right", "uuid", character varying, "uuid", "text") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."app_versions_has_app_permission"("public"."user_min_right", "uuid", character varying, "uuid", "text") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."app_versions_has_app_permission"("public"."user_min_right", "uuid", character varying, "uuid", "text") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."app_versions_has_app_permission"("public"."user_min_right", "uuid", character varying, "uuid", "text") TO "service_role"; - -COMMENT ON FUNCTION "public"."app_versions_has_app_permission"("public"."user_min_right", "uuid", character varying, "uuid", "text") IS -'Checks app_versions access for one target app. Used by app_versions RLS write/read paths so broad API keys with many app bindings do not materialize every linked app during bundle upload finalization.'; - -DROP POLICY IF EXISTS "Allow for auth, api keys (read+)" ON "public"."app_versions"; -CREATE POLICY "Allow for auth, api keys (read+)" -ON "public"."app_versions" -FOR SELECT -TO "authenticated", "anon" -USING ( - ( - (SELECT auth.uid()) IS NOT NULL - OR (SELECT public.get_apikey_header()) IS NOT NULL - ) - AND EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid, public.get_apikey_header() AS apikey) AS identity - WHERE ( - identity.uid IS NOT NULL - AND public.app_versions_has_app_permission( - 'read'::public.user_min_right, - owner_org, - app_id, - identity.uid, - NULL::text - ) - ) - OR ( - identity.uid IS NULL - AND identity.apikey IS NOT NULL - AND public.app_versions_has_app_permission( - 'read'::public.user_min_right, - owner_org, - app_id, - NULL::uuid, - identity.apikey - ) - ) - ) -); - -DROP POLICY IF EXISTS "Allow insert for api keys (write,all,upload) (upload+)" ON "public"."app_versions"; -CREATE POLICY "Allow insert for api keys (write,all,upload) (upload+)" -ON "public"."app_versions" -FOR INSERT -TO "anon" -WITH CHECK ( - EXISTS ( - SELECT 1 - FROM (SELECT public.get_apikey_header() AS apikey) AS identity - WHERE identity.apikey IS NOT NULL - AND public.app_versions_has_app_permission( - 'upload'::public.user_min_right, - owner_org, - app_id, - NULL::uuid, - identity.apikey - ) - ) -); - -DROP POLICY IF EXISTS "Allow update for auth and api keys" ON "public"."app_versions"; -CREATE POLICY "Allow update for auth and api keys" -ON "public"."app_versions" -FOR UPDATE -TO "authenticated", "anon" -USING ( - EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid, public.get_apikey_header() AS apikey) AS identity - WHERE ( - identity.uid IS NOT NULL - AND public.app_versions_has_app_permission( - 'write'::public.user_min_right, - owner_org, - app_id, - identity.uid, - NULL::text - ) - ) - OR ( - identity.uid IS NULL - AND identity.apikey IS NOT NULL - AND public.app_versions_has_app_permission( - 'upload'::public.user_min_right, - owner_org, - app_id, - NULL::uuid, - identity.apikey - ) - ) - ) -) -WITH CHECK ( - EXISTS ( - SELECT 1 - FROM (SELECT auth.uid() AS uid, public.get_apikey_header() AS apikey) AS identity - WHERE ( - identity.uid IS NOT NULL - AND public.app_versions_has_app_permission( - 'write'::public.user_min_right, - owner_org, - app_id, - identity.uid, - NULL::text - ) - ) - OR ( - identity.uid IS NULL - AND identity.apikey IS NOT NULL - AND public.app_versions_has_app_permission( - 'upload'::public.user_min_right, - owner_org, - app_id, - NULL::uuid, - identity.apikey - ) - ) - ) -); diff --git a/supabase/migrations/20260528023934_optimize_apikey_hashed_enforcement.sql b/supabase/migrations/20260528023934_optimize_apikey_hashed_enforcement.sql deleted file mode 100644 index 4e1fa7bf86..0000000000 --- a/supabase/migrations/20260528023934_optimize_apikey_hashed_enforcement.sql +++ /dev/null @@ -1,81 +0,0 @@ -CREATE INDEX IF NOT EXISTS "orgs_enforce_hashed_api_keys_true_idx" -ON "public"."orgs" ("id") -WHERE "enforce_hashed_api_keys" = true; - -CREATE OR REPLACE FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") RETURNS boolean -LANGUAGE "plpgsql" SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - scoped_enforced_org_exists boolean; -BEGIN - IF apikey_row.key IS NULL AND apikey_row.key_hash IS NOT NULL THEN - RETURN true; - END IF; - - IF apikey_row.rbac_id IS NULL THEN - RETURN true; - END IF; - - WITH enforced_orgs AS ( - SELECT public.orgs.id - FROM public.orgs - WHERE public.orgs.enforce_hashed_api_keys = true - ) - SELECT EXISTS ( - SELECT 1 - FROM enforced_orgs - WHERE EXISTS ( - SELECT 1 - FROM public.role_bindings rb - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = apikey_row.rbac_id - AND rb.scope_type = public.rbac_scope_org() - AND rb.org_id = enforced_orgs.id - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ) - OR EXISTS ( - SELECT 1 - FROM public.role_bindings rb - JOIN public.apps apps - ON apps.id = rb.app_id - AND apps.owner_org = enforced_orgs.id - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = apikey_row.rbac_id - AND rb.scope_type = public.rbac_scope_app() - AND rb.app_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ) - OR EXISTS ( - SELECT 1 - FROM public.role_bindings rb - JOIN public.channels channels - ON channels.rbac_id = rb.channel_id - AND channels.owner_org = enforced_orgs.id - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = apikey_row.rbac_id - AND rb.scope_type = public.rbac_scope_channel() - AND rb.channel_id IS NOT NULL - AND (rb.expires_at IS NULL OR rb.expires_at > now()) - ) - ) - INTO scoped_enforced_org_exists; - - IF scoped_enforced_org_exists THEN - PERFORM public.pg_log( - 'deny: ORG_REQUIRES_HASHED_API_KEY', - jsonb_build_object('apikey_id', apikey_row.id, 'user_id', apikey_row.user_id) - ); - RETURN false; - END IF; - - RETURN true; -END; -$$; - -ALTER FUNCTION "public"."check_apikey_hashed_key_enforcement"("public"."apikeys") OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."check_apikey_hashed_key_enforcement"("public"."apikeys") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."check_apikey_hashed_key_enforcement"("public"."apikeys") TO "service_role"; - -COMMENT ON FUNCTION "public"."check_apikey_hashed_key_enforcement"("public"."apikeys") IS -'Rejects plaintext API keys when any scoped org requires hashed API keys. The lookup starts from enforcing orgs and indexed RBAC bindings so broad API keys do not scan every app binding on each permission check.'; diff --git a/supabase/migrations/20260528090340_manifest_index.sql b/supabase/migrations/20260528090340_manifest_index.sql deleted file mode 100644 index 73d28c63e8..0000000000 --- a/supabase/migrations/20260528090340_manifest_index.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_manifest_file_name ON public.manifest USING btree (file_name); - -CREATE INDEX IF NOT EXISTS idx_manifest_file_hash ON public.manifest USING btree (file_hash); diff --git a/supabase/migrations/20260528113224_missing_indexs.sql b/supabase/migrations/20260528113224_missing_indexs.sql deleted file mode 100644 index 7ee98fdc47..0000000000 --- a/supabase/migrations/20260528113224_missing_indexs.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_audit_logs_record_id ON public.audit_logs USING btree (record_id); - -CREATE INDEX IF NOT EXISTS idx_apps_default_upload_channel ON public.apps USING btree (default_upload_channel); - -CREATE INDEX IF NOT EXISTS idx_usage_credit_transactions_org_id ON public.usage_credit_transactions USING btree (org_id); - -CREATE INDEX IF NOT EXISTS idx_usage_overage_events_org_id ON public.usage_overage_events USING btree (org_id); diff --git a/supabase/migrations/20260529075127_storage_hourly_shadow_billing.sql b/supabase/migrations/20260529075127_storage_hourly_shadow_billing.sql deleted file mode 100644 index 95d20d444f..0000000000 --- a/supabase/migrations/20260529075127_storage_hourly_shadow_billing.sql +++ /dev/null @@ -1,60 +0,0 @@ -CREATE TABLE IF NOT EXISTS "public"."daily_storage_hourly" ( - "app_id" character varying(255) NOT NULL REFERENCES "public"."apps"("app_id") ON DELETE CASCADE, - "owner_org" uuid NOT NULL REFERENCES "public"."orgs"("id") ON DELETE CASCADE, - "date" date NOT NULL, - "storage_byte_hours" double precision NOT NULL DEFAULT 0, - "created_at" timestamp with time zone NOT NULL DEFAULT "now"(), - "updated_at" timestamp with time zone NOT NULL DEFAULT "now"(), - CONSTRAINT "daily_storage_hourly_pkey" PRIMARY KEY ("app_id", "date") -); - -ALTER TABLE "public"."daily_storage_hourly" OWNER TO "postgres"; - -COMMENT ON TABLE "public"."daily_storage_hourly" IS 'Shadow daily storage-hour usage, recorded as byte-hours. This is intentionally not used for billing until storage-hour billing is explicitly enabled.'; -COMMENT ON COLUMN "public"."daily_storage_hourly"."storage_byte_hours" IS 'Byte-hour contribution for this UTC day.'; - -CREATE INDEX IF NOT EXISTS "idx_daily_storage_hourly_date" ON "public"."daily_storage_hourly" USING "btree" ("date"); -CREATE INDEX IF NOT EXISTS "idx_daily_storage_hourly_owner_org_date" ON "public"."daily_storage_hourly" USING "btree" ("owner_org", "date"); -CREATE INDEX IF NOT EXISTS "idx_version_meta_app_id_timestamp" ON "public"."version_meta" USING "btree" ("app_id", "timestamp"); - -ALTER TABLE "public"."daily_storage_hourly" ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_storage_hourly" -FOR SELECT -TO "anon", "authenticated" -USING ( - "public"."check_min_rights"( - 'read'::"public"."user_min_right", - "public"."get_identity_org_appid"( - '{read,upload,write,all}'::"public"."key_mode"[], - "daily_storage_hourly"."owner_org", - "daily_storage_hourly"."app_id" - ), - "daily_storage_hourly"."owner_org", - "daily_storage_hourly"."app_id", - NULL::bigint - ) -); - -CREATE POLICY "Deny insert on daily_storage_hourly" ON "public"."daily_storage_hourly" -AS RESTRICTIVE -FOR INSERT -TO "anon", "authenticated" -WITH CHECK (false); - -CREATE POLICY "Deny update on daily_storage_hourly" ON "public"."daily_storage_hourly" -AS RESTRICTIVE -FOR UPDATE -TO "anon", "authenticated" -USING (false) -WITH CHECK (false); - -CREATE POLICY "Deny delete on daily_storage_hourly" ON "public"."daily_storage_hourly" -AS RESTRICTIVE -FOR DELETE -TO "anon", "authenticated" -USING (false); - -GRANT SELECT ON TABLE "public"."daily_storage_hourly" TO "anon"; -GRANT SELECT ON TABLE "public"."daily_storage_hourly" TO "authenticated"; -GRANT ALL ON TABLE "public"."daily_storage_hourly" TO "service_role"; diff --git a/supabase/migrations/20260530083657_add_builder_onboarding_pref.sql b/supabase/migrations/20260530083657_add_builder_onboarding_pref.sql deleted file mode 100644 index 465dae224e..0000000000 --- a/supabase/migrations/20260530083657_add_builder_onboarding_pref.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Add builder_onboarding preference for users and set default to true --- (Builder native-build onboarding recovery — separate from the OTA 'onboarding' key) - --- Backfill existing users who already have email_preferences set -UPDATE public.users -SET email_preferences = email_preferences || '{"builder_onboarding": true}'::jsonb -WHERE email_preferences IS NOT NULL - AND NOT (email_preferences ? 'builder_onboarding'); - --- Update the column default to include the new key -ALTER TABLE public.users -ALTER COLUMN email_preferences SET DEFAULT '{"usage_limit": true, "credit_usage": true, "onboarding": true, "builder_onboarding": true, "weekly_stats": true, "monthly_stats": true, "billing_period_stats": true, "deploy_stats_24h": true, "bundle_created": true, "bundle_deployed": true, "device_error": true, "channel_self_rejected": true, "cli_realtime_feed": true}'::jsonb; - -COMMENT ON COLUMN public.users.email_preferences IS 'Per-user email notification preferences. Keys: usage_limit, credit_usage, onboarding, builder_onboarding, weekly_stats, monthly_stats, billing_period_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected, cli_realtime_feed. Values are booleans.'; diff --git a/supabase/migrations/20260530114525_add_bundle_incompatible_pref.sql b/supabase/migrations/20260530114525_add_bundle_incompatible_pref.sql deleted file mode 100644 index 7320a308ad..0000000000 --- a/supabase/migrations/20260530114525_add_bundle_incompatible_pref.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Add bundle_incompatible preference for users and set default to true --- (Emailed when an uploaded bundle / `capgo bundle compatibility` check is --- incompatible with the channel's live native packages — separate from the --- bundle_created / bundle_deployed keys) - --- Backfill existing users who already have email_preferences set -UPDATE public.users -SET email_preferences = email_preferences || '{"bundle_incompatible": true}'::jsonb -WHERE email_preferences IS NOT NULL - AND NOT (email_preferences ? 'bundle_incompatible'); - --- Update the column default to include the new key -ALTER TABLE public.users -ALTER COLUMN email_preferences SET DEFAULT '{"usage_limit": true, "credit_usage": true, "onboarding": true, "builder_onboarding": true, "weekly_stats": true, "monthly_stats": true, "billing_period_stats": true, "deploy_stats_24h": true, "bundle_created": true, "bundle_deployed": true, "device_error": true, "channel_self_rejected": true, "cli_realtime_feed": true, "bundle_incompatible": true}'::jsonb; - -COMMENT ON COLUMN public.users.email_preferences IS 'Per-user email notification preferences. Keys: usage_limit, credit_usage, onboarding, builder_onboarding, weekly_stats, monthly_stats, billing_period_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected, cli_realtime_feed, bundle_incompatible. Values are booleans.'; diff --git a/supabase/migrations/20260531063221_get_org_apps_with_last_upload.sql b/supabase/migrations/20260531063221_get_org_apps_with_last_upload.sql deleted file mode 100644 index 85f002bd06..0000000000 --- a/supabase/migrations/20260531063221_get_org_apps_with_last_upload.sql +++ /dev/null @@ -1,142 +0,0 @@ --- Paginated org apps listing that exposes each app's real "last upload" time. --- --- The apps table only stores `updated_at`, which is bumped by unrelated edits and --- background/cron jobs (e.g. channel device-count refresh), so it is not a reliable --- "last upload" signal. This RPC derives `last_upload_at` from the created_at of the --- bundle matching the app's `last_version` and lets the database own search, sort, --- pagination and the total count so page ordering matches the displayed column. --- --- It returns the full apps row (plus last_upload_at and total_count) so it stays a --- drop-in replacement for the previous `from('apps').select()` query and the frontend --- needs no type assertions. --- --- SECURITY INVOKER: the function intentionally runs with the caller's rights so the --- existing RLS on `apps` (and `app_versions`) performs all visibility filtering. This --- mirrors the previous `from('apps').eq('owner_org', ...)` client query and avoids any --- privilege escalation. `p_org_id` is an additional, indexed narrowing filter on top of --- RLS, never a replacement for it. --- --- Scalability: the only per-row work is a LATERAL lookup into app_versions keyed by --- (app_id, name) which is served by the existing idx_app_id_name_app_versions index as a --- bounded equality seek. The outer scan is bounded to one org's apps via finx_apps_owner_org --- and then to a single page via LIMIT/OFFSET, so no large table is scanned per request. - -CREATE OR REPLACE FUNCTION "public"."get_org_apps_with_last_upload"( - "p_org_id" "uuid", - "p_search" "text" DEFAULT NULL, - "p_sort_by" "text" DEFAULT 'last_upload_at', - "p_sort_desc" boolean DEFAULT true, - "p_limit" integer DEFAULT 10, - "p_offset" integer DEFAULT 0 -) -RETURNS TABLE( - "created_at" timestamp with time zone, - "app_id" character varying, - "icon_url" character varying, - "user_id" "uuid", - "name" character varying, - "last_version" character varying, - "updated_at" timestamp with time zone, - "id" "uuid", - "retention" bigint, - "owner_org" "uuid", - "default_upload_channel" character varying, - "transfer_history" "jsonb"[], - "channel_device_count" bigint, - "manifest_bundle_count" bigint, - "expose_metadata" boolean, - "allow_preview" boolean, - "allow_device_custom_id" boolean, - "need_onboarding" boolean, - "existing_app" boolean, - "ios_store_url" "text", - "android_store_url" "text", - "stats_updated_at" timestamp without time zone, - "stats_refresh_requested_at" timestamp without time zone, - "build_timeout_seconds" bigint, - "build_timeout_updated_at" timestamp with time zone, - "last_upload_at" timestamp with time zone, - "total_count" bigint -) -LANGUAGE "plpgsql" -SECURITY INVOKER -SET "search_path" TO '' -AS $$ -DECLARE - v_limit integer := LEAST(GREATEST(COALESCE(p_limit, 10), 1), 100); - v_offset integer := GREATEST(COALESCE(p_offset, 0), 0); - v_search text := NULLIF(btrim(COALESCE(p_search, '')), ''); - -- Whitelist sort keys to avoid dynamic-SQL injection via p_sort_by. - v_sort text := CASE - WHEN p_sort_by IN ('name', 'last_version', 'updated_at', 'created_at', 'last_upload_at') - THEN p_sort_by - ELSE 'last_upload_at' - END; - v_desc boolean := COALESCE(p_sort_desc, true); -BEGIN - RETURN QUERY - WITH scoped AS ( - SELECT - a.*, - lv.created_at AS last_upload_at - FROM public.apps a - LEFT JOIN LATERAL ( - SELECT av.created_at - FROM public.app_versions av - WHERE av.app_id = a.app_id - AND av.name = a.last_version - AND av.deleted = false - ORDER BY av.created_at DESC - LIMIT 1 - ) lv ON a.last_version IS NOT NULL - WHERE a.owner_org = p_org_id - AND ( - v_search IS NULL - OR a.name ILIKE '%' || v_search || '%' - OR a.app_id ILIKE '%' || v_search || '%' - ) - ) - SELECT - s.*, - COUNT(*) OVER () AS total_count - FROM scoped s - ORDER BY - -- NULLS LAST in both directions so apps without uploads sort to the bottom. - CASE WHEN v_sort = 'last_upload_at' AND v_desc THEN s.last_upload_at END DESC NULLS LAST, - CASE WHEN v_sort = 'last_upload_at' AND NOT v_desc THEN s.last_upload_at END ASC NULLS LAST, - CASE WHEN v_sort = 'updated_at' AND v_desc THEN s.updated_at END DESC NULLS LAST, - CASE WHEN v_sort = 'updated_at' AND NOT v_desc THEN s.updated_at END ASC NULLS LAST, - CASE WHEN v_sort = 'created_at' AND v_desc THEN s.created_at END DESC NULLS LAST, - CASE WHEN v_sort = 'created_at' AND NOT v_desc THEN s.created_at END ASC NULLS LAST, - CASE WHEN v_sort = 'name' AND v_desc THEN s.name END DESC NULLS LAST, - CASE WHEN v_sort = 'name' AND NOT v_desc THEN s.name END ASC NULLS LAST, - CASE WHEN v_sort = 'last_version' AND v_desc THEN s.last_version END DESC NULLS LAST, - CASE WHEN v_sort = 'last_version' AND NOT v_desc THEN s.last_version END ASC NULLS LAST, - -- Stable tiebreaker so pagination is deterministic across pages. - s.app_id ASC - LIMIT v_limit - OFFSET v_offset; -END; -$$; - -ALTER FUNCTION "public"."get_org_apps_with_last_upload"( - "uuid", "text", "text", boolean, integer, integer -) OWNER TO "postgres"; - -COMMENT ON FUNCTION "public"."get_org_apps_with_last_upload"( - "uuid", "text", "text", boolean, integer, integer -) IS 'Paginated apps for one org with a derived last_upload_at (created_at of the bundle matching apps.last_version). Returns the full apps row plus last_upload_at and total_count. SECURITY INVOKER so RLS on apps/app_versions enforces visibility; p_org_id is an indexed narrowing filter on top of RLS. Search/sort/pagination/total_count are computed in SQL so page order matches the displayed last-upload sort.'; - --- Least privilege: no PUBLIC, only the user-context roles the frontend uses plus service_role. -REVOKE ALL ON FUNCTION "public"."get_org_apps_with_last_upload"( - "uuid", "text", "text", boolean, integer, integer -) FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."get_org_apps_with_last_upload"( - "uuid", "text", "text", boolean, integer, integer -) TO "anon"; -GRANT ALL ON FUNCTION "public"."get_org_apps_with_last_upload"( - "uuid", "text", "text", boolean, integer, integer -) TO "authenticated"; -GRANT ALL ON FUNCTION "public"."get_org_apps_with_last_upload"( - "uuid", "text", "text", boolean, integer, integer -) TO "service_role"; diff --git a/supabase/migrations/20260601101710_allow_apikey_org_status_rpcs.sql b/supabase/migrations/20260601101710_allow_apikey_org_status_rpcs.sql deleted file mode 100644 index c619258a4d..0000000000 --- a/supabase/migrations/20260601101710_allow_apikey_org_status_rpcs.sql +++ /dev/null @@ -1,13 +0,0 @@ --- The CLI uses the Supabase anon key and authenticates Capgo access with the --- capgkey request header. These status RPCs already perform org-scoped read --- checks via request_has_org_read_access(), so anon needs EXECUTE permission for --- valid API-key callers to receive trial warning context. -REVOKE ALL ON FUNCTION "public"."is_paying_org"("orgid" "uuid") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."is_paying_org"("orgid" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_paying_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_paying_org"("orgid" "uuid") TO "service_role"; - -REVOKE ALL ON FUNCTION "public"."is_trial_org"("orgid" "uuid") FROM PUBLIC; -GRANT EXECUTE ON FUNCTION "public"."is_trial_org"("orgid" "uuid") TO "anon"; -GRANT EXECUTE ON FUNCTION "public"."is_trial_org"("orgid" "uuid") TO "authenticated"; -GRANT EXECUTE ON FUNCTION "public"."is_trial_org"("orgid" "uuid") TO "service_role"; diff --git a/supabase/migrations/20260603102048_add_orgs_onboarding.sql b/supabase/migrations/20260603102048_add_orgs_onboarding.sql deleted file mode 100644 index 64b71ac2f7..0000000000 --- a/supabase/migrations/20260603102048_add_orgs_onboarding.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Org-level onboarding answers. A JSONB bag (not a single column) so we can add --- more onboarding questions later without another migration. Today it holds the --- creator's intent: --- {"intent": "unknown" | "ota" | "builder" | "both" | "exploring"} --- NOT NULL with a default, so existing rows backfill to {"intent": "unknown"}. --- A CHECK validates the intent value when present; extend it when adding new --- validated keys. -ALTER TABLE "public"."orgs" -ADD COLUMN IF NOT EXISTS "onboarding" "jsonb" DEFAULT '{"intent": "unknown"}'::"jsonb" NOT NULL; - -ALTER TABLE "public"."orgs" -ADD CONSTRAINT "orgs_onboarding_valid" CHECK ( - (jsonb_typeof("onboarding") = 'object') - AND ((NOT ("onboarding" ? 'intent')) OR (("onboarding" ->> 'intent') = ANY (ARRAY['unknown', 'ota', 'builder', 'both', 'exploring']))) -); - -COMMENT ON COLUMN "public"."orgs"."onboarding" IS 'Onboarding answers (extensible JSONB). Currently: {"intent": unknown|ota|builder|both|exploring}. Used for segmentation and to tailor the org experience.'; diff --git a/supabase/migrations/20260603113951_suppress_stats_refresh_audit_logs.sql b/supabase/migrations/20260603113951_suppress_stats_refresh_audit_logs.sql deleted file mode 100644 index 88daa1036f..0000000000 --- a/supabase/migrations/20260603113951_suppress_stats_refresh_audit_logs.sql +++ /dev/null @@ -1,120 +0,0 @@ -CREATE OR REPLACE FUNCTION "public"."audit_log_trigger"() RETURNS "trigger" -LANGUAGE "plpgsql" -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_old_record JSONB; - v_new_record JSONB; - v_changed_fields TEXT[]; - v_org_id UUID; - v_record_id TEXT; - v_user_id UUID; - v_key TEXT; - v_org_exists BOOLEAN; - v_stats_refresh_fields CONSTANT TEXT[] := ARRAY['stats_refresh_requested_at', 'stats_updated_at', 'updated_at']; -BEGIN - -- Skip audit logging for org DELETE operations - -- When an org is deleted, we can't insert into audit_logs because the org_id - -- foreign key would reference a non-existent org - IF TG_TABLE_NAME = 'orgs' AND TG_OP = 'DELETE' THEN - RETURN OLD; - END IF; - - -- Get current user from auth context or API key - -- Uses get_identity() WITH key_mode parameter to support both JWT auth and API key authentication - v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); - - -- Skip audit logging if no user is identified - -- We only want to log actions performed by authenticated users - IF v_user_id IS NULL THEN - RETURN COALESCE(NEW, OLD); - END IF; - - -- Convert records to JSONB based on operation type - IF TG_OP = 'DELETE' THEN - v_old_record := pg_catalog.to_jsonb(OLD); - v_new_record := NULL; - ELSIF TG_OP = 'INSERT' THEN - v_old_record := NULL; - v_new_record := pg_catalog.to_jsonb(NEW); - ELSE -- UPDATE - v_old_record := pg_catalog.to_jsonb(OLD); - v_new_record := pg_catalog.to_jsonb(NEW); - - -- Calculate changed fields by comparing old and new values - FOR v_key IN SELECT pg_catalog.jsonb_object_keys(v_new_record) - LOOP - IF v_old_record->v_key IS DISTINCT FROM v_new_record->v_key THEN - v_changed_fields := pg_catalog.array_append(v_changed_fields, v_key); - END IF; - END LOOP; - - -- Dashboard chart refreshes only touch stats refresh state. The apps table - -- also receives updated_at from its update trigger, so keep that out too. - IF TG_TABLE_NAME = ANY(ARRAY['apps', 'orgs']) - AND v_changed_fields && ARRAY['stats_refresh_requested_at', 'stats_updated_at'] - AND NOT EXISTS ( - SELECT 1 - FROM pg_catalog.unnest(v_changed_fields) AS changed_field(field_name) - WHERE changed_field.field_name <> ALL(v_stats_refresh_fields) - ) THEN - RETURN NEW; - END IF; - END IF; - - -- Get org_id and record_id based on table being modified - CASE TG_TABLE_NAME - WHEN 'orgs' THEN - v_org_id := COALESCE(NEW.id, OLD.id); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - WHEN 'apps' THEN - v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); - v_record_id := COALESCE(NEW.app_id, OLD.app_id)::TEXT; - WHEN 'channels' THEN - v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - WHEN 'app_versions' THEN - v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - WHEN 'org_users' THEN - v_org_id := COALESCE(NEW.org_id, OLD.org_id); - v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; - ELSE - -- Fallback for any other table (shouldn't happen with current triggers) - v_org_id := NULL; - v_record_id := NULL; - END CASE; - - -- Only insert if we have a valid org_id and the org still exists - -- This handles edge cases where related tables are deleted after the org - IF v_org_id IS NOT NULL THEN - -- Check if the org still exists (important for DELETE operations on child tables) - SELECT EXISTS(SELECT 1 FROM public.orgs WHERE id = v_org_id) INTO v_org_exists; - - IF v_org_exists THEN - INSERT INTO "public"."audit_logs" ( - table_name, record_id, operation, user_id, org_id, - old_record, new_record, changed_fields - ) VALUES ( - TG_TABLE_NAME, v_record_id, TG_OP, v_user_id, v_org_id, - v_old_record, v_new_record, v_changed_fields - ); - END IF; - END IF; - - RETURN COALESCE(NEW, OLD); -END; -$$; - -ALTER FUNCTION "public"."audit_log_trigger"() OWNER TO "postgres"; - -DELETE FROM "public"."audit_logs" -WHERE "operation" = 'UPDATE' - AND "table_name" = ANY(ARRAY['apps', 'orgs']) - AND "changed_fields" && ARRAY['stats_refresh_requested_at', 'stats_updated_at'] - AND NOT EXISTS ( - SELECT 1 - FROM pg_catalog.unnest("changed_fields") AS changed_field(field_name) - WHERE changed_field.field_name <> ALL(ARRAY['stats_refresh_requested_at', 'stats_updated_at', 'updated_at']) - ); diff --git a/supabase/migrations/20260603174942_restore_apikey_org_creation.sql b/supabase/migrations/20260603174942_restore_apikey_org_creation.sql deleted file mode 100644 index 22c9f7929a..0000000000 --- a/supabase/migrations/20260603174942_restore_apikey_org_creation.sql +++ /dev/null @@ -1,309 +0,0 @@ -CREATE OR REPLACE FUNCTION public.rbac_perm_org_create() -RETURNS text -LANGUAGE sql -IMMUTABLE -SET search_path = '' -AS $$ - SELECT 'org.create'::text -$$; - -ALTER FUNCTION public.rbac_perm_org_create() OWNER TO postgres; -REVOKE ALL ON FUNCTION public.rbac_perm_org_create() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.rbac_perm_org_create() TO anon; -GRANT EXECUTE ON FUNCTION public.rbac_perm_org_create() TO authenticated; -GRANT EXECUTE ON FUNCTION public.rbac_perm_org_create() TO service_role; - -COMMENT ON FUNCTION public.rbac_perm_org_create() IS - 'Global API-key permission for creating a new organization before an org-scoped RBAC binding can exist.'; - -CREATE TABLE IF NOT EXISTS public.apikey_global_permissions ( - id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - apikey_rbac_id uuid NOT NULL REFERENCES public.apikeys(rbac_id) ON DELETE CASCADE, - permission_key text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - granted_by uuid REFERENCES public.users(id) ON DELETE SET NULL, - reason text, - CONSTRAINT apikey_global_permissions_permission_key_not_empty CHECK (permission_key <> ''), - CONSTRAINT apikey_global_permissions_rbac_permission_unique UNIQUE (apikey_rbac_id, permission_key) -); - -ALTER TABLE public.apikey_global_permissions OWNER TO postgres; -ALTER TABLE public.apikey_global_permissions ENABLE ROW LEVEL SECURITY; - -REVOKE ALL ON TABLE public.apikey_global_permissions FROM PUBLIC; -REVOKE ALL ON TABLE public.apikey_global_permissions FROM anon; -REVOKE ALL ON TABLE public.apikey_global_permissions FROM authenticated; -GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.apikey_global_permissions TO service_role; -GRANT USAGE, SELECT ON SEQUENCE public.apikey_global_permissions_id_seq TO service_role; - -CREATE POLICY "Deny select on apikey_global_permissions" -ON public.apikey_global_permissions -AS RESTRICTIVE -FOR SELECT -TO anon, authenticated -USING (false); - -CREATE POLICY "Deny insert on apikey_global_permissions" -ON public.apikey_global_permissions -AS RESTRICTIVE -FOR INSERT -TO anon, authenticated -WITH CHECK (false); - -CREATE POLICY "Deny update on apikey_global_permissions" -ON public.apikey_global_permissions -AS RESTRICTIVE -FOR UPDATE -TO anon, authenticated -USING (false) -WITH CHECK (false); - -CREATE POLICY "Deny delete on apikey_global_permissions" -ON public.apikey_global_permissions -AS RESTRICTIVE -FOR DELETE -TO anon, authenticated -USING (false); - -COMMENT ON TABLE public.apikey_global_permissions IS - 'Global permissions for API keys where no org/app/channel target exists yet. Currently used to grandfather org creation for existing write-capable keys without granting it to future keys by default.'; - -INSERT INTO public.apikey_global_permissions ( - apikey_rbac_id, - permission_key, - granted_by, - reason -) -SELECT - apikeys.rbac_id, - public.rbac_perm_org_create(), - apikeys.user_id, - 'Backfilled for org-scoped write-capable API keys that existed before org.create became explicit' -FROM public.apikeys -WHERE EXISTS ( - SELECT 1 - FROM public.role_bindings - JOIN public.roles ON public.roles.id = public.role_bindings.role_id - WHERE public.role_bindings.principal_type = public.rbac_principal_apikey() - AND public.role_bindings.principal_id = apikeys.rbac_id - AND public.role_bindings.scope_type = public.rbac_scope_org() - AND ( - public.role_bindings.expires_at IS NULL - OR public.role_bindings.expires_at > pg_catalog.now() - ) - AND public.roles.name IN ( - public.rbac_role_org_super_admin(), - public.rbac_role_org_admin() - ) -) -ON CONFLICT (apikey_rbac_id, permission_key) DO NOTHING; - -CREATE OR REPLACE FUNCTION public.apikey_has_current_org_create_capability( - p_apikey_rbac_id uuid -) -RETURNS boolean -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = '' -AS $$ - SELECT EXISTS ( - SELECT 1 - FROM public.role_bindings AS rb - JOIN public.roles AS r ON r.id = rb.role_id - WHERE rb.principal_type = public.rbac_principal_apikey() - AND rb.principal_id = p_apikey_rbac_id - AND rb.scope_type = public.rbac_scope_org() - AND r.scope_type = public.rbac_scope_org() - AND ( - rb.expires_at IS NULL - OR rb.expires_at > pg_catalog.now() - ) - AND r.name IN ( - public.rbac_role_org_super_admin(), - public.rbac_role_org_admin() - ) - ) -$$; - -ALTER FUNCTION public.apikey_has_current_org_create_capability(uuid) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.apikey_has_current_org_create_capability(uuid) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.apikey_has_current_org_create_capability(uuid) TO service_role; - -COMMENT ON FUNCTION public.apikey_has_current_org_create_capability(uuid) IS - 'Private helper ensuring org.create grants only remain effective while the API key still has a current org-scoped write-capable RBAC binding.'; - -CREATE OR REPLACE FUNCTION public.apikey_has_global_permission( - p_apikey text, - p_permission_key text -) -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_api_key public.apikeys%ROWTYPE; -BEGIN - IF p_apikey IS NULL OR p_apikey = '' OR p_permission_key IS NULL OR p_permission_key = '' THEN - RETURN false; - END IF; - - SELECT * - INTO v_api_key - FROM public.find_apikey_by_value(p_apikey) - LIMIT 1; - - IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN - RETURN false; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM public.apikey_global_permissions AS agp - WHERE agp.apikey_rbac_id = v_api_key.rbac_id - AND agp.permission_key = p_permission_key - ) THEN - RETURN false; - END IF; - - IF p_permission_key = public.rbac_perm_org_create() THEN - RETURN public.apikey_has_current_org_create_capability(v_api_key.rbac_id); - END IF; - - RETURN true; -END; -$$; - -ALTER FUNCTION public.apikey_has_global_permission(text, text) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.apikey_has_global_permission(text, text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.apikey_has_global_permission(text, text) TO service_role; - -COMMENT ON FUNCTION public.apikey_has_global_permission(text, text) IS - 'Service-role helper that checks global API-key permissions such as org.create using the supplied key value, including hashed-key lookup.'; - -CREATE OR REPLACE FUNCTION public.get_identity_for_apikey_creation() -RETURNS uuid -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - auth_uid uuid; -BEGIN - SELECT auth.uid() INTO auth_uid; - IF auth_uid IS NOT NULL THEN - RETURN auth_uid; - END IF; - - PERFORM public.pg_log('deny: APIKEY_CREATE_WITH_API_KEY_DISABLED', '{}'::jsonb); - RETURN NULL; -END; -$$; - -ALTER FUNCTION public.get_identity_for_apikey_creation() OWNER TO postgres; -REVOKE ALL ON FUNCTION public.get_identity_for_apikey_creation() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.get_identity_for_apikey_creation() TO anon; -GRANT EXECUTE ON FUNCTION public.get_identity_for_apikey_creation() TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_identity_for_apikey_creation() TO service_role; - -COMMENT ON FUNCTION public.get_identity_for_apikey_creation() IS - 'Returns auth.uid() for JWT callers; API-key creation of API keys stays disabled even when org.create is granted.'; - -CREATE OR REPLACE FUNCTION public.bind_creating_apikey_to_org_on_create() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key_text text; - api_key public.apikeys%ROWTYPE; - org_super_admin_role_id uuid; -BEGIN - SELECT public.get_apikey_header() INTO api_key_text; - IF api_key_text IS NULL THEN - RETURN NEW; - END IF; - - SELECT * - INTO api_key - FROM public.find_apikey_by_value(api_key_text) - LIMIT 1; - - IF api_key.id IS NULL - OR public.is_apikey_expired(api_key.expires_at) - OR api_key.user_id IS DISTINCT FROM NEW.created_by - THEN - RETURN NEW; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM public.apikey_global_permissions AS agp - WHERE agp.apikey_rbac_id = api_key.rbac_id - AND agp.permission_key = public.rbac_perm_org_create() - ) - OR NOT public.apikey_has_current_org_create_capability(api_key.rbac_id) THEN - RETURN NEW; - END IF; - - SELECT roles.id - INTO org_super_admin_role_id - FROM public.roles - WHERE roles.name = public.rbac_role_org_super_admin() - AND roles.scope_type = public.rbac_scope_org() - LIMIT 1; - - IF org_super_admin_role_id IS NULL THEN - RAISE EXCEPTION 'org_super_admin role not found'; - END IF; - - INSERT INTO public.role_bindings ( - principal_type, - principal_id, - role_id, - scope_type, - org_id, - granted_by, - granted_at, - reason, - is_direct - ) - VALUES ( - public.rbac_principal_apikey(), - api_key.rbac_id, - org_super_admin_role_id, - public.rbac_scope_org(), - NEW.id, - NEW.created_by, - pg_catalog.now(), - 'Auto-granted to API key on org creation', - true - ) - ON CONFLICT DO NOTHING; - - RETURN NEW; -END; -$$; - -ALTER FUNCTION public.bind_creating_apikey_to_org_on_create() OWNER TO postgres; -REVOKE ALL ON FUNCTION public.bind_creating_apikey_to_org_on_create() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.bind_creating_apikey_to_org_on_create() TO service_role; - -DROP TRIGGER IF EXISTS bind_creating_apikey_to_org_on_create ON public.orgs; -CREATE TRIGGER bind_creating_apikey_to_org_on_create -AFTER INSERT ON public.orgs -FOR EACH ROW -EXECUTE FUNCTION public.bind_creating_apikey_to_org_on_create(); - -DROP POLICY IF EXISTS "Allow insert org for user" ON public.orgs; -DROP POLICY IF EXISTS "Allow insert org for apikey or user" ON public.orgs; -DROP POLICY IF EXISTS "Allow insert org for user or apikey with org.create" ON public.orgs; -CREATE POLICY "Allow insert org for user" -ON public.orgs -FOR INSERT -TO authenticated -WITH CHECK ( - created_by = (SELECT public.get_identity_for_apikey_creation()) -); diff --git a/supabase/migrations/20260605104908_compatibility_events.sql b/supabase/migrations/20260605104908_compatibility_events.sql deleted file mode 100644 index 49e7d43346..0000000000 --- a/supabase/migrations/20260605104908_compatibility_events.sql +++ /dev/null @@ -1,82 +0,0 @@ -CREATE TABLE IF NOT EXISTS public.compatibility_events ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - org_id uuid NOT NULL REFERENCES public.orgs(id) ON DELETE CASCADE, - app_id text NOT NULL REFERENCES public.apps(app_id) ON DELETE CASCADE, - source text NOT NULL, -- default_channel_version_changed | default_channel_changed - platform text NOT NULL, -- ios | android | electron - channel_id bigint, -- nullable snapshot; intentionally NO FK (event must survive channel deletion) - channel_name text NOT NULL, - current_version_id bigint, - current_version_name text NOT NULL, - previous_version_id bigint, - previous_version_name text NOT NULL, - offenders jsonb NOT NULL DEFAULT '[]'::jsonb, - created_at timestamptz NOT NULL DEFAULT now(), - resolved_at timestamptz, - resolved_by uuid, - resolution_kind text, -- auto_compatible | accepted - resolution_note text -); - --- idempotent upsert target for the async handler -CREATE UNIQUE INDEX IF NOT EXISTS uq_compatibility_events_dedup - ON public.compatibility_events (app_id, channel_id, platform, current_version_id, previous_version_id) NULLS NOT DISTINCT; -CREATE INDEX IF NOT EXISTS idx_compatibility_events_app_created - ON public.compatibility_events (app_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_compatibility_events_unresolved - ON public.compatibility_events (app_id) WHERE resolved_at IS NULL; - -ALTER TABLE public.compatibility_events ENABLE ROW LEVEL SECURITY; - --- Read for users with RBAC app-read on this app; no INSERT/UPDATE policy => only --- the service role (handler) and SECURITY DEFINER RPCs can write. -CREATE POLICY "compatibility_events_select" ON public.compatibility_events - FOR SELECT TO authenticated - USING ( public.rbac_check_permission(public.rbac_perm_app_read(), org_id, app_id, NULL::bigint) ); - --- Explicit deny for every user-facing write (AGENTS.md RLS Rule 1.5: never rely --- on implicit deny). Only the service-role handler (bypasses RLS) and the --- SECURITY DEFINER accept RPC (runs as owner) may write; user-context roles --- (incl. anon API-key traffic) must never INSERT/UPDATE/DELETE directly. -CREATE POLICY "compatibility_events_deny_insert" ON public.compatibility_events - AS RESTRICTIVE FOR INSERT TO anon, authenticated - WITH CHECK (false); - -CREATE POLICY "compatibility_events_deny_update" ON public.compatibility_events - AS RESTRICTIVE FOR UPDATE TO anon, authenticated - USING (false) WITH CHECK (false); - -CREATE POLICY "compatibility_events_deny_delete" ON public.compatibility_events - AS RESTRICTIVE FOR DELETE TO anon, authenticated - USING (false); - --- Manual accept: app.write, sets the resolution fields, requires a reason. -CREATE OR REPLACE FUNCTION public.acknowledge_compatibility_event(event_id bigint, note text) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE v_org uuid; v_app text; -BEGIN - IF note IS NULL OR length(btrim(note)) = 0 THEN - RAISE EXCEPTION 'reason_required'; - END IF; - SELECT org_id, app_id INTO v_org, v_app - FROM public.compatibility_events WHERE id = event_id; - IF v_org IS NULL THEN RETURN; END IF; -- unknown id: no-op - -- RBAC: app upload-bundle permission (release managers); NOT legacy min_rights. - -- Adjust the perm key in review if a different role should be allowed to accept. - IF NOT public.rbac_check_permission_direct( - public.rbac_perm_app_upload_bundle(), auth.uid(), v_org, v_app, NULL::bigint) THEN - RETURN; -- unauthorized: no-op (no oracle) - END IF; - UPDATE public.compatibility_events - SET resolved_at = now(), resolved_by = auth.uid(), - resolution_kind = 'accepted', resolution_note = note - WHERE id = event_id AND resolved_at IS NULL; -END; $$; - -ALTER FUNCTION public.acknowledge_compatibility_event(bigint, text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION public.acknowledge_compatibility_event(bigint, text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.acknowledge_compatibility_event(bigint, text) TO authenticated; diff --git a/supabase/migrations/20260608094944_remove_stale_device_replication_trigger.sql b/supabase/migrations/20260608094944_remove_stale_device_replication_trigger.sql deleted file mode 100644 index 226962b776..0000000000 --- a/supabase/migrations/20260608094944_remove_stale_device_replication_trigger.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Remove stale D1/device replication wiring that points at the obsolete --- replicate_data queue. Current migrations never create this trigger, but it --- still exists in production from the old replication system. -DROP TRIGGER IF EXISTS replicate_devices ON public.devices; diff --git a/supabase/migrations/20260608111805_compatibility_events_per_occurrence.sql b/supabase/migrations/20260608111805_compatibility_events_per_occurrence.sql deleted file mode 100644 index c84d7a42c5..0000000000 --- a/supabase/migrations/20260608111805_compatibility_events_per_occurrence.sql +++ /dev/null @@ -1,25 +0,0 @@ --- A genuine re-occurrence of a previously-recorded (and possibly resolved) --- transition must create a NEW row instead of being absorbed by the dedup --- upsert. The occurrence identity is the channel row's updated_at at the time --- of the change: a queue redelivery of the same webhook carries the same value --- (still idempotent, resolution still protected), while a new flip of the same --- bundle pair carries a new one and inserts a fresh, unresolved row. - -ALTER TABLE public.compatibility_events - ADD COLUMN IF NOT EXISTS change_occurred_at timestamptz; - -UPDATE public.compatibility_events - SET change_occurred_at = created_at - WHERE change_occurred_at IS NULL; - -ALTER TABLE public.compatibility_events - ALTER COLUMN change_occurred_at SET NOT NULL; - -ALTER TABLE public.compatibility_events - ALTER COLUMN change_occurred_at SET DEFAULT now(); - -DROP INDEX IF EXISTS public.uq_compatibility_events_dedup; - -CREATE UNIQUE INDEX IF NOT EXISTS uq_compatibility_events_dedup - ON public.compatibility_events (app_id, channel_id, platform, current_version_id, previous_version_id, change_occurred_at) - NULLS NOT DISTINCT; diff --git a/supabase/migrations/20260608143906_fix_manifest_update_rls_policy.sql b/supabase/migrations/20260608143906_fix_manifest_update_rls_policy.sql index 76262238c2..30ec265a74 100644 --- a/supabase/migrations/20260608143906_fix_manifest_update_rls_policy.sql +++ b/supabase/migrations/20260608143906_fix_manifest_update_rls_policy.sql @@ -1,12 +1,25585 @@ -DROP POLICY IF EXISTS -"Prevent users from updating manifest entries" -- noqa: RF05 -ON public.manifest; - -CREATE POLICY -"Prevent users from updating manifest entries" -- noqa: RF05 -ON public.manifest -AS RESTRICTIVE -FOR UPDATE -TO authenticated, anon -USING (false) -WITH CHECK (false); + + + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + + +CREATE SCHEMA IF NOT EXISTS "capgo_private"; + + +ALTER SCHEMA "capgo_private" OWNER TO "postgres"; + + +CREATE EXTENSION IF NOT EXISTS "pg_cron" WITH SCHEMA "pg_catalog"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions"; + + + + + + + + +ALTER SCHEMA "public" OWNER TO "postgres"; + + +COMMENT ON SCHEMA "public" IS 'standard public schema'; + + + +CREATE EXTENSION IF NOT EXISTS "http" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "hypopg" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "moddatetime" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pgmq"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "plpgsql_check" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault"; + + + + + + +CREATE TYPE "public"."action_type" AS ENUM ( + 'mau', + 'storage', + 'bandwidth', + 'build_time' +); + + +ALTER TYPE "public"."action_type" OWNER TO "postgres"; + + +CREATE TYPE "public"."credit_metric_type" AS ENUM ( + 'mau', + 'bandwidth', + 'storage', + 'build_time' +); + + +ALTER TYPE "public"."credit_metric_type" OWNER TO "postgres"; + + +CREATE TYPE "public"."credit_transaction_type" AS ENUM ( + 'grant', + 'purchase', + 'manual_grant', + 'deduction', + 'expiry', + 'refund' +); + + +ALTER TYPE "public"."credit_transaction_type" OWNER TO "postgres"; + + +CREATE TYPE "public"."cron_task_type" AS ENUM ( + 'function', + 'queue', + 'function_queue' +); + + +ALTER TYPE "public"."cron_task_type" OWNER TO "postgres"; + + +CREATE TYPE "public"."disable_update" AS ENUM ( + 'major', + 'minor', + 'patch', + 'version_number', + 'none' +); + + +ALTER TYPE "public"."disable_update" OWNER TO "postgres"; + + +CREATE TYPE "public"."key_mode" AS ENUM ( + 'read', + 'write', + 'all', + 'upload' +); + + +ALTER TYPE "public"."key_mode" OWNER TO "postgres"; + + +CREATE TYPE "public"."manifest_entry" AS ( + "file_name" character varying, + "s3_path" character varying, + "file_hash" character varying +); + + +ALTER TYPE "public"."manifest_entry" OWNER TO "postgres"; + + +CREATE TYPE "public"."message_update" AS ( + "msg_id" bigint, + "cf_id" character varying, + "queue" character varying +); + + +ALTER TYPE "public"."message_update" OWNER TO "postgres"; + + +CREATE TYPE "public"."orgs_table" AS ( + "id" "uuid", + "created_by" "uuid", + "created_at" timestamp with time zone, + "updated_at" timestamp with time zone, + "logo" "text", + "name" "text" +); + + +ALTER TYPE "public"."orgs_table" OWNER TO "postgres"; + + +CREATE TYPE "public"."owned_orgs" AS ( + "id" "uuid", + "created_by" "uuid", + "logo" "text", + "name" "text", + "role" character varying +); + + +ALTER TYPE "public"."owned_orgs" OWNER TO "postgres"; + + +CREATE TYPE "public"."platform_os" AS ENUM ( + 'ios', + 'android', + 'electron' +); + + +ALTER TYPE "public"."platform_os" OWNER TO "postgres"; + + +CREATE TYPE "public"."stats_action" AS ENUM ( + 'delete', + 'reset', + 'set', + 'get', + 'set_fail', + 'update_fail', + 'download_fail', + 'windows_path_fail', + 'canonical_path_fail', + 'directory_path_fail', + 'unzip_fail', + 'low_mem_fail', + 'download_10', + 'download_20', + 'download_30', + 'download_40', + 'download_50', + 'download_60', + 'download_70', + 'download_80', + 'download_90', + 'download_complete', + 'decrypt_fail', + 'app_moved_to_foreground', + 'app_moved_to_background', + 'uninstall', + 'needPlanUpgrade', + 'missingBundle', + 'noNew', + 'disablePlatformIos', + 'disablePlatformAndroid', + 'disableAutoUpdateToMajor', + 'cannotUpdateViaPrivateChannel', + 'disableAutoUpdateToMinor', + 'disableAutoUpdateToPatch', + 'channelMisconfigured', + 'disableAutoUpdateMetadata', + 'disableAutoUpdateUnderNative', + 'disableDevBuild', + 'disableProdBuild', + 'disableEmulator', + 'disableDevice', + 'cannotGetBundle', + 'checksum_fail', + 'NoChannelOrOverride', + 'setChannel', + 'getChannel', + 'rateLimited', + 'disableAutoUpdate', + 'keyMismatch', + 'ping', + 'InvalidIp', + 'blocked_by_server_url', + 'download_manifest_start', + 'download_manifest_complete', + 'download_zip_start', + 'download_zip_complete', + 'download_manifest_file_fail', + 'download_manifest_checksum_fail', + 'download_manifest_brotli_fail', + 'backend_refusal', + 'download_0', + 'disablePlatformElectron', + 'customIdBlocked', + 'app_crash', + 'app_crash_native', + 'app_anr', + 'app_killed_low_memory', + 'app_killed_excessive_resource_usage', + 'app_initialization_failure', + 'app_memory_warning', + 'webview_javascript_error', + 'webview_unhandled_rejection', + 'webview_resource_error', + 'webview_security_policy_violation', + 'webview_unclean_restart', + 'webview_render_process_gone', + 'webview_content_process_terminated' +); + + +ALTER TYPE "public"."stats_action" OWNER TO "postgres"; + + +CREATE TYPE "public"."stats_table" AS ( + "mau" bigint, + "bandwidth" bigint, + "storage" bigint +); + + +ALTER TYPE "public"."stats_table" OWNER TO "postgres"; + + +CREATE TYPE "public"."stripe_status" AS ENUM ( + 'created', + 'succeeded', + 'updated', + 'failed', + 'deleted', + 'canceled' +); + + +ALTER TYPE "public"."stripe_status" OWNER TO "postgres"; + + +CREATE TYPE "public"."user_min_right" AS ENUM ( + 'invite_read', + 'invite_upload', + 'invite_write', + 'invite_admin', + 'invite_super_admin', + 'read', + 'upload', + 'write', + 'admin', + 'super_admin' +); + + +ALTER TYPE "public"."user_min_right" OWNER TO "postgres"; + + +CREATE TYPE "public"."user_role" AS ENUM ( + 'read', + 'upload', + 'write', + 'admin' +); + + +ALTER TYPE "public"."user_role" OWNER TO "postgres"; + + +CREATE TYPE "public"."version_action" AS ENUM ( + 'get', + 'fail', + 'install', + 'uninstall' +); + + +ALTER TYPE "public"."version_action" OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "capgo_private"."matches_app_storage_apikey_owner"("folder_user_id" "text", "target_app_id" character varying, "keymode" "public"."key_mode"[]) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key_text text; + api_key public.apikeys%ROWTYPE; + target_app record; + required_permission text; +BEGIN + SELECT public.get_apikey_header() INTO api_key_text; + IF api_key_text IS NULL THEN + RETURN false; + END IF; + + SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; + IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + RETURN false; + END IF; + + SELECT user_id, owner_org + INTO target_app + FROM public.apps + WHERE app_id = target_app_id + LIMIT 1; + + IF target_app.user_id IS NULL THEN + RETURN false; + END IF; + + IF api_key.user_id::text <> folder_user_id OR target_app.user_id <> api_key.user_id THEN + RETURN false; + END IF; + + required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_app()); + RETURN public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, target_app.owner_org, target_app_id, NULL); +END; +$$; + + +ALTER FUNCTION "capgo_private"."matches_app_storage_apikey_owner"("folder_user_id" "text", "target_app_id" character varying, "keymode" "public"."key_mode"[]) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "capgo_private"."matches_app_storage_apikey_owner"("folder_user_id" "text", "target_app_id" character varying, "keymode" "public"."key_mode"[]) IS 'Internal non-RPC helper for storage app-bucket API-key auth.'; + + + +CREATE OR REPLACE FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") RETURNS character varying + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + SET "row_security" TO 'off' + AS $$ +DECLARE + invite public.org_users%ROWTYPE; + invite_user_id uuid; + invite_org_id uuid; + legacy_right public.user_min_right; + role_name text; + role_id uuid; +BEGIN + SELECT public.org_users.* + INTO invite + FROM public.org_users + WHERE public.org_users.org_id = accept_invitation_to_org.org_id + AND public.org_users.user_id = (SELECT auth.uid()) + ORDER BY (public.org_users.user_right::text LIKE 'invite_%') DESC, + public.org_users.created_at DESC NULLS LAST, + public.org_users.id DESC + LIMIT 1; + + IF invite.id IS NOT NULL AND invite.user_right::text NOT LIKE 'invite_%' THEN + RETURN 'INVALID_ROLE'; + END IF; + + IF invite.id IS NOT NULL THEN + invite_user_id := invite.user_id; + invite_org_id := invite.org_id; + legacy_right := public.transform_role_to_non_invite(invite.user_right); + role_name := COALESCE(invite.rbac_role_name, public.rbac_org_role_for_legacy_right(legacy_right)); + ELSE + SELECT rb.principal_id, rb.org_id, r.name + INTO invite_user_id, invite_org_id, role_name + FROM public.role_bindings rb + JOIN public.roles r + ON r.id = rb.role_id + AND r.scope_type = rb.scope_type + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = (SELECT auth.uid()) + AND rb.org_id = accept_invitation_to_org.org_id + AND rb.scope_type = public.rbac_scope_org() + AND rb.reason IN ('Pending invitation', 'Invited via invite_user_to_org_rbac') + ORDER BY rb.granted_at DESC NULLS LAST + LIMIT 1; + + IF invite_user_id IS NULL THEN + RETURN 'NO_INVITE'; + END IF; + + legacy_right := public.rbac_legacy_right_for_org_role(role_name); + END IF; + + IF role_name IS NULL THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + SELECT public.roles.id INTO role_id + FROM public.roles + WHERE public.roles.name = role_name + AND public.roles.scope_type = public.rbac_scope_org() + AND public.roles.is_assignable = true + LIMIT 1; + + IF role_id IS NULL THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + UPDATE public.org_users + SET user_right = legacy_right, + rbac_role_name = role_name, + updated_at = CURRENT_TIMESTAMP + WHERE public.org_users.id = invite.id; + + IF invite.id IS NULL THEN + INSERT INTO public.org_users (user_id, org_id, user_right, rbac_role_name) + VALUES (invite_user_id, invite_org_id, legacy_right, role_name); + END IF; + + DELETE FROM public.role_bindings + WHERE public.role_bindings.principal_type = public.rbac_principal_user() + AND public.role_bindings.principal_id = invite_user_id + AND public.role_bindings.scope_type = public.rbac_scope_org() + AND public.role_bindings.org_id = invite_org_id; + + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + channel_id, + granted_by, + granted_at, + reason, + is_direct + ) VALUES ( + public.rbac_principal_user(), + invite_user_id, + role_id, + public.rbac_scope_org(), + invite_org_id, + NULL, + NULL, + auth.uid(), + now(), + 'Accepted invitation', + true + ) ON CONFLICT DO NOTHING; + + RETURN 'OK'; +END; +$$; + + +ALTER FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") IS 'Accepts a pending org invite and creates the active RBAC binding. Kept for old clients.'; + + + +CREATE OR REPLACE FUNCTION "public"."acknowledge_compatibility_event"("event_id" bigint, "note" "text") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE v_org uuid; v_app text; +BEGIN + IF note IS NULL OR length(btrim(note)) = 0 THEN + RAISE EXCEPTION 'reason_required'; + END IF; + SELECT org_id, app_id INTO v_org, v_app + FROM public.compatibility_events WHERE id = event_id; + IF v_org IS NULL THEN RETURN; END IF; -- unknown id: no-op + -- RBAC: app upload-bundle permission (release managers); NOT legacy min_rights. + -- Adjust the perm key in review if a different role should be allowed to accept. + IF NOT public.rbac_check_permission_direct( + public.rbac_perm_app_upload_bundle(), auth.uid(), v_org, v_app, NULL::bigint) THEN + RETURN; -- unauthorized: no-op (no oracle) + END IF; + UPDATE public.compatibility_events + SET resolved_at = now(), resolved_by = auth.uid(), + resolution_kind = 'accepted', resolution_note = note + WHERE id = event_id AND resolved_at IS NULL; +END; $$; + + +ALTER FUNCTION "public"."acknowledge_compatibility_event"("event_id" bigint, "note" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."aggregate_build_log_to_daily"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_old_date date; +BEGIN + -- Handle DELETE: subtract old values and return + IF TG_OP = 'DELETE' THEN + IF OLD.app_id IS NOT NULL THEN + v_old_date := (OLD.created_at AT TIME ZONE 'UTC')::date; + UPDATE public.daily_build_time + SET build_time_unit = GREATEST(build_time_unit - OLD.billable_seconds, 0), + build_count = GREATEST(build_count - 1, 0) + WHERE app_id = OLD.app_id AND date = v_old_date; + END IF; + RETURN OLD; + END IF; + + -- Handle UPDATE: subtract old values from the old bucket (if old had app_id) + IF TG_OP = 'UPDATE' AND OLD.app_id IS NOT NULL THEN + v_old_date := (OLD.created_at AT TIME ZONE 'UTC')::date; + UPDATE public.daily_build_time + SET build_time_unit = GREATEST(build_time_unit - OLD.billable_seconds, 0), + build_count = GREATEST(build_count - 1, 0) + WHERE app_id = OLD.app_id AND date = v_old_date; + END IF; + + -- Handle INSERT/UPDATE: add new values (only if new app_id is set) + IF NEW.app_id IS NOT NULL THEN + INSERT INTO public.daily_build_time (app_id, date, build_time_unit, build_count) + VALUES (NEW.app_id, (NEW.created_at AT TIME ZONE 'UTC')::date, NEW.billable_seconds, 1) + ON CONFLICT (app_id, date) DO UPDATE SET + build_time_unit = public.daily_build_time.build_time_unit + EXCLUDED.build_time_unit, + build_count = public.daily_build_time.build_count + EXCLUDED.build_count; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."aggregate_build_log_to_daily"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."apikey_has_current_org_create_capability"("p_apikey_rbac_id" "uuid") RETURNS boolean + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.role_bindings AS rb + JOIN public.roles AS r ON r.id = rb.role_id + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = p_apikey_rbac_id + AND rb.scope_type = public.rbac_scope_org() + AND r.scope_type = public.rbac_scope_org() + AND ( + rb.expires_at IS NULL + OR rb.expires_at > pg_catalog.now() + ) + AND r.name IN ( + public.rbac_role_org_super_admin(), + public.rbac_role_org_admin() + ) + ) +$$; + + +ALTER FUNCTION "public"."apikey_has_current_org_create_capability"("p_apikey_rbac_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."apikey_has_current_org_create_capability"("p_apikey_rbac_id" "uuid") IS 'Private helper ensuring org.create grants only remain effective while the API key still has a current org-scoped write-capable RBAC binding.'; + + + +CREATE OR REPLACE FUNCTION "public"."apikey_has_global_permission"("p_apikey" "text", "p_permission_key" "text") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_api_key public.apikeys%ROWTYPE; +BEGIN + IF p_apikey IS NULL OR p_apikey = '' OR p_permission_key IS NULL OR p_permission_key = '' THEN + RETURN false; + END IF; + + SELECT * + INTO v_api_key + FROM public.find_apikey_by_value(p_apikey) + LIMIT 1; + + IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN + RETURN false; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.apikey_global_permissions AS agp + WHERE agp.apikey_rbac_id = v_api_key.rbac_id + AND agp.permission_key = p_permission_key + ) THEN + RETURN false; + END IF; + + IF p_permission_key = public.rbac_perm_org_create() THEN + RETURN public.apikey_has_current_org_create_capability(v_api_key.rbac_id); + END IF; + + RETURN true; +END; +$$; + + +ALTER FUNCTION "public"."apikey_has_global_permission"("p_apikey" "text", "p_permission_key" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."apikey_has_global_permission"("p_apikey" "text", "p_permission_key" "text") IS 'Service-role helper that checks global API-key permissions such as org.create using the supplied key value, including hashed-key lookup.'; + + + +CREATE OR REPLACE FUNCTION "public"."apikey_permission_for_keymode"("keymode" "public"."key_mode"[], "scope_type" "text") RETURNS "text" + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + IF scope_type = public.rbac_scope_org() THEN + RETURN CASE + WHEN 'read'::public.key_mode = ANY(keymode) THEN public.rbac_perm_org_read() + WHEN 'upload'::public.key_mode = ANY(keymode) THEN public.rbac_perm_org_update_settings() + WHEN 'write'::public.key_mode = ANY(keymode) THEN public.rbac_perm_org_update_settings() + ELSE public.rbac_perm_org_update_user_roles() + END; + END IF; + + RETURN CASE + WHEN 'read'::public.key_mode = ANY(keymode) THEN public.rbac_perm_app_read() + WHEN 'upload'::public.key_mode = ANY(keymode) THEN public.rbac_perm_app_upload_bundle() + WHEN 'write'::public.key_mode = ANY(keymode) THEN public.rbac_perm_app_update_settings() + ELSE public.rbac_perm_app_update_user_roles() + END; +END; +$$; + + +ALTER FUNCTION "public"."apikey_permission_for_keymode"("keymode" "public"."key_mode"[], "scope_type" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."apikeys_force_server_key"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_plain_key text; + v_is_hashed boolean; +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NEW; + END IF; + + IF current_setting('capgo.skip_apikey_trigger', true) = 'true' THEN + RETURN NEW; + END IF; + + -- SECURITY DEFINER makes current_user the function owner, so use session_user to detect the caller. + IF session_user IN ('postgres', 'service_role', 'supabase_admin', 'supabase_auth_admin', 'supabase_storage_admin', 'supabase_realtime_admin') THEN + RETURN NEW; + END IF; + + IF TG_OP = 'UPDATE' THEN + -- Allow callers to force regeneration even if they mistakenly re-submit the same value. + -- This is primarily useful for controlled internal operations; normal API flows always + -- write a different placeholder value. + IF current_setting('capgo.force_regenerate_apikey', true) IS DISTINCT FROM 'true' + AND NEW.key IS NOT DISTINCT FROM OLD.key + AND NEW.key_hash IS NOT DISTINCT FROM OLD.key_hash THEN + RETURN NEW; + END IF; + v_is_hashed := (OLD.key_hash IS NOT NULL AND OLD.key IS NULL) OR NEW.key_hash IS NOT NULL; + ELSE + v_is_hashed := NEW.key_hash IS NOT NULL; + END IF; + + v_plain_key := gen_random_uuid()::text; + + IF v_is_hashed THEN + NEW.key_hash := encode(extensions.digest(v_plain_key, 'sha256'), 'hex'); + NEW.key := v_plain_key; + ELSE + NEW.key := v_plain_key; + NEW.key_hash := NULL; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."apikeys_force_server_key"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."apikeys_strip_plain_key_for_hashed"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + + IF current_setting('capgo.skip_apikey_trigger', true) = 'true' THEN + RETURN NULL; + END IF; + + IF NEW.key_hash IS NOT NULL AND NEW.key IS NOT NULL THEN + UPDATE public.apikeys + SET key = NULL + WHERE id = NEW.id; + END IF; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."apikeys_strip_plain_key_for_hashed"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."app_versions_has_app_permission"("p_min_right" "public"."user_min_right", "p_owner_org" "uuid", "p_app_id" character varying, "p_user_id" "uuid", "p_apikey" "text") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid := p_user_id; + v_api_key public.apikeys%ROWTYPE; + v_principal_type text; + v_principal_id uuid; + v_app_uuid uuid; + v_app_owner_org uuid; + v_permission text; +BEGIN + IF p_min_right IS NULL OR p_owner_org IS NULL OR p_app_id IS NULL THEN + RETURN false; + END IF; + + SELECT apps.id, apps.owner_org + INTO v_app_uuid, v_app_owner_org + FROM public.apps + WHERE apps.app_id = p_app_id + LIMIT 1; + + IF v_app_uuid IS NULL OR v_app_owner_org IS DISTINCT FROM p_owner_org THEN + RETURN false; + END IF; + + IF p_apikey IS NOT NULL THEN + SELECT * INTO v_api_key + FROM public.find_apikey_by_value(p_apikey) + LIMIT 1; + + IF v_api_key.id IS NULL + OR public.is_apikey_expired(v_api_key.expires_at) + OR (p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id) + THEN + RETURN false; + END IF; + + v_user_id := v_api_key.user_id; + v_principal_type := public.rbac_principal_apikey(); + v_principal_id := v_api_key.rbac_id; + ELSE + IF v_user_id IS NULL THEN + RETURN false; + END IF; + + v_principal_type := public.rbac_principal_user(); + v_principal_id := v_user_id; + END IF; + + IF v_principal_id IS NULL OR v_user_id IS NULL THEN + RETURN false; + END IF; + + IF (SELECT orgs.enforcing_2fa FROM public.orgs WHERE orgs.id = v_app_owner_org) + AND NOT public.has_2fa_enabled(v_user_id) + THEN + RETURN false; + END IF; + + IF public.user_meets_password_policy(v_user_id, v_app_owner_org) IS FALSE THEN + RETURN false; + END IF; + + IF v_principal_type = public.rbac_principal_user() + AND EXISTS ( + SELECT 1 + FROM public.org_users + WHERE org_users.user_id = v_principal_id + AND org_users.org_id = v_app_owner_org + AND org_users.channel_id IS NULL + AND (org_users.app_id IS NULL OR org_users.app_id = p_app_id) + AND org_users.user_right >= p_min_right + LIMIT 1 + ) + THEN + RETURN true; + END IF; + + v_permission := public.rbac_permission_for_legacy(p_min_right, public.rbac_scope_app()); + IF v_permission IS NULL THEN + RETURN false; + END IF; + + RETURN EXISTS ( + WITH RECURSIVE direct_bindings AS ( + SELECT rb.role_id, rb.scope_type + FROM public.role_bindings rb + WHERE rb.principal_type = v_principal_type + AND rb.principal_id = v_principal_id + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id = v_app_owner_org + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + + UNION + + SELECT rb.role_id, rb.scope_type + FROM public.role_bindings rb + WHERE rb.principal_type = v_principal_type + AND rb.principal_id = v_principal_id + AND rb.scope_type = public.rbac_scope_app() + AND rb.org_id = v_app_owner_org + AND rb.app_id = v_app_uuid + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + + UNION + + SELECT rb.role_id, rb.scope_type + FROM public.group_members gm + INNER JOIN public.groups g ON g.id = gm.group_id + INNER JOIN public.role_bindings rb + ON rb.principal_type = public.rbac_principal_group() + AND rb.principal_id = gm.group_id + AND rb.org_id = g.org_id + WHERE v_principal_type = public.rbac_principal_user() + AND gm.user_id = v_principal_id + AND g.org_id = v_app_owner_org + AND ( + ( + rb.scope_type = public.rbac_scope_org() + AND rb.org_id = v_app_owner_org + ) + OR ( + rb.scope_type = public.rbac_scope_app() + AND rb.org_id = v_app_owner_org + AND rb.app_id = v_app_uuid + ) + ) + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ), + role_closure AS ( + SELECT direct_bindings.role_id, direct_bindings.scope_type + FROM direct_bindings + + UNION + + SELECT role_hierarchy.child_role_id, role_closure.scope_type + FROM role_closure + INNER JOIN public.role_hierarchy + ON role_hierarchy.parent_role_id = role_closure.role_id + INNER JOIN public.roles child_role + ON child_role.id = role_hierarchy.child_role_id + AND child_role.scope_type = role_closure.scope_type + ) + SELECT 1 + FROM role_closure + INNER JOIN public.role_permissions + ON role_permissions.role_id = role_closure.role_id + INNER JOIN public.permissions + ON permissions.id = role_permissions.permission_id + WHERE permissions.key = v_permission + LIMIT 1 + ); +END; +$$; + + +ALTER FUNCTION "public"."app_versions_has_app_permission"("p_min_right" "public"."user_min_right", "p_owner_org" "uuid", "p_app_id" character varying, "p_user_id" "uuid", "p_apikey" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."app_versions_has_app_permission"("p_min_right" "public"."user_min_right", "p_owner_org" "uuid", "p_app_id" character varying, "p_user_id" "uuid", "p_apikey" "text") IS 'Checks app_versions access for one target app. Used by app_versions RLS write/read paths so broad API keys with many app bindings do not materialize every linked app during bundle upload finalization.'; + + + +CREATE OR REPLACE FUNCTION "public"."app_versions_readable_app_ids"() RETURNS character varying[] + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_api_key_text text; + v_api_key public.apikeys%ROWTYPE; + v_principal_type text; + v_principal_id uuid; + v_allowed character varying[] := '{}'::character varying[]; +BEGIN + SELECT auth.uid() INTO v_user_id; + SELECT public.get_apikey_header() INTO v_api_key_text; + + IF v_api_key_text IS NOT NULL THEN + SELECT * INTO v_api_key + FROM public.find_apikey_by_value(v_api_key_text) + LIMIT 1; + + IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN + RETURN v_allowed; + END IF; + + v_user_id := v_api_key.user_id; + v_principal_type := public.rbac_principal_apikey(); + v_principal_id := v_api_key.rbac_id; + ELSIF v_user_id IS NOT NULL THEN + v_principal_type := public.rbac_principal_user(); + v_principal_id := v_user_id; + ELSE + RETURN v_allowed; + END IF; + + IF v_principal_id IS NULL THEN + RETURN v_allowed; + END IF; + + WITH RECURSIVE direct_bindings AS ( + SELECT rb.role_id, rb.scope_type, rb.org_id, rb.app_id + FROM public.role_bindings rb + WHERE rb.principal_type = v_principal_type + AND rb.principal_id = v_principal_id + AND rb.scope_type IN (public.rbac_scope_org(), public.rbac_scope_app()) + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + + UNION + + SELECT rb.role_id, rb.scope_type, rb.org_id, rb.app_id + FROM public.group_members gm + INNER JOIN public.groups g ON g.id = gm.group_id + INNER JOIN public.role_bindings rb + ON rb.principal_type = public.rbac_principal_group() + AND rb.principal_id = gm.group_id + AND rb.org_id = g.org_id + WHERE v_principal_type = public.rbac_principal_user() + AND gm.user_id = v_principal_id + AND rb.scope_type IN (public.rbac_scope_org(), public.rbac_scope_app()) + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ), + role_closure AS ( + SELECT + direct_bindings.role_id, + direct_bindings.role_id AS effective_role_id, + direct_bindings.scope_type, + direct_bindings.org_id, + direct_bindings.app_id + FROM direct_bindings + + UNION + + SELECT + role_closure.role_id, + role_hierarchy.child_role_id, + role_closure.scope_type, + role_closure.org_id, + role_closure.app_id + FROM role_closure + INNER JOIN public.role_hierarchy + ON role_hierarchy.parent_role_id = role_closure.effective_role_id + INNER JOIN public.roles child_role + ON child_role.id = role_hierarchy.child_role_id + AND child_role.scope_type = role_closure.scope_type + ), + readable_scopes AS ( + SELECT DISTINCT role_closure.scope_type, role_closure.org_id, role_closure.app_id + FROM role_closure + INNER JOIN public.role_permissions + ON role_permissions.role_id = role_closure.effective_role_id + INNER JOIN public.permissions + ON permissions.id = role_permissions.permission_id + WHERE permissions.key = public.rbac_perm_app_read() + ), + legacy_readable_scopes AS ( + SELECT + CASE + WHEN org_users.app_id IS NULL THEN public.rbac_scope_org() + ELSE public.rbac_scope_app() + END AS scope_type, + org_users.org_id, + apps.id AS app_id + FROM public.org_users + LEFT JOIN public.apps + ON apps.app_id = org_users.app_id + AND apps.owner_org = org_users.org_id + WHERE v_api_key_text IS NULL + AND v_user_id IS NOT NULL + AND org_users.user_id = v_user_id + AND org_users.user_right >= 'read'::public.user_min_right + AND org_users.channel_id IS NULL + ), + scoped_apps AS ( + SELECT apps.app_id, apps.owner_org + FROM readable_scopes + INNER JOIN public.apps + ON apps.owner_org = readable_scopes.org_id + WHERE readable_scopes.scope_type = public.rbac_scope_org() + + UNION + + SELECT apps.app_id, apps.owner_org + FROM readable_scopes + INNER JOIN public.apps + ON apps.id = readable_scopes.app_id + AND apps.owner_org = readable_scopes.org_id + WHERE readable_scopes.scope_type = public.rbac_scope_app() + AND readable_scopes.app_id IS NOT NULL + + UNION + + SELECT apps.app_id, apps.owner_org + FROM legacy_readable_scopes + INNER JOIN public.apps + ON apps.owner_org = legacy_readable_scopes.org_id + WHERE legacy_readable_scopes.scope_type = public.rbac_scope_org() + + UNION + + SELECT apps.app_id, apps.owner_org + FROM legacy_readable_scopes + INNER JOIN public.apps + ON apps.id = legacy_readable_scopes.app_id + AND apps.owner_org = legacy_readable_scopes.org_id + WHERE legacy_readable_scopes.scope_type = public.rbac_scope_app() + AND legacy_readable_scopes.app_id IS NOT NULL + ), + candidate_orgs AS ( + SELECT DISTINCT scoped_apps.owner_org + FROM scoped_apps + ), + readable_orgs AS ( + SELECT orgs.id + FROM candidate_orgs + INNER JOIN public.orgs ON orgs.id = candidate_orgs.owner_org + WHERE ( + orgs.enforcing_2fa IS NOT TRUE + OR (v_user_id IS NOT NULL AND public.has_2fa_enabled(v_user_id)) + ) + AND public.user_meets_password_policy(v_user_id, orgs.id) IS DISTINCT FROM false + ) + SELECT COALESCE(array_agg(DISTINCT scoped_apps.app_id), '{}'::character varying[]) + INTO v_allowed + FROM scoped_apps + INNER JOIN readable_orgs ON readable_orgs.id = scoped_apps.owner_org; + + RETURN v_allowed; +END; +$$; + + +ALTER FUNCTION "public"."app_versions_readable_app_ids"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."app_versions_readable_app_ids"() IS 'Returns app IDs whose bundle rows are readable by the current authenticated user or Capgo API key. The lookup starts from caller-scoped role bindings and expands role permissions set-wise for compatibility; targeted app_versions RLS checks use app_versions_has_app_permission instead.'; + + + +CREATE OR REPLACE FUNCTION "public"."apply_usage_overage"("p_org_id" "uuid", "p_metric" "public"."credit_metric_type", "p_overage_amount" numeric, "p_billing_cycle_start" timestamp with time zone, "p_billing_cycle_end" timestamp with time zone, "p_details" "jsonb" DEFAULT NULL::"jsonb") RETURNS TABLE("overage_amount" numeric, "credits_required" numeric, "credits_applied" numeric, "credits_remaining" numeric, "credit_step_id" bigint, "overage_covered" numeric, "overage_unpaid" numeric, "overage_event_id" "uuid") + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_calc RECORD; + v_event_id uuid; + v_remaining numeric := 0; + v_applied numeric := 0; + v_per_unit numeric := 0; + v_available numeric; + v_use numeric; + v_balance numeric; + v_overage_paid numeric := 0; + v_existing_credits_debited numeric := 0; + v_required numeric := 0; + v_credits_to_apply numeric := 0; + v_credits_available numeric := 0; + v_latest_event_id uuid; + v_latest_overage_amount numeric; + v_needs_new_record boolean := false; + grant_rec public.usage_credit_grants%ROWTYPE; +BEGIN + -- Early exit for invalid input + IF p_overage_amount IS NULL OR p_overage_amount <= 0 THEN + RETURN QUERY SELECT 0::numeric, 0::numeric, 0::numeric, 0::numeric, NULL::bigint, 0::numeric, 0::numeric, NULL::uuid; + RETURN; + END IF; + + -- Calculate credit cost for this overage + SELECT * + INTO v_calc + FROM public.calculate_credit_cost(p_metric, p_overage_amount) + LIMIT 1; + + -- If no pricing step found, create a single record and exit + IF v_calc.credit_step_id IS NULL THEN + -- Check if we already have a record for this cycle with NULL step + SELECT uoe.id, uoe.overage_amount INTO v_latest_event_id, v_latest_overage_amount + FROM public.usage_overage_events uoe + WHERE uoe.org_id = p_org_id + AND uoe.metric = p_metric + AND uoe.credit_step_id IS NULL + AND (uoe.billing_cycle_start IS NOT DISTINCT FROM p_billing_cycle_start::date) + AND (uoe.billing_cycle_end IS NOT DISTINCT FROM p_billing_cycle_end::date) + ORDER BY uoe.created_at DESC + LIMIT 1; + + -- Only create new record if overage amount changed significantly (more than 1% or first record) + IF v_latest_event_id IS NULL OR ABS(v_latest_overage_amount - p_overage_amount) / NULLIF(v_latest_overage_amount, 0) > 0.01 THEN + INSERT INTO public.usage_overage_events ( + org_id, + metric, + overage_amount, + credits_estimated, + credits_debited, + credit_step_id, + billing_cycle_start, + billing_cycle_end, + details + ) + VALUES ( + p_org_id, + p_metric, + p_overage_amount, + 0, + 0, + NULL, + p_billing_cycle_start, + p_billing_cycle_end, + p_details + ) + RETURNING id INTO v_event_id; + ELSE + -- Reuse existing event + v_event_id := v_latest_event_id; + END IF; + + RETURN QUERY SELECT p_overage_amount, 0::numeric, 0::numeric, 0::numeric, NULL::bigint, 0::numeric, p_overage_amount, v_event_id; + RETURN; + END IF; + + v_per_unit := v_calc.credit_cost_per_unit; + v_required := v_calc.credits_required; + + -- Get the most recent event for this cycle + SELECT uoe.id, uoe.overage_amount + INTO v_latest_event_id, v_latest_overage_amount + FROM public.usage_overage_events uoe + WHERE uoe.org_id = p_org_id + AND uoe.metric = p_metric + AND (uoe.billing_cycle_start IS NOT DISTINCT FROM p_billing_cycle_start::date) + AND (uoe.billing_cycle_end IS NOT DISTINCT FROM p_billing_cycle_end::date) + ORDER BY uoe.created_at DESC + LIMIT 1; + + -- Calculate how many credits we can still try to apply + -- Use credits_debited for this since it reflects actual consumption + SELECT COALESCE(SUM(credits_debited), 0) + INTO v_existing_credits_debited + FROM public.usage_overage_events + WHERE org_id = p_org_id + AND metric = p_metric + AND (billing_cycle_start IS NOT DISTINCT FROM p_billing_cycle_start::date) + AND (billing_cycle_end IS NOT DISTINCT FROM p_billing_cycle_end::date); + + v_credits_to_apply := GREATEST(v_required - v_existing_credits_debited, 0); + v_remaining := v_credits_to_apply; + + -- Check if there are any credits available in grants + SELECT COALESCE(SUM(GREATEST(credits_total - credits_consumed, 0)), 0) + INTO v_credits_available + FROM public.usage_credit_grants + WHERE org_id = p_org_id + AND expires_at >= NOW(); + + -- Determine if we need a new record: + -- 1. No existing record for this cycle (first overage) + -- 2. Overage amount changed significantly (more than 1%) + -- 3. We have NEW credits available AND we need to apply them + v_needs_new_record := v_latest_event_id IS NULL + OR (v_latest_overage_amount IS NOT NULL + AND ABS(v_latest_overage_amount - p_overage_amount) / NULLIF(v_latest_overage_amount, 0) > 0.01) + OR (v_credits_to_apply > 0 AND v_credits_available > 0 AND v_existing_credits_debited = 0); + + -- Only create new record if needed + IF v_needs_new_record THEN + INSERT INTO public.usage_overage_events ( + org_id, + metric, + overage_amount, + credits_estimated, + credits_debited, + credit_step_id, + billing_cycle_start, + billing_cycle_end, + details + ) + VALUES ( + p_org_id, + p_metric, + p_overage_amount, + v_required, + 0, + v_calc.credit_step_id, + p_billing_cycle_start, + p_billing_cycle_end, + COALESCE(p_details, '{}'::jsonb) || jsonb_build_object( + 'credits_available', v_credits_available, + 'credits_to_apply', v_credits_to_apply, + 'debit_status', CASE + WHEN v_credits_available = 0 THEN 'no_grants_available' + WHEN v_credits_to_apply = 0 THEN 'already_debited' + ELSE 'pending_debit' + END + ) + ) + RETURNING id INTO v_event_id; + + -- Apply credits from available grants if any + IF v_credits_to_apply > 0 THEN + FOR grant_rec IN + SELECT * + FROM public.usage_credit_grants + WHERE org_id = p_org_id + AND expires_at >= NOW() + AND credits_consumed < credits_total + ORDER BY expires_at ASC, granted_at ASC + FOR UPDATE + LOOP + EXIT WHEN v_remaining <= 0; + + v_available := grant_rec.credits_total - grant_rec.credits_consumed; + IF v_available <= 0 THEN + CONTINUE; + END IF; + + v_use := LEAST(v_available, v_remaining); + v_remaining := v_remaining - v_use; + v_applied := v_applied + v_use; + + UPDATE public.usage_credit_grants + SET credits_consumed = credits_consumed + v_use + WHERE id = grant_rec.id; + + INSERT INTO public.usage_credit_consumptions ( + grant_id, + org_id, + overage_event_id, + metric, + credits_used + ) + VALUES ( + grant_rec.id, + p_org_id, + v_event_id, + p_metric, + v_use + ); + + SELECT COALESCE(SUM(GREATEST(credits_total - credits_consumed, 0)), 0) + INTO v_balance + FROM public.usage_credit_grants + WHERE org_id = p_org_id + AND expires_at >= NOW(); + + INSERT INTO public.usage_credit_transactions ( + org_id, + grant_id, + transaction_type, + amount, + balance_after, + occurred_at, + description, + source_ref + ) + VALUES ( + p_org_id, + grant_rec.id, + 'deduction', + -v_use, + v_balance, + NOW(), + format('Overage deduction for %s usage', p_metric::text), + jsonb_build_object('overage_event_id', v_event_id, 'metric', p_metric::text) + ); + END LOOP; + + -- Update the event with actual credits applied + UPDATE public.usage_overage_events + SET + credits_debited = v_applied, + details = COALESCE(details, '{}'::jsonb) || jsonb_build_object( + 'credits_actually_applied', v_applied, + 'debit_status', CASE + WHEN v_applied >= v_credits_to_apply THEN 'fully_debited' + WHEN v_applied > 0 THEN 'partially_debited' + ELSE 'no_debit' + END + ) + WHERE id = v_event_id; + END IF; + ELSE + -- Reuse latest event ID, no new record needed + v_event_id := v_latest_event_id; + END IF; + + -- Calculate how much overage is covered by credits + IF v_per_unit > 0 THEN + v_overage_paid := LEAST(p_overage_amount, (v_applied + v_existing_credits_debited) / v_per_unit); + ELSE + v_overage_paid := p_overage_amount; + END IF; + + RETURN QUERY SELECT + p_overage_amount, + v_required, + v_applied, + GREATEST(v_required - v_existing_credits_debited - v_applied, 0), + v_calc.credit_step_id, + v_overage_paid, + GREATEST(p_overage_amount - v_overage_paid, 0), + v_event_id; +END; +$$; + + +ALTER FUNCTION "public"."apply_usage_overage"("p_org_id" "uuid", "p_metric" "public"."credit_metric_type", "p_overage_amount" numeric, "p_billing_cycle_start" timestamp with time zone, "p_billing_cycle_end" timestamp with time zone, "p_details" "jsonb") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."audit_log_trigger"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_old_record JSONB; + v_new_record JSONB; + v_changed_fields TEXT[]; + v_org_id UUID; + v_record_id TEXT; + v_user_id UUID; + v_key TEXT; + v_org_exists BOOLEAN; + v_stats_refresh_fields CONSTANT TEXT[] := ARRAY['stats_refresh_requested_at', 'stats_updated_at', 'updated_at']; +BEGIN + -- Skip audit logging for org DELETE operations + -- When an org is deleted, we can't insert into audit_logs because the org_id + -- foreign key would reference a non-existent org + IF TG_TABLE_NAME = 'orgs' AND TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + + -- Get current user from auth context or API key + -- Uses get_identity() WITH key_mode parameter to support both JWT auth and API key authentication + v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); + + -- Skip audit logging if no user is identified + -- We only want to log actions performed by authenticated users + IF v_user_id IS NULL THEN + RETURN COALESCE(NEW, OLD); + END IF; + + -- Convert records to JSONB based on operation type + IF TG_OP = 'DELETE' THEN + v_old_record := pg_catalog.to_jsonb(OLD); + v_new_record := NULL; + ELSIF TG_OP = 'INSERT' THEN + v_old_record := NULL; + v_new_record := pg_catalog.to_jsonb(NEW); + ELSE -- UPDATE + v_old_record := pg_catalog.to_jsonb(OLD); + v_new_record := pg_catalog.to_jsonb(NEW); + + -- Calculate changed fields by comparing old and new values + FOR v_key IN SELECT pg_catalog.jsonb_object_keys(v_new_record) + LOOP + IF v_old_record->v_key IS DISTINCT FROM v_new_record->v_key THEN + v_changed_fields := pg_catalog.array_append(v_changed_fields, v_key); + END IF; + END LOOP; + + -- Dashboard chart refreshes only touch stats refresh state. The apps table + -- also receives updated_at from its update trigger, so keep that out too. + IF TG_TABLE_NAME = ANY(ARRAY['apps', 'orgs']) + AND v_changed_fields && ARRAY['stats_refresh_requested_at', 'stats_updated_at'] + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(v_changed_fields) AS changed_field(field_name) + WHERE changed_field.field_name <> ALL(v_stats_refresh_fields) + ) THEN + RETURN NEW; + END IF; + END IF; + + -- Get org_id and record_id based on table being modified + CASE TG_TABLE_NAME + WHEN 'orgs' THEN + v_org_id := COALESCE(NEW.id, OLD.id); + v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; + WHEN 'apps' THEN + v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); + v_record_id := COALESCE(NEW.app_id, OLD.app_id)::TEXT; + WHEN 'channels' THEN + v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); + v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; + WHEN 'app_versions' THEN + v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); + v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; + WHEN 'org_users' THEN + v_org_id := COALESCE(NEW.org_id, OLD.org_id); + v_record_id := COALESCE(NEW.id, OLD.id)::TEXT; + ELSE + -- Fallback for any other table (shouldn't happen with current triggers) + v_org_id := NULL; + v_record_id := NULL; + END CASE; + + -- Only insert if we have a valid org_id and the org still exists + -- This handles edge cases where related tables are deleted after the org + IF v_org_id IS NOT NULL THEN + -- Check if the org still exists (important for DELETE operations on child tables) + SELECT EXISTS(SELECT 1 FROM public.orgs WHERE id = v_org_id) INTO v_org_exists; + + IF v_org_exists THEN + INSERT INTO "public"."audit_logs" ( + table_name, record_id, operation, user_id, org_id, + old_record, new_record, changed_fields + ) VALUES ( + TG_TABLE_NAME, v_record_id, TG_OP, v_user_id, v_org_id, + v_old_record, v_new_record, v_changed_fields + ); + END IF; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$; + + +ALTER FUNCTION "public"."audit_log_trigger"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."audit_logs_allowed_orgs"() RETURNS "uuid"[] + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_api_key_text text; + v_api_key public.apikeys%ROWTYPE; + v_permission text := public.rbac_permission_for_legacy(public.rbac_right_super_admin(), public.rbac_scope_org()); + v_allowed uuid[] := '{}'::uuid[]; +BEGIN + SELECT auth.uid() INTO v_user_id; + SELECT public.get_apikey_header() INTO v_api_key_text; + + IF v_user_id IS NULL AND v_api_key_text IS NULL THEN + RETURN v_allowed; + END IF; + + IF v_api_key_text IS NOT NULL THEN + SELECT * INTO v_api_key FROM public.find_apikey_by_value(v_api_key_text) LIMIT 1; + IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN + RETURN v_allowed; + END IF; + v_user_id := v_api_key.user_id; + END IF; + + SELECT COALESCE(array_agg(DISTINCT orgs.id), '{}'::uuid[]) + INTO v_allowed + FROM public.orgs + WHERE CASE + WHEN v_api_key.id IS NOT NULL THEN public.rbac_check_permission_direct(v_permission, v_user_id, orgs.id, NULL, NULL, v_api_key_text) + ELSE public.rbac_check_permission_direct(v_permission, v_user_id, orgs.id, NULL, NULL, NULL) + END; + + RETURN v_allowed; +END; +$$; + + +ALTER FUNCTION "public"."audit_logs_allowed_orgs"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."auto_apikey_name_by_id"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$BEGIN + + IF (NEW.name IS NOT DISTINCT FROM NULL) OR LENGTH(NEW.name) = 0 THEN + NEW.name = format('Apikey %s', NEW.id); + END IF; + + RETURN NEW; +END;$$; + + +ALTER FUNCTION "public"."auto_apikey_name_by_id"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."auto_owner_org_by_app_id"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + IF NEW."app_id" IS DISTINCT FROM OLD."app_id" AND OLD."app_id" IS DISTINCT FROM NULL THEN + RAISE EXCEPTION 'changing the app_id is not allowed'; + END IF; + + NEW.owner_org = public.get_owner_org_by_app_id_internal(NEW."app_id"); + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."auto_owner_org_by_app_id"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."bind_creating_apikey_to_org_on_create"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key_text text; + api_key public.apikeys%ROWTYPE; + org_super_admin_role_id uuid; +BEGIN + SELECT public.get_apikey_header() INTO api_key_text; + IF api_key_text IS NULL THEN + RETURN NEW; + END IF; + + SELECT * + INTO api_key + FROM public.find_apikey_by_value(api_key_text) + LIMIT 1; + + IF api_key.id IS NULL + OR public.is_apikey_expired(api_key.expires_at) + OR api_key.user_id IS DISTINCT FROM NEW.created_by + THEN + RETURN NEW; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.apikey_global_permissions AS agp + WHERE agp.apikey_rbac_id = api_key.rbac_id + AND agp.permission_key = public.rbac_perm_org_create() + ) + OR NOT public.apikey_has_current_org_create_capability(api_key.rbac_id) THEN + RETURN NEW; + END IF; + + SELECT roles.id + INTO org_super_admin_role_id + FROM public.roles + WHERE roles.name = public.rbac_role_org_super_admin() + AND roles.scope_type = public.rbac_scope_org() + LIMIT 1; + + IF org_super_admin_role_id IS NULL THEN + RAISE EXCEPTION 'org_super_admin role not found'; + END IF; + + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + granted_by, + granted_at, + reason, + is_direct + ) + VALUES ( + public.rbac_principal_apikey(), + api_key.rbac_id, + org_super_admin_role_id, + public.rbac_scope_org(), + NEW.id, + NEW.created_by, + pg_catalog.now(), + 'Auto-granted to API key on org creation', + true + ) + ON CONFLICT DO NOTHING; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."bind_creating_apikey_to_org_on_create"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."calculate_credit_cost"("p_metric" "public"."credit_metric_type", "p_overage_amount" numeric) RETURNS TABLE("credit_step_id" bigint, "credit_cost_per_unit" numeric, "credits_required" numeric) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + v_step public.capgo_credits_steps%ROWTYPE; + v_highest public.capgo_credits_steps%ROWTYPE; + v_remaining numeric; + v_applied_range numeric; + v_units numeric; + v_total_credits numeric := 0; + v_last_step_id bigint := NULL; + v_unit_factor numeric; +BEGIN + IF p_overage_amount IS NULL OR p_overage_amount <= 0 THEN + RETURN QUERY SELECT NULL::bigint, 0::numeric, 0::numeric; + RETURN; + END IF; + + v_remaining := p_overage_amount; + + SELECT * + INTO v_highest + FROM public.capgo_credits_steps + WHERE type = p_metric::text + ORDER BY step_max DESC, step_min DESC + LIMIT 1; + + IF NOT FOUND THEN + RAISE WARNING 'No pricing steps found for metric: %', p_metric::text; + RETURN QUERY SELECT NULL::bigint, 0::numeric, 0::numeric; + RETURN; + END IF; + + FOR v_step IN + SELECT * + FROM public.capgo_credits_steps + WHERE type = p_metric::text + ORDER BY step_min ASC + LOOP + EXIT WHEN v_remaining <= 0; + + IF p_overage_amount < v_step.step_min THEN + EXIT; + END IF; + + v_applied_range := LEAST( + v_remaining, + (v_step.step_max - v_step.step_min)::numeric + ); + + IF v_applied_range <= 0 THEN + CONTINUE; + END IF; + + v_unit_factor := GREATEST(NULLIF(v_step.unit_factor, 0), 1)::numeric; + v_units := CEILING(v_applied_range / v_unit_factor); + + IF v_units <= 0 THEN + CONTINUE; + END IF; + + v_total_credits := v_total_credits + (v_units * v_step.price_per_unit::numeric); + v_remaining := v_remaining - v_applied_range; + v_last_step_id := v_step.id; + END LOOP; + + IF v_remaining > 0 THEN + v_unit_factor := GREATEST(NULLIF(v_highest.unit_factor, 0), 1)::numeric; + v_units := CEILING(v_remaining / v_unit_factor); + + IF v_units > 0 THEN + v_total_credits := v_total_credits + (v_units * v_highest.price_per_unit::numeric); + v_last_step_id := v_highest.id; + END IF; + END IF; + + RETURN QUERY SELECT + v_last_step_id::bigint, + CASE WHEN p_overage_amount > 0 THEN v_total_credits / p_overage_amount ELSE 0 END, + v_total_credits; +END; +$$; + + +ALTER FUNCTION "public"."calculate_credit_cost"("p_metric" "public"."credit_metric_type", "p_overage_amount" numeric) OWNER TO "postgres"; + +SET default_tablespace = ''; + +SET default_table_access_method = "heap"; + + +CREATE TABLE IF NOT EXISTS "public"."org_metrics_cache" ( + "org_id" "uuid" NOT NULL, + "start_date" "date" NOT NULL, + "end_date" "date" NOT NULL, + "mau" bigint NOT NULL, + "storage" bigint NOT NULL, + "bandwidth" bigint NOT NULL, + "build_time_unit" bigint NOT NULL, + "get" bigint NOT NULL, + "fail" bigint NOT NULL, + "install" bigint NOT NULL, + "uninstall" bigint NOT NULL, + "cached_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."org_metrics_cache" OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."calculate_org_metrics_cache_entry"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") RETURNS "public"."org_metrics_cache" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_mau bigint; + v_storage bigint; + v_bandwidth bigint; + v_build_time bigint; + v_get bigint; + v_fail bigint; + v_install bigint; + v_uninstall bigint; + cache_record public.org_metrics_cache%ROWTYPE; +BEGIN + WITH app_ids AS ( + SELECT apps.app_id + FROM public.apps + WHERE apps.owner_org = p_org_id + UNION + SELECT deleted_apps.app_id + FROM public.deleted_apps + WHERE deleted_apps.owner_org = p_org_id + ), + mau AS ( + SELECT COALESCE(SUM(dm.mau), 0)::bigint AS value + FROM public.daily_mau dm + JOIN app_ids a ON a.app_id = dm.app_id + WHERE dm.date BETWEEN p_start_date AND p_end_date + ), + bandwidth AS ( + SELECT COALESCE(SUM(db.bandwidth), 0)::bigint AS value + FROM public.daily_bandwidth db + JOIN app_ids a ON a.app_id = db.app_id + WHERE db.date BETWEEN p_start_date AND p_end_date + ), + build_time AS ( + SELECT COALESCE(SUM(dbt.build_time_unit), 0)::bigint AS value + FROM public.daily_build_time dbt + JOIN app_ids a ON a.app_id = dbt.app_id + WHERE dbt.date BETWEEN p_start_date AND p_end_date + ), + version_stats AS ( + SELECT + COALESCE(SUM(dv.get), 0)::bigint AS get, + COALESCE(SUM(dv.fail), 0)::bigint AS fail, + COALESCE(SUM(dv.install), 0)::bigint AS install, + COALESCE(SUM(dv.uninstall), 0)::bigint AS uninstall + FROM public.daily_version dv + JOIN app_ids a ON a.app_id = dv.app_id + WHERE dv.date BETWEEN p_start_date AND p_end_date + ), + storage AS ( + SELECT COALESCE(SUM(avm.size), 0)::bigint AS value + FROM public.app_versions av + INNER JOIN public.app_versions_meta avm ON av.id = avm.id + WHERE av.owner_org = p_org_id AND av.deleted = false + ) + SELECT + mau.value, + storage.value, + bandwidth.value, + build_time.value, + version_stats.get, + version_stats.fail, + version_stats.install, + version_stats.uninstall + INTO v_mau, v_storage, v_bandwidth, v_build_time, v_get, v_fail, v_install, v_uninstall + FROM mau, storage, bandwidth, build_time, version_stats; + + cache_record.org_id := p_org_id; + cache_record.start_date := p_start_date; + cache_record.end_date := p_end_date; + cache_record.mau := v_mau; + cache_record.storage := v_storage; + cache_record.bandwidth := v_bandwidth; + cache_record.build_time_unit := v_build_time; + cache_record.get := v_get; + cache_record.fail := v_fail; + cache_record.install := v_install; + cache_record.uninstall := v_uninstall; + cache_record.cached_at := clock_timestamp(); + + RETURN cache_record; +END; +$$; + + +ALTER FUNCTION "public"."calculate_org_metrics_cache_entry"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."calculate_org_metrics_cache_entry"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") IS 'Compute the aggregated org metrics (MAU, storage, bandwidth, build time unit, get/fail/install/uninstall) for the supplied date range without persisting changes. Read-only paths use this helper so they can return cached metrics without touching org_metrics_cache directly.'; + + + +CREATE TABLE IF NOT EXISTS "public"."apikeys" ( + "id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"(), + "user_id" "uuid" NOT NULL, + "key" character varying, + "updated_at" timestamp with time zone DEFAULT "now"(), + "name" character varying NOT NULL, + "rbac_id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "key_hash" "text", + "expires_at" timestamp with time zone, + CONSTRAINT "apikeys_key_or_hash" CHECK ((("key" IS NOT NULL) OR ("key_hash" IS NOT NULL))) +); + + +ALTER TABLE "public"."apikeys" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."apikeys"."rbac_id" IS 'Stable UUID to bind RBAC roles to api keys.'; + + + +COMMENT ON COLUMN "public"."apikeys"."key_hash" IS 'SHA-256 hash of the API key. When set, the key column is cleared to null for security.'; + + + +COMMENT ON COLUMN "public"."apikeys"."expires_at" IS 'When this API key expires. NULL means never expires.'; + + + +CREATE OR REPLACE FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + scoped_enforced_org_exists boolean; +BEGIN + IF apikey_row.key IS NULL AND apikey_row.key_hash IS NOT NULL THEN + RETURN true; + END IF; + + IF apikey_row.rbac_id IS NULL THEN + RETURN true; + END IF; + + WITH enforced_orgs AS ( + SELECT public.orgs.id + FROM public.orgs + WHERE public.orgs.enforce_hashed_api_keys = true + ) + SELECT EXISTS ( + SELECT 1 + FROM enforced_orgs + WHERE EXISTS ( + SELECT 1 + FROM public.role_bindings rb + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = apikey_row.rbac_id + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id = enforced_orgs.id + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ) + OR EXISTS ( + SELECT 1 + FROM public.role_bindings rb + JOIN public.apps apps + ON apps.id = rb.app_id + AND apps.owner_org = enforced_orgs.id + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = apikey_row.rbac_id + AND rb.scope_type = public.rbac_scope_app() + AND rb.app_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ) + OR EXISTS ( + SELECT 1 + FROM public.role_bindings rb + JOIN public.channels channels + ON channels.rbac_id = rb.channel_id + AND channels.owner_org = enforced_orgs.id + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = apikey_row.rbac_id + AND rb.scope_type = public.rbac_scope_channel() + AND rb.channel_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ) + ) + INTO scoped_enforced_org_exists; + + IF scoped_enforced_org_exists THEN + PERFORM public.pg_log( + 'deny: ORG_REQUIRES_HASHED_API_KEY', + jsonb_build_object('apikey_id', apikey_row.id, 'user_id', apikey_row.user_id) + ); + RETURN false; + END IF; + + RETURN true; +END; +$$; + + +ALTER FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") IS 'Rejects plaintext API keys when any scoped org requires hashed API keys. The lookup starts from enforcing orgs and indexed RBAC bindings so broad API keys do not scan every app binding on each permission check.'; + + + +CREATE OR REPLACE FUNCTION "public"."check_domain_sso"("p_domain" "text") RETURNS TABLE("has_sso" boolean, "provider_id" "text", "org_id" "uuid") + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ + SELECT + true AS has_sso, + sp.provider_id, + sp.org_id + FROM public.sso_providers AS sp + JOIN public.orgs AS o ON o.id = sp.org_id + WHERE sp."domain" = lower(btrim(p_domain)) + AND sp.status = 'active' + LIMIT 1; +$$; + + +ALTER FUNCTION "public"."check_domain_sso"("p_domain" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_encrypted_bundle_on_insert"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_id uuid; + org_enforcing boolean; + org_required_key varchar(21); + bundle_is_encrypted boolean; + bundle_key_id varchar(20); + bundle_was_ready boolean; +BEGIN + IF TG_OP = 'UPDATE' THEN + bundle_was_ready := OLD.storage_provider IS DISTINCT FROM 'r2-direct'; + + IF bundle_was_ready + AND ( + NEW.name IS DISTINCT FROM OLD.name + OR NEW.app_id IS DISTINCT FROM OLD.app_id + OR NEW.session_key IS DISTINCT FROM OLD.session_key + OR NEW.key_id IS DISTINCT FROM OLD.key_id + OR NEW.storage_provider IS DISTINCT FROM OLD.storage_provider + OR NEW.r2_path IS DISTINCT FROM OLD.r2_path + OR NEW.external_url IS DISTINCT FROM OLD.external_url + OR NEW.checksum IS DISTINCT FROM OLD.checksum + OR NEW.manifest IS DISTINCT FROM OLD.manifest + OR NEW.native_packages IS DISTINCT FROM OLD.native_packages + ) + THEN + PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', + jsonb_build_object( + 'org_id', OLD.owner_org, + 'app_id', OLD.app_id, + 'version_name', OLD.name, + 'user_id', OLD.user_id, + 'old_storage_provider', OLD.storage_provider, + 'new_storage_provider', NEW.storage_provider, + 'reason', 'bundle_ready' + )); + RAISE EXCEPTION '%', + 'bundle_already_ready: Bundle content cannot be changed ' + || 'after upload is complete. Upload a new bundle instead.'; + END IF; + END IF; + + -- Derive org_id from NEW.app_id first because + -- force_valid_owner_org_app_versions runs after this trigger. + SELECT apps.owner_org INTO org_id + FROM public.apps + WHERE apps.app_id = NEW.app_id; + + IF org_id IS NULL THEN + org_id := NEW.owner_org; + END IF; + + -- If org not found, allow the existing foreign-key/owner checks to fail. + IF org_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT enforce_encrypted_bundles, required_encryption_key + INTO org_enforcing, org_required_key + FROM public.orgs + WHERE id = org_id; + + IF org_enforcing IS NULL OR org_enforcing = false THEN + RETURN NEW; + END IF; + + bundle_is_encrypted := public.is_bundle_encrypted(NEW.session_key); + bundle_key_id := NULLIF(btrim(NEW.key_id), '')::varchar(20); + + IF NOT bundle_is_encrypted THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_ENCRYPTED_BUNDLES_TRIGGER', + jsonb_build_object( + 'org_id', org_id, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'reason', 'not_encrypted' + )); + RAISE EXCEPTION '%', + 'encryption_required: This organization requires all bundles to be ' + || 'encrypted. Please upload an encrypted bundle with a session_key.'; + END IF; + + IF org_required_key IS NOT NULL AND org_required_key <> '' THEN + IF bundle_key_id IS NULL THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', + jsonb_build_object( + 'org_id', org_id, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'required_key', org_required_key, + 'bundle_key_id', bundle_key_id, + 'reason', 'missing_key_id' + )); + RAISE EXCEPTION '%', + 'encryption_key_required: This organization requires bundles to be ' + || 'encrypted with a specific key. The uploaded bundle does not have ' + || 'a key_id.'; + END IF; + + -- key_id is 20 chars and required_encryption_key may be 20 or 21 chars. + IF NOT ( + bundle_key_id = LEFT(org_required_key, 20) + OR LEFT(bundle_key_id, LENGTH(org_required_key)) = org_required_key + ) THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', + jsonb_build_object( + 'org_id', org_id, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'required_key', org_required_key, + 'bundle_key_id', bundle_key_id, + 'reason', 'key_mismatch' + )); + RAISE EXCEPTION '%', + 'encryption_key_mismatch: This organization requires bundles to be ' + || 'encrypted with a specific key. The uploaded bundle was encrypted ' + || 'with a different key.'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."check_encrypted_bundle_on_insert"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_if_org_can_exist"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM public.orgs + WHERE + ( + ( + SELECT + count(*) + FROM + public.org_users + WHERE + org_users.user_right = 'super_admin' + AND org_users.user_id != OLD.user_id + AND org_users.org_id=orgs.id + ) = 0 + ) + AND orgs.id=OLD.org_id; + + RETURN OLD; +END;$$; + + +ALTER FUNCTION "public"."check_if_org_can_exist"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) RETURNS boolean + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.check_min_rights(min_right, (SELECT auth.uid()), org_id, app_id, channel_id); +END; +$$; + + +ALTER FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_perm text; + v_scope text; + v_apikey text; + v_effective_org_id uuid := org_id; + v_app_owner_org uuid; + v_org_enforcing_2fa boolean; + v_password_policy_ok boolean; +BEGIN + IF app_id IS NOT NULL THEN + SELECT owner_org INTO v_app_owner_org + FROM public.apps + WHERE public.apps.app_id = check_min_rights.app_id + LIMIT 1; + + IF v_app_owner_org IS NOT NULL THEN + IF v_effective_org_id IS NOT NULL AND v_effective_org_id IS DISTINCT FROM v_app_owner_org THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APP_ORG_MISMATCH', jsonb_build_object( + 'org_id', v_effective_org_id, + 'app_owner_org', v_app_owner_org, + 'app_id', app_id, + 'channel_id', channel_id, + 'min_right', min_right::text, + 'user_id', user_id + )); + RETURN false; + END IF; + + v_effective_org_id := v_app_owner_org; + END IF; + END IF; + + IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN + SELECT owner_org INTO v_effective_org_id + FROM public.channels + WHERE public.channels.id = channel_id + LIMIT 1; + END IF; + + SELECT public.get_apikey_header() INTO v_apikey; + + IF v_effective_org_id IS NOT NULL AND NOT (v_apikey IS NOT NULL AND user_id IS NULL) THEN + SELECT enforcing_2fa INTO v_org_enforcing_2fa + FROM public.orgs + WHERE id = v_effective_org_id; + + IF v_org_enforcing_2fa = true AND (user_id IS NULL OR NOT public.has_2fa_enabled(user_id)) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( + 'org_id', COALESCE(org_id, v_effective_org_id), + 'app_id', app_id, + 'channel_id', channel_id, + 'min_right', min_right::text, + 'user_id', user_id + )); + RETURN false; + END IF; + + v_password_policy_ok := public.user_meets_password_policy(user_id, v_effective_org_id); + IF v_password_policy_ok = false THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( + 'org_id', COALESCE(org_id, v_effective_org_id), + 'app_id', app_id, + 'channel_id', channel_id, + 'min_right', min_right::text, + 'user_id', user_id + )); + RETURN false; + END IF; + END IF; + + IF channel_id IS NOT NULL THEN + v_scope := public.rbac_scope_channel(); + ELSIF app_id IS NOT NULL THEN + v_scope := public.rbac_scope_app(); + ELSE + v_scope := public.rbac_scope_org(); + END IF; + + v_perm := public.rbac_permission_for_legacy(min_right, v_scope); + RETURN public.rbac_check_permission_direct(v_perm, user_id, v_effective_org_id, app_id, channel_id, v_apikey); +END; +$$; + + +ALTER FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_min_rights_legacy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.check_min_rights(min_right, user_id, org_id, app_id, channel_id); +END; +$$; + + +ALTER FUNCTION "public"."check_min_rights_legacy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_min_rights_legacy_no_password_policy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_perm text; + v_scope text; +BEGIN + IF channel_id IS NOT NULL THEN + v_scope := public.rbac_scope_channel(); + ELSIF app_id IS NOT NULL THEN + v_scope := public.rbac_scope_app(); + ELSE + v_scope := public.rbac_scope_org(); + END IF; + + v_perm := public.rbac_permission_for_legacy(min_right, v_scope); + RETURN public.rbac_check_permission_direct_no_password_policy(v_perm, user_id, org_id, app_id, channel_id, NULL); +END; +$$; + + +ALTER FUNCTION "public"."check_min_rights_legacy_no_password_policy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_org_encrypted_bundle_enforcement"("org_id" "uuid", "session_key" "text") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_enforcing boolean; + is_encrypted boolean; +BEGIN + -- Check if org exists and get enforcement setting + SELECT enforce_encrypted_bundles INTO org_enforcing + FROM public.orgs + WHERE id = check_org_encrypted_bundle_enforcement.org_id; + + IF NOT FOUND THEN + RETURN true; -- Org not found, allow (will fail on other checks) + END IF; + + -- If org doesn't enforce encrypted bundles, allow + IF org_enforcing = false THEN + RETURN true; + END IF; + + -- Check if this bundle is encrypted + is_encrypted := public.is_bundle_encrypted(session_key); + + IF NOT is_encrypted THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_ENCRYPTED_BUNDLES', + jsonb_build_object('org_id', org_id)); + RETURN false; + END IF; + + RETURN true; +END; +$$; + + +ALTER FUNCTION "public"."check_org_encrypted_bundle_enforcement"("org_id" "uuid", "session_key" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_org_hashed_key_enforcement"("org_id" "uuid", "apikey_row" "public"."apikeys") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_enforcing boolean; + is_hashed_key boolean; +BEGIN + -- Check if org exists and get enforcement setting + SELECT enforce_hashed_api_keys INTO org_enforcing + FROM public.orgs + WHERE id = check_org_hashed_key_enforcement.org_id; + + IF NOT FOUND THEN + RETURN true; -- Org not found, allow (will fail on other checks) + END IF; + + -- If org doesn't enforce hashed keys, allow + IF org_enforcing = false THEN + RETURN true; + END IF; + + -- Check if this is a hashed key (key is null, key_hash is not null) + is_hashed_key := (apikey_row.key IS NULL AND apikey_row.key_hash IS NOT NULL); + + IF NOT is_hashed_key THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_HASHED_API_KEY', + jsonb_build_object('org_id', org_id, 'apikey_id', apikey_row.id)); + RETURN false; + END IF; + + RETURN true; +END; +$$; + + +ALTER FUNCTION "public"."check_org_hashed_key_enforcement"("org_id" "uuid", "apikey_row" "public"."apikeys") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uuid") RETURNS TABLE("user_id" "uuid", "2fa_enabled" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + -- Check if org exists + IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = check_org_members_2fa_enabled.org_id) THEN + RAISE EXCEPTION 'Organization does not exist'; + END IF; + + -- Check if the current user is a super_admin of the organization + IF NOT ( + public.check_min_rights( + 'super_admin'::public.user_min_right, + (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], check_org_members_2fa_enabled.org_id)), + check_org_members_2fa_enabled.org_id, + NULL::character varying, + NULL::bigint + ) + ) THEN + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + -- Return list of org members with their 2FA status + RETURN QUERY + SELECT + ou.user_id, + COALESCE(public.has_2fa_enabled(ou.user_id), false) AS "2fa_enabled" + FROM public.org_users ou + WHERE ou.org_id = check_org_members_2fa_enabled.org_id; +END; +$$; + + +ALTER FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") RETURNS TABLE("user_id" "uuid", "email" "text", "first_name" "text", "last_name" "text", "password_policy_compliant" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_is_service_role boolean; +BEGIN + v_user_id := public.get_identity( + '{read,upload,write,all}'::public.key_mode[] + ); + v_is_service_role := ( + (SELECT auth.jwt() ->> 'role') = 'service_role' + OR (SELECT session_user) IS NOT DISTINCT FROM 'postgres' + ); + + IF NOT v_is_service_role THEN + IF v_user_id IS NULL OR NOT public.check_min_rights( + 'super_admin'::public.user_min_right, + public.get_identity_org_allowed( + '{read,upload,write,all}'::public.key_mode[], + check_org_members_password_policy.org_id + ), + check_org_members_password_policy.org_id, + NULL::character varying, + NULL::bigint + ) THEN + PERFORM public.pg_log( + 'deny: NO_RIGHTS', + jsonb_build_object( + 'org_id', check_org_members_password_policy.org_id, + 'uid', v_user_id + ) + ); + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + END IF; + + -- Check if org exists + IF NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE public.orgs.id = check_org_members_password_policy.org_id + ) THEN + RAISE EXCEPTION 'Organization does not exist'; + END IF; + + RETURN QUERY + SELECT + ou.user_id, + au.email::text, + u.first_name::text, + u.last_name::text, + public.user_meets_password_policy( + ou.user_id, + check_org_members_password_policy.org_id + ) AS password_policy_compliant + FROM public.org_users ou + JOIN auth.users au ON au.id = ou.user_id + LEFT JOIN public.users u ON u.id = ou.user_id + WHERE ou.org_id = check_org_members_password_policy.org_id; +END; +$$; + + +ALTER FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_org_user_privileges"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + v_is_super_admin boolean := false; + v_use_rbac boolean := false; + v_enforcing_2fa boolean := false; +BEGIN + -- Allow service_role / postgres to bypass + IF (((SELECT auth.jwt() ->> 'role') = 'service_role') OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres')) THEN + RETURN NEW; + END IF; + + v_use_rbac := public.rbac_is_enabled_for_org(NEW.org_id); + + IF v_use_rbac THEN + SELECT EXISTS ( + SELECT 1 + FROM public.role_bindings rb + JOIN public.roles r ON r.id = rb.role_id + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = auth.uid() + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id = NEW.org_id + AND r.name = public.rbac_role_org_super_admin() + ) INTO v_is_super_admin; + + IF v_is_super_admin THEN + SELECT enforcing_2fa INTO v_enforcing_2fa + FROM public.orgs + WHERE id = NEW.org_id; + + IF v_enforcing_2fa AND NOT public.has_2fa_enabled(auth.uid()) THEN + PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); + v_is_super_admin := false; + END IF; + END IF; + ELSE + v_is_super_admin := public.check_min_rights( + 'super_admin'::public.user_min_right, + (SELECT auth.uid()), + NEW.org_id, + NULL::character varying, + NULL::bigint + ); + END IF; + + IF v_is_super_admin THEN + RETURN NEW; + END IF; + + IF NEW.user_right IS NOT DISTINCT FROM 'super_admin'::public.user_min_right THEN + PERFORM public.pg_log('deny: ELEVATE_SUPER_ADMIN', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); + RAISE EXCEPTION 'Admins cannot elevate privileges!'; + END IF; + + IF NEW.user_right IS NOT DISTINCT FROM 'invite_super_admin'::public.user_min_right THEN + PERFORM public.pg_log('deny: ELEVATE_INVITE_SUPER_ADMIN', jsonb_build_object('org_id', NEW.org_id, 'uid', auth.uid())); + RAISE EXCEPTION 'Admins cannot elevate privileges!'; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."check_org_user_privileges"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) RETURNS integer + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + PERFORM appid; + RETURN NULL::integer; +END; +$$; + + +ALTER FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) IS 'Legacy RPC kept for older clients. Native/builtin channel targets are represented by channels.version = NULL and this function must not recreate app_versions rows.'; + + + +CREATE OR REPLACE FUNCTION "public"."claim_legacy_onboarding_demo_data"("p_app_uuid" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_app_id text; + v_owner_org uuid; + v_can_claim_full_seed boolean := false; +BEGIN + SELECT "app_id", "owner_org" + INTO v_app_id, v_owner_org + FROM "public"."apps" + WHERE "id" = p_app_uuid + AND "need_onboarding" IS TRUE; + + IF v_app_id IS NULL THEN + RETURN; + END IF; + + -- Legacy demo rows created before this provenance table had no durable owner + -- marker. Only claim rows with hard demo storage/build markers. Names alone + -- are not enough because customers can create normal 1.0.0/production rows. + INSERT INTO "public"."onboarding_demo_data" ( + "app_id", + "owner_org", + "relation_name", + "row_key", + "seed_id" + ) + SELECT + v_app_id, + v_owner_org, + 'manifest', + m."id"::text, + p_app_uuid + FROM "public"."manifest" m + INNER JOIN "public"."app_versions" av + ON av."id" = m."app_version_id" + WHERE av."app_id" = v_app_id + AND m."s3_path" LIKE ('demo/' || v_app_id || '/%') + ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE + SET + "owner_org" = EXCLUDED."owner_org", + "seed_id" = EXCLUDED."seed_id", + "created_at" = "now"(); + + INSERT INTO "public"."onboarding_demo_data" ( + "app_id", + "owner_org", + "relation_name", + "row_key", + "seed_id" + ) + SELECT + v_app_id, + v_owner_org, + 'build_requests', + br."id"::text, + p_app_uuid + FROM "public"."build_requests" br + WHERE br."app_id" = v_app_id + AND br."upload_session_key" LIKE 'demo-session-%' + AND br."upload_path" LIKE ('builds/' || v_app_id || '/%') + AND br."upload_url" LIKE ('https://demo-builds.example.com/' || v_app_id || '/%') + AND COALESCE(br."build_config"->>'bundleId', '') = v_app_id + AND ( + br."builder_job_id" LIKE 'demo-job-%' + OR br."builder_job_id" IS NULL + ) + ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE + SET + "owner_org" = EXCLUDED."owner_org", + "seed_id" = EXCLUDED."seed_id", + "created_at" = "now"(); + + SELECT + EXISTS ( + SELECT 1 + FROM "public"."manifest" m + INNER JOIN "public"."app_versions" av + ON av."id" = m."app_version_id" + WHERE av."app_id" = v_app_id + AND m."s3_path" LIKE ('demo/' || v_app_id || '/%') + ) + AND EXISTS ( + SELECT 1 + FROM "public"."build_requests" br + WHERE br."app_id" = v_app_id + AND br."upload_session_key" LIKE 'demo-session-%' + AND br."upload_url" LIKE ('https://demo-builds.example.com/' || v_app_id || '/%') + ) + AND NOT EXISTS ( + SELECT 1 + FROM "public"."app_versions" av + WHERE av."app_id" = v_app_id + AND av."name" NOT IN ('unknown', 'builtin', '1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0') + ) + AND NOT EXISTS ( + SELECT 1 + FROM "public"."manifest" m + INNER JOIN "public"."app_versions" av + ON av."id" = m."app_version_id" + WHERE av."app_id" = v_app_id + AND m."s3_path" NOT LIKE ('demo/' || v_app_id || '/%') + ) + AND NOT EXISTS ( + SELECT 1 + FROM "public"."channels" c + INNER JOIN "public"."app_versions" av + ON av."id" = c."version" + WHERE c."app_id" = v_app_id + AND NOT ( + c."disable_auto_update_under_native" IS TRUE + AND c."disable_auto_update" = 'major'::"public"."disable_update" + AND c."ios" IS TRUE + AND c."android" IS TRUE + AND c."electron" IS TRUE + AND c."allow_emulator" IS TRUE + AND c."allow_device" IS TRUE + AND c."allow_prod" IS TRUE + AND ( + ( + c."name" = 'production' + AND c."public" IS TRUE + AND c."allow_device_self_set" IS FALSE + AND c."allow_dev" IS FALSE + AND av."name" = '1.1.1' + ) + OR ( + c."name" = 'development' + AND c."public" IS FALSE + AND c."allow_device_self_set" IS FALSE + AND c."allow_dev" IS TRUE + AND av."name" = '1.2.0' + ) + OR ( + c."name" = 'pr-123' + AND c."public" IS FALSE + AND c."allow_device_self_set" IS TRUE + AND c."allow_dev" IS TRUE + AND av."name" = '1.2.0' + ) + ) + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM "public"."channel_devices" cd + WHERE cd."app_id" = v_app_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM "public"."devices" d + WHERE d."app_id" = v_app_id + AND NOT ( + d."plugin_version" = '6.0.0' + AND d."version_name" = '1.1.1' + AND COALESCE(d."version_build", '') = '1' + AND d."platform" IN ('ios'::"public"."platform_os", 'android'::"public"."platform_os") + AND COALESCE(d."os_version", '') IN ('17.0', '14') + AND COALESCE(d."is_prod", false) IS TRUE + AND COALESCE(d."is_emulator", true) IS FALSE + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM "public"."build_requests" br + WHERE br."app_id" = v_app_id + AND NOT ( + br."upload_session_key" LIKE 'demo-session-%' + AND br."upload_path" LIKE ('builds/' || v_app_id || '/%') + AND br."upload_url" LIKE ('https://demo-builds.example.com/' || v_app_id || '/%') + AND COALESCE(br."build_config"->>'bundleId', '') = v_app_id + AND ( + br."builder_job_id" LIKE 'demo-job-%' + OR br."builder_job_id" IS NULL + ) + ) + ) + AND NOT EXISTS ( + WITH expected_deploys AS ( + SELECT * + FROM (VALUES + ('production'::text, '1.0.0'::text), + ('development'::text, '1.0.1'::text), + ('production'::text, '1.0.1'::text), + ('development'::text, '1.1.0'::text), + ('production'::text, '1.1.0'::text), + ('development'::text, '1.1.1'::text), + ('production'::text, '1.1.1'::text), + ('pr-123'::text, '1.2.0'::text), + ('development'::text, '1.2.0'::text) + ) AS expected("channel_name", "version_name") + ) + SELECT 1 + FROM "public"."deploy_history" dh + INNER JOIN "public"."channels" c + ON c."id" = dh."channel_id" + INNER JOIN "public"."app_versions" av + ON av."id" = dh."version_id" + WHERE dh."app_id" = v_app_id + AND NOT EXISTS ( + SELECT 1 + FROM expected_deploys expected + WHERE expected."channel_name" = c."name" + AND expected."version_name" = av."name" + ) + ) + INTO v_can_claim_full_seed; + + IF NOT v_can_claim_full_seed THEN + RETURN; + END IF; + + INSERT INTO "public"."onboarding_demo_data" ( + "app_id", + "owner_org", + "relation_name", + "row_key", + "seed_id" + ) + SELECT + v_app_id, + v_owner_org, + 'app_versions', + av."id"::text, + p_app_uuid + FROM "public"."app_versions" av + WHERE av."app_id" = v_app_id + AND av."name" IN ('1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0') + ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE + SET + "owner_org" = EXCLUDED."owner_org", + "seed_id" = EXCLUDED."seed_id", + "created_at" = "now"(); + + INSERT INTO "public"."onboarding_demo_data" ( + "app_id", + "owner_org", + "relation_name", + "row_key", + "seed_id" + ) + SELECT + v_app_id, + v_owner_org, + 'app_versions_meta', + avm."id"::text, + p_app_uuid + FROM "public"."app_versions_meta" avm + INNER JOIN "public"."app_versions" av + ON av."id" = avm."id" + WHERE av."app_id" = v_app_id + AND av."name" IN ('1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0') + ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE + SET + "owner_org" = EXCLUDED."owner_org", + "seed_id" = EXCLUDED."seed_id", + "created_at" = "now"(); + + INSERT INTO "public"."onboarding_demo_data" ( + "app_id", + "owner_org", + "relation_name", + "row_key", + "seed_id" + ) + SELECT + v_app_id, + v_owner_org, + 'channels', + c."id"::text, + p_app_uuid + FROM "public"."channels" c + WHERE c."app_id" = v_app_id + AND c."name" IN ('production', 'development', 'pr-123') + ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE + SET + "owner_org" = EXCLUDED."owner_org", + "seed_id" = EXCLUDED."seed_id", + "created_at" = "now"(); + + INSERT INTO "public"."onboarding_demo_data" ( + "app_id", + "owner_org", + "relation_name", + "row_key", + "seed_id" + ) + SELECT + v_app_id, + v_owner_org, + 'deploy_history', + dh."id"::text, + p_app_uuid + FROM "public"."deploy_history" dh + WHERE dh."app_id" = v_app_id + ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE + SET + "owner_org" = EXCLUDED."owner_org", + "seed_id" = EXCLUDED."seed_id", + "created_at" = "now"(); + + INSERT INTO "public"."onboarding_demo_data" ( + "app_id", + "owner_org", + "relation_name", + "row_key", + "seed_id" + ) + SELECT + v_app_id, + v_owner_org, + 'devices', + d."id"::text, + p_app_uuid + FROM "public"."devices" d + WHERE d."app_id" = v_app_id + ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE + SET + "owner_org" = EXCLUDED."owner_org", + "seed_id" = EXCLUDED."seed_id", + "created_at" = "now"(); +END; +$$; + + +ALTER FUNCTION "public"."claim_legacy_onboarding_demo_data"("p_app_uuid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_apikey_role_bindings"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM public.role_bindings + WHERE principal_type = public.rbac_principal_apikey() + AND principal_id = OLD.rbac_id; + + RETURN OLD; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_apikey_role_bindings"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_expired_apikeys"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM "public"."apikeys" + WHERE expires_at IS NOT NULL + AND expires_at < NOW() - INTERVAL '30 days'; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_expired_apikeys"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_expired_demo_apps"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + deleted_count integer; +BEGIN + WITH deleted_apps AS ( + DELETE FROM public.apps + WHERE need_onboarding IS TRUE + AND created_at < now() - interval '14 days' + AND public.has_seeded_demo_data(app_id) + RETURNING owner_org + ), + evicted_cache AS ( + DELETE FROM public.app_metrics_cache + WHERE org_id IN ( + SELECT DISTINCT owner_org + FROM deleted_apps + WHERE owner_org IS NOT NULL + ) + ) + SELECT COUNT(*)::integer + INTO deleted_count + FROM deleted_apps; + + RAISE NOTICE 'cleanup_expired_demo_apps: Deleted % expired demo apps', deleted_count; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_expired_demo_apps"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_frequent_job_details"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM cron.job_run_details + WHERE job_pid IN ( + SELECT jobid + FROM cron.job + WHERE schedule = '5 seconds' OR schedule = '1 seconds' OR schedule = '10 seconds' + ) + AND end_time < NOW() - interval '1 hour'; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_frequent_job_details"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_job_run_details_7days"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM cron.job_run_details WHERE end_time < NOW() - interval '7 days'; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_job_run_details_7days"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_old_audit_logs"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM "public"."audit_logs" + WHERE created_at < pg_catalog.now() - INTERVAL '90 days'; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_old_audit_logs"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_old_channel_devices"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + deleted_count bigint; + purged_count bigint; +BEGIN + -- Disable triggers on channel_devices to avoid unnecessary queue operations during bulk cleanup + -- This prevents the enqueue_channel_device_counts trigger from firing for each deleted row + ALTER TABLE public.channel_devices DISABLE TRIGGER channel_device_count_enqueue; + + -- Use nested block with exception handler to ensure trigger is re-enabled on any failure + BEGIN + -- Delete channel_devices where the last activity (updated_at or created_at) is older than 1 month + DELETE FROM public.channel_devices + WHERE COALESCE(updated_at, created_at) < NOW() - INTERVAL '1 month'; + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + + -- Re-enable triggers before any further operations + ALTER TABLE public.channel_devices ENABLE TRIGGER channel_device_count_enqueue; + + IF deleted_count > 0 THEN + RAISE NOTICE 'cleanup_old_channel_devices: Deleted % stale channel device entries', deleted_count; + + -- Purge any pending messages in the channel_device_counts queue before recomputing + -- This prevents stale deltas from being applied after the full recount + SELECT pgmq.purge_queue('channel_device_counts') INTO purged_count; + IF purged_count > 0 THEN + RAISE NOTICE 'cleanup_old_channel_devices: Purged % pending queue messages', purged_count; + END IF; + + -- Recalculate channel_device_count for all apps since we bypassed the trigger + -- This is more efficient than firing triggers for potentially thousands of rows + UPDATE public.apps + SET channel_device_count = COALESCE(( + SELECT COUNT(*) + FROM public.channel_devices cd + WHERE cd.app_id = apps.app_id + ), 0); + + RAISE NOTICE 'cleanup_old_channel_devices: Recalculated channel_device_count for all apps'; + END IF; + EXCEPTION WHEN OTHERS THEN + -- Ensure trigger is re-enabled even on failure + ALTER TABLE public.channel_devices ENABLE TRIGGER channel_device_count_enqueue; + RAISE; + END; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_old_channel_devices"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_preserve_setting text; + v_preserve_app_version_id bigint; +BEGIN + IF OLD.need_onboarding IS TRUE AND NEW.need_onboarding IS FALSE THEN + v_preserve_setting := current_setting('capgo.onboarding_preserve_app_version_id', true); + v_preserve_app_version_id := NULLIF(v_preserve_setting, '')::bigint; + + PERFORM public.clear_onboarding_app_data(NEW.id, v_preserve_app_version_id); + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_queue_messages"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $_$ +DECLARE + queue_name text; +BEGIN + -- Clean up messages older than 7 days FROM all queues + FOR queue_name IN ( + SELECT q.queue_name FROM pgmq.list_queues() q + ) LOOP + -- Delete archived messages older than 7 days + EXECUTE format('DELETE FROM pgmq.a_%I WHERE archived_at < $1', queue_name) + USING (NOW() - INTERVAL '7 days')::timestamptz; + + -- Delete failed messages that have been retried more than 5 times + EXECUTE format('DELETE FROM pgmq.q_%I WHERE read_ct > 5', queue_name); + END LOOP; +END; +$_$; + + +ALTER FUNCTION "public"."cleanup_queue_messages"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_tmp_users"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM "public"."tmp_users" + WHERE GREATEST(updated_at, created_at) < NOW() - INTERVAL '7 days'; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_tmp_users"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_webhook_deliveries"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM "public"."webhook_deliveries" + WHERE "created_at" < NOW() - INTERVAL '7 days'; +END; +$$; + + +ALTER FUNCTION "public"."cleanup_webhook_deliveries"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + PERFORM "public"."reset_onboarding_demo_app_data"(p_app_uuid); +END; +$$; + + +ALTER FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + -- This legacy helper used to delete broad app data. Keep the name for older + -- callers, but make it provenance-based so completing/resetting onboarding + -- can never wipe untracked production rows. + PERFORM p_preserve_app_version_id; + PERFORM "public"."reset_onboarding_demo_app_data"(p_app_uuid); +END; +$$; + + +ALTER FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cli_check_permission"("apikey" "text" DEFAULT NULL::"text", "permission_key" "text" DEFAULT NULL::"text", "org_id" "uuid" DEFAULT NULL::"uuid", "app_id" "text" DEFAULT NULL::"text", "channel_id" bigint DEFAULT NULL::bigint) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_request_apikey text; + v_api_key public.apikeys%ROWTYPE; +BEGIN + IF permission_key IS NULL OR permission_key = '' THEN + RETURN false; + END IF; + + SELECT public.get_apikey_header() INTO v_request_apikey; + + IF v_request_apikey IS NULL OR v_request_apikey = '' THEN + RETURN false; + END IF; + + IF apikey IS NOT NULL AND apikey <> '' AND apikey IS DISTINCT FROM v_request_apikey THEN + RETURN false; + END IF; + + SELECT * INTO v_api_key + FROM public.find_apikey_by_value(v_request_apikey) + LIMIT 1; + + IF v_api_key.id IS NULL THEN + RETURN false; + END IF; + + RETURN public.rbac_check_permission_direct( + permission_key, + v_api_key.user_id, + org_id, + app_id, + channel_id, + v_request_apikey + ); +END; +$$; + + +ALTER FUNCTION "public"."cli_check_permission"("apikey" "text", "permission_key" "text", "org_id" "uuid", "app_id" "text", "channel_id" bigint) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."cli_check_permission"("apikey" "text", "permission_key" "text", "org_id" "uuid", "app_id" "text", "channel_id" bigint) IS 'CLI permission wrapper bound to the request capgkey header. The apikey argument is retained for CLI compatibility and must match the header when provided.'; + + + +CREATE OR REPLACE FUNCTION "public"."convert_bytes_to_gb"("bytes_value" double precision) RETURNS double precision + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN bytes_value / 1024.0 / 1024.0 / 1024.0; +END; +$$; + + +ALTER FUNCTION "public"."convert_bytes_to_gb"("bytes_value" double precision) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."convert_bytes_to_mb"("bytes_value" double precision) RETURNS double precision + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN bytes_value / 1024.0 / 1024.0; +END; +$$; + + +ALTER FUNCTION "public"."convert_bytes_to_mb"("bytes_value" double precision) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."convert_gb_to_bytes"("gb" double precision) RETURNS double precision + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN gb * 1024 * 1024 * 1024; +END; +$$; + + +ALTER FUNCTION "public"."convert_gb_to_bytes"("gb" double precision) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."convert_mb_to_bytes"("gb" double precision) RETURNS double precision + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN gb * 1024 * 1024; +END; +$$; + + +ALTER FUNCTION "public"."convert_mb_to_bytes"("gb" double precision) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."convert_number_to_percent"("val" double precision, "max_val" double precision) RETURNS double precision + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + percentage numeric; +BEGIN + IF max_val = 0 THEN + RETURN 0; + ELSE + percentage := ((val * 100) / max_val)::numeric; + -- Add small epsilon for positive values to handle floating-point errors + -- Subtract epsilon for negative values + IF percentage >= 0 THEN + RETURN trunc(percentage + 0.0001, 0); + ELSE + RETURN trunc(percentage - 0.0001, 0); + END IF; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."convert_number_to_percent"("val" double precision, "max_val" double precision) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."count_active_users"("app_ids" character varying[]) RETURNS integer + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN ( + SELECT COUNT(DISTINCT user_id) + FROM public.apps + WHERE app_id = ANY(app_ids) + ); +END; +$$; + + +ALTER FUNCTION "public"."count_active_users"("app_ids" character varying[]) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."count_all_need_upgrade"() RETURNS integer + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (SELECT COUNT(*) FROM public.stripe_info WHERE is_good_plan = false AND status = 'succeeded'); +END; +$$; + + +ALTER FUNCTION "public"."count_all_need_upgrade"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."count_all_onboarded"() RETURNS integer + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (SELECT COUNT(DISTINCT owner_org) FROM public.apps); +END; +$$; + + +ALTER FUNCTION "public"."count_all_onboarded"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."count_all_plans_v2"() RETURNS TABLE("plan_name" character varying, "count" bigint) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + WITH ActiveSubscriptions AS ( + SELECT DISTINCT ON (si.customer_id) + p.name AS product_name, + si.customer_id + FROM public.stripe_info si + INNER JOIN public.plans p ON si.product_id = p.stripe_id + WHERE si.status = 'succeeded' + ORDER BY si.customer_id, si.created_at DESC + ), + TrialUsers AS ( + SELECT DISTINCT ON (si.customer_id) + 'Trial' AS product_name, + si.customer_id + FROM public.stripe_info si + WHERE si.trial_at > NOW() + AND si.status is NULL + AND NOT EXISTS ( + SELECT 1 FROM ActiveSubscriptions a + WHERE a.customer_id = si.customer_id + ) + ) + SELECT + product_name as plan_name, + COUNT(*) as count + FROM ( + SELECT product_name, customer_id FROM ActiveSubscriptions + UNION ALL + SELECT product_name, customer_id FROM TrialUsers + ) all_subs + GROUP BY product_name; +END; +$$; + + +ALTER FUNCTION "public"."count_all_plans_v2"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."count_non_compliant_bundles"("org_id" "uuid", "required_key" "text" DEFAULT NULL::"text") RETURNS TABLE("non_encrypted_count" bigint, "wrong_key_count" bigint, "total_non_compliant" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + non_encrypted bigint := 0; + wrong_key bigint := 0; + caller_user_id uuid; + api_key_text text; +BEGIN + SELECT public.get_identity('{read,upload,write,all}'::public.key_mode[]) INTO caller_user_id; + SELECT public.get_apikey_header() INTO api_key_text; + + IF caller_user_id IS NULL THEN + RAISE EXCEPTION 'Unauthorized: Authentication required'; + END IF; + + -- org.delete is the RBAC/legacy super_admin-equivalent org gate. Using it + -- preserves the previous super_admin-only requirement for this org-wide scan. + IF NOT public.rbac_check_permission_direct( + public.rbac_perm_org_delete(), + caller_user_id, + count_non_compliant_bundles.org_id, + NULL::character varying, + NULL::bigint, + api_key_text + ) THEN + RAISE EXCEPTION 'Unauthorized: Only super_admin can access this function'; + END IF; + + SELECT COUNT(*) INTO non_encrypted + FROM public.app_versions av + INNER JOIN public.apps a ON a.app_id = av.app_id + WHERE a.owner_org = count_non_compliant_bundles.org_id + AND av.deleted = false + AND (av.session_key IS NULL OR av.session_key = ''); + + IF required_key IS NOT NULL AND required_key <> '' THEN + SELECT COUNT(*) INTO wrong_key + FROM public.app_versions av + INNER JOIN public.apps a ON a.app_id = av.app_id + WHERE a.owner_org = count_non_compliant_bundles.org_id + AND av.deleted = false + AND av.session_key IS NOT NULL + AND av.session_key <> '' + AND ( + av.key_id IS NULL + OR av.key_id = '' + -- key_id can store either the 20-char required_key prefix or the full key, so accept both match directions. + OR NOT (av.key_id = LEFT(required_key, 20) OR LEFT(av.key_id, LENGTH(required_key)) = required_key) + ); + END IF; + + RETURN QUERY SELECT non_encrypted, wrong_key, (non_encrypted + wrong_key); +END; +$$; + + +ALTER FUNCTION "public"."count_non_compliant_bundles"("org_id" "uuid", "required_key" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."current_request_role"() RETURNS "text" + LANGUAGE "sql" STABLE + SET "search_path" TO '' + AS $$ + SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.role', true), ''), + NULLIF((SELECT auth.jwt() ->> 'role'), ''), + NULLIF(current_setting('role', true), ''), + '' + ) +$$; + + +ALTER FUNCTION "public"."current_request_role"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."delete_accounts_marked_for_deletion"() RETURNS TABLE("deleted_count" integer, "deleted_user_ids" "uuid"[]) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + account_record RECORD; + org_record RECORD; + deleted_users UUID[] := ARRAY[]::UUID[]; + total_deleted INTEGER := 0; + other_super_admins_count INTEGER; + replacement_owner_id UUID; +BEGIN + -- Loop through all accounts marked for deletion where removal_date has passed + FOR account_record IN + SELECT "account_id", "removal_date", "removed_data" + FROM "public"."to_delete_accounts" + WHERE "removal_date" < NOW() + LOOP + BEGIN + -- Process each org the user belongs to + FOR org_record IN + SELECT DISTINCT "org_id", "user_right" + FROM "public"."org_users" + WHERE "user_id" = account_record.account_id + LOOP + -- Reset replacement_owner_id for each org + replacement_owner_id := NULL; + + -- Check if user is a super_admin in this org + IF org_record.user_right = 'super_admin'::"public"."user_min_right" THEN + -- Count other super_admins in this org (excluding the user being deleted) + SELECT COUNT(*) INTO other_super_admins_count + FROM "public"."org_users" + WHERE "org_id" = org_record.org_id + AND "user_id" != account_record.account_id + AND "user_right" = 'super_admin'::"public"."user_min_right"; + + IF other_super_admins_count = 0 THEN + -- User is the last super_admin: DELETE all org resources + RAISE NOTICE 'User % is last super_admin of org %. Deleting all org resources.', + account_record.account_id, org_record.org_id; + + -- Delete deploy_history for this org + DELETE FROM "public"."deploy_history" WHERE "owner_org" = org_record.org_id; + + -- Delete channel_devices for this org + DELETE FROM "public"."channel_devices" WHERE "owner_org" = org_record.org_id; + + -- Delete channels for this org + DELETE FROM "public"."channels" WHERE "owner_org" = org_record.org_id; + + -- Delete app_versions for this org + DELETE FROM "public"."app_versions" WHERE "owner_org" = org_record.org_id; + + -- Delete apps for this org + DELETE FROM "public"."apps" WHERE "owner_org" = org_record.org_id; + + -- Delete the org itself since user is last super_admin + -- Note: audit_logs will be cascade deleted with the org + DELETE FROM "public"."orgs" WHERE "id" = org_record.org_id; + + -- Skip ownership transfer since all resources are deleted + CONTINUE; + END IF; + END IF; + + -- If we reach here, we need to transfer ownership (either non-super_admin or non-last super_admin) + -- Find a super_admin to transfer ownership to + SELECT "user_id" INTO replacement_owner_id + FROM "public"."org_users" + WHERE "org_id" = org_record.org_id + AND "user_id" != account_record.account_id + AND "user_right" = 'super_admin'::"public"."user_min_right" + LIMIT 1; + + IF replacement_owner_id IS NOT NULL THEN + RAISE NOTICE 'Transferring ownership from user % to user % in org %', + account_record.account_id, replacement_owner_id, org_record.org_id; + + -- Transfer app ownership + UPDATE "public"."apps" + SET "user_id" = replacement_owner_id, "updated_at" = NOW() + WHERE "user_id" = account_record.account_id AND "owner_org" = org_record.org_id; + + -- Transfer app_versions ownership + UPDATE "public"."app_versions" + SET "user_id" = replacement_owner_id, "updated_at" = NOW() + WHERE "user_id" = account_record.account_id AND "owner_org" = org_record.org_id; + + -- Transfer channels ownership + UPDATE "public"."channels" + SET "created_by" = replacement_owner_id, "updated_at" = NOW() + WHERE "created_by" = account_record.account_id AND "owner_org" = org_record.org_id; + + -- Transfer deploy_history ownership + UPDATE "public"."deploy_history" + SET "created_by" = replacement_owner_id, "updated_at" = NOW() + WHERE "created_by" = account_record.account_id AND "owner_org" = org_record.org_id; + + -- Transfer org ownership if user created it + UPDATE "public"."orgs" + SET "created_by" = replacement_owner_id, "updated_at" = NOW() + WHERE "id" = org_record.org_id AND "created_by" = account_record.account_id; + + -- Transfer audit_logs ownership + UPDATE "public"."audit_logs" + SET "user_id" = replacement_owner_id + WHERE "user_id" = account_record.account_id AND "org_id" = org_record.org_id; + ELSE + RAISE WARNING 'No super_admin found to transfer ownership in org % for user %', + org_record.org_id, account_record.account_id; + END IF; + END LOOP; + + -- Delete from public.users table + DELETE FROM "public"."users" WHERE "id" = account_record.account_id; + + -- Delete from auth.users table + DELETE FROM "auth"."users" WHERE "id" = account_record.account_id; + + -- Remove from to_delete_accounts table + DELETE FROM "public"."to_delete_accounts" WHERE "account_id" = account_record.account_id; + + -- Track the deleted user + deleted_users := "array_append"(deleted_users, account_record.account_id); + total_deleted := total_deleted + 1; + + -- Log the deletion + RAISE NOTICE 'Successfully deleted account: % (removal date: %)', + account_record.account_id, account_record.removal_date; + + EXCEPTION + WHEN OTHERS THEN + -- Log the error but continue with other accounts + RAISE WARNING 'Failed to delete account %: %', account_record.account_id, SQLERRM; + END; + END LOOP; + + -- Return results + deleted_count := total_deleted; + deleted_user_ids := deleted_users; + RETURN NEXT; + + RAISE NOTICE 'Deletion process completed. Total accounts deleted: %', total_deleted; +END; +$$; + + +ALTER FUNCTION "public"."delete_accounts_marked_for_deletion"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."delete_group_with_bindings"("group_id" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_id uuid; +BEGIN + -- Verify group exists and caller has org.update_user_roles permission. + SELECT org_id INTO v_org_id + FROM public.groups + WHERE id = group_id; + + IF v_org_id IS NULL THEN + RAISE EXCEPTION 'Group not found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT public.rbac_check_permission_direct( + public.rbac_perm_org_update_user_roles(), + auth.uid(), + v_org_id, + NULL::varchar, + NULL::bigint + ) THEN + RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501'; + END IF; + + DELETE FROM public.role_bindings + WHERE principal_type = public.rbac_principal_group() + AND principal_id = group_id; + + + -- Clean up channel permission overrides for this group + DELETE FROM public.channel_permission_overrides + WHERE principal_type = public.rbac_principal_group() + AND principal_id = group_id; + DELETE FROM public.groups + WHERE id = group_id; +END; +$$; + + +ALTER FUNCTION "public"."delete_group_with_bindings"("group_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."delete_group_with_bindings"("group_id" "uuid") IS 'Atomically deletes a group and all its role bindings. Requires org.update_user_roles permission.'; + + + +CREATE OR REPLACE FUNCTION "public"."delete_http_response"("request_id" bigint) RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM net._http_response + WHERE id = request_id; +END; +$$; + + +ALTER FUNCTION "public"."delete_http_response"("request_id" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."delete_non_compliant_bundles"("org_id" "uuid", "required_key" "text" DEFAULT NULL::"text") RETURNS bigint + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + deleted_count bigint := 0; + bundle_ids bigint[]; + caller_user_id uuid; + api_key_text text; +BEGIN + SELECT public.get_identity('{read,upload,write,all}'::public.key_mode[]) INTO caller_user_id; + SELECT public.get_apikey_header() INTO api_key_text; + + IF caller_user_id IS NULL THEN + RAISE EXCEPTION 'Unauthorized: Authentication required'; + END IF; + + -- org.delete is the RBAC/legacy super_admin-equivalent org gate. Using it + -- preserves the previous super_admin-only requirement for this destructive cleanup. + IF NOT public.rbac_check_permission_direct( + public.rbac_perm_org_delete(), + caller_user_id, + delete_non_compliant_bundles.org_id, + NULL::character varying, + NULL::bigint, + api_key_text + ) THEN + RAISE EXCEPTION 'Unauthorized: Only super_admin can access this function'; + END IF; + + IF required_key IS NULL OR required_key = '' THEN + SELECT ARRAY_AGG(av.id) INTO bundle_ids + FROM public.app_versions av + INNER JOIN public.apps a ON a.app_id = av.app_id + WHERE a.owner_org = delete_non_compliant_bundles.org_id + AND av.deleted = false + AND (av.session_key IS NULL OR av.session_key = ''); + ELSE + SELECT ARRAY_AGG(av.id) INTO bundle_ids + FROM public.app_versions av + INNER JOIN public.apps a ON a.app_id = av.app_id + WHERE a.owner_org = delete_non_compliant_bundles.org_id + AND av.deleted = false + AND ( + (av.session_key IS NULL OR av.session_key = '') + OR ( + av.session_key IS NOT NULL + AND av.session_key <> '' + AND ( + av.key_id IS NULL + OR av.key_id = '' + -- key_id can store either the 20-char required_key prefix or the full key, so accept both match directions. + OR NOT (av.key_id = LEFT(required_key, 20) OR LEFT(av.key_id, LENGTH(required_key)) = required_key) + ) + ) + ); + END IF; + + IF bundle_ids IS NOT NULL AND array_length(bundle_ids, 1) > 0 THEN + UPDATE public.app_versions + SET deleted = true + WHERE id = ANY(bundle_ids); + + deleted_count := array_length(bundle_ids, 1); + + PERFORM public.pg_log('action: DELETED_NON_COMPLIANT_BUNDLES', + jsonb_build_object( + 'org_id', org_id, + 'required_key', required_key, + 'deleted_count', deleted_count, + 'bundle_ids', bundle_ids, + 'caller_user_id', caller_user_id + )); + END IF; + + RETURN deleted_count; +END; +$$; + + +ALTER FUNCTION "public"."delete_non_compliant_bundles"("org_id" "uuid", "required_key" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."delete_old_deleted_apps"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM "public"."deleted_apps" + WHERE deleted_at < NOW() - INTERVAL '35 days'; +END; +$$; + + +ALTER FUNCTION "public"."delete_old_deleted_apps"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."delete_old_deleted_versions"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + deleted_count bigint; +BEGIN + DELETE FROM "public"."app_versions" + WHERE "app_versions"."deleted" = true + AND "app_versions"."deleted_at" IS NOT NULL + AND "app_versions"."deleted_at" <= pg_catalog.now() - INTERVAL '90 days' + AND "app_versions"."name" NOT IN ('builtin', 'unknown') + AND "app_versions"."manifest_count" = 0 + AND ( + "app_versions"."r2_path" IS NULL + OR EXISTS ( + SELECT 1 + FROM "public"."app_versions_meta" + WHERE "app_versions_meta"."id" = "app_versions"."id" + AND "app_versions_meta"."size" = 0 + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM "public"."channels" + WHERE "channels"."version" = "app_versions"."id" + ); + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + + IF deleted_count > 0 THEN + RAISE NOTICE 'delete_old_deleted_versions: permanently deleted % app versions', deleted_count; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."delete_old_deleted_versions"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."delete_old_deleted_versions"() IS 'Permanently deletes app_versions that have been soft-deleted for at least 90 days after storage cleanup is reflected in app_versions_meta and app_versions.manifest_count.'; + + + +CREATE OR REPLACE FUNCTION "public"."delete_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_created_by uuid; +BEGIN + -- Check if user has permission to update roles + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), p_org_id, NULL, NULL) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + + -- Get org owner to prevent removing the last super admin + SELECT created_by INTO v_org_created_by + FROM public.orgs + WHERE id = p_org_id; + + -- Prevent removing the org owner + IF p_user_id = v_org_created_by THEN + RAISE EXCEPTION 'CANNOT_CHANGE_OWNER_ROLE'; + END IF; + + -- Check if removing a super_admin and if this is the last super_admin + IF EXISTS ( + SELECT 1 + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.principal_id = p_user_id + AND rb.principal_type = public.rbac_principal_user() + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id = p_org_id + AND r.name = public.rbac_role_org_super_admin() + ) THEN + IF ( + SELECT COUNT(*) + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.scope_type = public.rbac_scope_org() + AND rb.org_id = p_org_id + AND rb.principal_type = public.rbac_principal_user() + AND r.name = public.rbac_role_org_super_admin() + ) <= 1 THEN + RAISE EXCEPTION 'CANNOT_REMOVE_LAST_SUPER_ADMIN'; + END IF; + END IF; + + -- Delete ALL role bindings for this user in this org (org, app, and channel scopes) + -- to prevent orphaned app/channel bindings after org-level removal + DELETE FROM public.role_bindings + WHERE principal_id = p_user_id + AND principal_type = public.rbac_principal_user() + AND org_id = p_org_id; + + RETURN 'OK'; +END; +$$; + + +ALTER FUNCTION "public"."delete_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."delete_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid") IS 'Deletes all of an organization member''s role bindings (org, app, and channel scopes). Requires org.update_user_roles permission. Returns OK on success.'; + + + +CREATE OR REPLACE FUNCTION "public"."delete_user"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + user_id_fn uuid; + user_email text; + old_record_json jsonb; + last_sign_in_at_ts timestamptz; + did_schedule integer; +BEGIN + SELECT "auth"."uid"() INTO user_id_fn; + IF user_id_fn IS NULL THEN + RAISE EXCEPTION 'not_authenticated' USING ERRCODE = '42501'; + END IF; + + SELECT "email", "last_sign_in_at" + INTO user_email, last_sign_in_at_ts + FROM "auth"."users" + WHERE "id" = user_id_fn; + + -- Require proof of email ownership from the custom email OTP flow rather than + -- relying on Supabase auth email_confirmed_at, which may be auto-populated. + IF NOT "public"."is_recent_email_otp_verified"(user_id_fn) THEN + RAISE EXCEPTION 'email_not_verified' USING ERRCODE = 'P0003'; + END IF; + + IF last_sign_in_at_ts IS NULL OR last_sign_in_at_ts < NOW() - INTERVAL '5 minutes' THEN + RAISE EXCEPTION 'reauth_required' USING ERRCODE = 'P0001'; + END IF; + + SELECT row_to_json(u)::jsonb INTO old_record_json + FROM ( + SELECT * + FROM "public"."users" + WHERE id = user_id_fn + ) AS u; + + IF old_record_json IS NULL THEN + RAISE EXCEPTION 'user_not_found' USING ERRCODE = 'P0002'; + END IF; + + INSERT INTO "public"."to_delete_accounts" ( + "account_id", + "removal_date", + "removed_data" + ) VALUES + ( + user_id_fn, + NOW() + INTERVAL '30 days', + "jsonb_build_object"('email', user_email, 'apikeys', COALESCE((SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn), '[]'::jsonb)) + ) + ON CONFLICT ("account_id") DO NOTHING + RETURNING 1 INTO did_schedule; + + IF did_schedule IS NULL THEN + RETURN; + END IF; + + PERFORM "pgmq"."send"( + 'on_user_delete'::text, + "jsonb_build_object"( + 'payload', "jsonb_build_object"( + 'old_record', old_record_json, + 'table', 'users', + 'type', 'DELETE' + ), + 'function_name', 'on_user_delete' + ) + ); + + DELETE FROM "public"."apikeys" WHERE "public"."apikeys"."user_id" = user_id_fn; +END; +$$; + + +ALTER FUNCTION "public"."delete_user"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."enforce_apikey_expiration_policy"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + scoped_org record; +BEGIN + IF TG_OP = 'UPDATE' + AND NEW.expires_at IS NOT DISTINCT FROM OLD.expires_at THEN + RETURN NEW; + END IF; + + FOR scoped_org IN + SELECT DISTINCT + public.orgs.id, + public.orgs.require_apikey_expiration, + public.orgs.max_apikey_expiration_days + FROM public.role_bindings + JOIN public.orgs ON public.orgs.id = public.role_bindings.org_id + WHERE public.role_bindings.principal_type = public.rbac_principal_apikey() + AND public.role_bindings.principal_id = NEW.rbac_id + AND public.role_bindings.org_id IS NOT NULL + LOOP + IF scoped_org.require_apikey_expiration AND NEW.expires_at IS NULL THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'expiration_required', + DETAIL = 'This organization requires API keys to have an expiration date'; + END IF; + + IF scoped_org.max_apikey_expiration_days IS NOT NULL + AND NEW.expires_at IS NOT NULL + AND NEW.expires_at > clock_timestamp() + make_interval(days => scoped_org.max_apikey_expiration_days) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'expiration_exceeds_max', + DETAIL = format('API key expiration cannot exceed %s days for this organization', scoped_org.max_apikey_expiration_days); + END IF; + END LOOP; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."enforce_apikey_expiration_policy"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key_row public.apikeys%ROWTYPE; + scoped_org record; +BEGIN + IF NEW.principal_type <> public.rbac_principal_apikey() + OR NEW.org_id IS NULL + OR (NEW.expires_at IS NOT NULL AND NEW.expires_at <= now()) THEN + RETURN NEW; + END IF; + + SELECT * + INTO api_key_row + FROM public.apikeys + WHERE public.apikeys.rbac_id = NEW.principal_id + LIMIT 1; + + IF api_key_row.id IS NULL THEN + RETURN NEW; + END IF; + + SELECT + public.orgs.id, + public.orgs.require_apikey_expiration, + public.orgs.max_apikey_expiration_days + INTO scoped_org + FROM public.orgs + WHERE public.orgs.id = NEW.org_id + LIMIT 1; + + IF scoped_org.id IS NULL THEN + RETURN NEW; + END IF; + + IF scoped_org.require_apikey_expiration AND api_key_row.expires_at IS NULL THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'expiration_required', + DETAIL = 'This organization requires API keys to have an expiration date'; + END IF; + + IF scoped_org.max_apikey_expiration_days IS NOT NULL + AND api_key_row.expires_at IS NOT NULL + AND api_key_row.expires_at > clock_timestamp() + make_interval(days => scoped_org.max_apikey_expiration_days) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'expiration_exceeds_max', + DETAIL = format('API key expiration cannot exceed %s days for this organization', scoped_org.max_apikey_expiration_days); + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."enforce_channel_version_promotion_permission"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_request_role text := COALESCE(auth.role(), session_user); +BEGIN + IF NEW.version IS NOT DISTINCT FROM OLD.version THEN + RETURN NEW; + END IF; + + IF v_request_role IN ('service_role', 'postgres') THEN + RETURN NEW; + END IF; + + IF v_request_role IS DISTINCT FROM 'anon' AND v_request_role IS DISTINCT FROM 'authenticated' THEN + RAISE EXCEPTION 'PERMISSION_DENIED_CHANNEL_PROMOTE_BUNDLE' + USING ERRCODE = '42501'; + END IF; + + IF NOT public.rbac_check_permission_request( + public.rbac_perm_channel_promote_bundle(), + OLD.owner_org, + OLD.app_id, + OLD.id + ) THEN + RAISE EXCEPTION 'PERMISSION_DENIED_CHANNEL_PROMOTE_BUNDLE' + USING ERRCODE = '42501'; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."enforce_channel_version_promotion_permission"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."enforce_email_otp_for_mfa"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + otp_ok boolean; + enforced_at timestamptz; + user_created_at timestamptz; +BEGIN + enforced_at := public.get_mfa_email_otp_enforced_at(); + + IF enforced_at IS NOT NULL THEN + SELECT auth.users.created_at + INTO user_created_at + FROM auth.users + WHERE auth.users.id = NEW.user_id; + + IF user_created_at IS NOT NULL AND user_created_at < enforced_at THEN + RETURN NEW; + END IF; + END IF; + + IF TG_OP = 'INSERT' THEN + otp_ok := public.is_recent_email_otp_verified(NEW.user_id); + IF NOT otp_ok THEN + RAISE EXCEPTION 'email otp verification required for mfa enrollment'; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'UPDATE' + AND (NEW.status IS DISTINCT FROM OLD.status) + AND NEW.status = 'verified' THEN + otp_ok := public.is_recent_email_otp_verified(NEW.user_id); + IF NOT otp_ok THEN + RAISE EXCEPTION 'email otp verification required for mfa enrollment'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."enforce_email_otp_for_mfa"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."enforce_role_binding_role_scope"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + v_role_scope_type text; +BEGIN + SELECT r.scope_type + INTO v_role_scope_type + FROM public.roles r + WHERE r.id = NEW.role_id + LIMIT 1; + + IF v_role_scope_type IS NULL THEN + RETURN NEW; + END IF; + + IF v_role_scope_type <> NEW.scope_type THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'ROLE_SCOPE_MISMATCH'; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."enforce_role_binding_role_scope"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."enforce_role_binding_role_scope"() IS 'Rejects role_bindings writes where the bound role family does not match the binding scope_type.'; + + + +CREATE OR REPLACE FUNCTION "public"."enqueue_channel_device_counts"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_delta integer; + v_app_id text; + v_owner uuid; + v_device text; +BEGIN + IF TG_OP = 'INSERT' THEN + v_delta := 1; + v_app_id := NEW.app_id; + v_owner := NEW.owner_org; + v_device := NEW.device_id; + ELSIF TG_OP = 'DELETE' THEN + v_delta := -1; + v_app_id := OLD.app_id; + v_owner := OLD.owner_org; + v_device := OLD.device_id; + ELSE + RETURN NEW; + END IF; + + PERFORM pgmq.send( + 'channel_device_counts', + jsonb_build_object( + 'app_id', v_app_id, + 'owner_org', v_owner, + 'device_id', v_device, + 'delta', v_delta + ) + ); + + RETURN COALESCE(NEW, OLD); +END; +$$; + + +ALTER FUNCTION "public"."enqueue_channel_device_counts"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."enqueue_credit_usage_alert"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_total numeric := 0; + v_available numeric := 0; + v_available_before numeric := 0; + v_percent_after numeric := 0; + v_percent_before numeric := 0; + v_threshold integer; + v_alert_cycle integer; + v_occurred_at timestamptz := COALESCE(NEW.occurred_at, NOW()); +BEGIN + IF TG_OP <> 'INSERT' THEN + RETURN COALESCE(NEW, OLD); + END IF; + + IF NEW.amount IS NULL OR NEW.amount >= 0 THEN + RETURN NEW; + END IF; + + SELECT + COALESCE(total_credits, 0), + COALESCE(available_credits, 0) + INTO v_total, v_available + FROM public.usage_credit_balances + WHERE org_id = NEW.org_id; + + v_available := GREATEST(COALESCE(NEW.balance_after, v_available, 0), 0); + + IF v_total <= 0 THEN + RETURN NEW; + END IF; + + v_available_before := GREATEST(v_available - NEW.amount, 0); + IF v_available_before > v_total THEN + v_available_before := v_total; + END IF; + + v_percent_after := LEAST(GREATEST(((v_total - v_available) / v_total) * 100, 0), 100); + v_percent_before := LEAST(GREATEST(((v_total - v_available_before) / v_total) * 100, 0), 100); + + v_alert_cycle := (date_part('year', v_occurred_at)::int * 100) + date_part('month', v_occurred_at)::int; + + FOREACH v_threshold IN ARRAY ARRAY [50, 75, 90, 100] + LOOP + IF v_percent_after >= v_threshold AND v_percent_before < v_threshold THEN + PERFORM pgmq.send( + 'credit_usage_alerts', + jsonb_build_object( + 'function_name', 'credit_usage_alerts', + 'function_type', NULL, + 'payload', jsonb_build_object( + 'org_id', NEW.org_id, + 'threshold', v_threshold, + 'percent_used', ROUND(v_percent_after, 2), + 'total_credits', v_total, + 'available_credits', v_available, + 'alert_cycle', v_alert_cycle, + 'transaction_id', NEW.id + ) + ) + ); + END IF; + END LOOP; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."enqueue_credit_usage_alert"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."exist_app_v2"("appid" character varying) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key text; +BEGIN + IF session_user IN ('postgres', 'service_role') THEN + RETURN (SELECT EXISTS (SELECT 1 + FROM public.apps + WHERE app_id = appid)); + END IF; + + SELECT public.get_apikey_header() INTO api_key; + + IF api_key IS NULL OR api_key = '' THEN + RETURN false; + END IF; + + IF NOT public.is_allowed_capgkey(api_key, '{read,upload,write,all}'::"public"."key_mode"[], appid) THEN + RETURN false; + END IF; + + RETURN (SELECT EXISTS (SELECT 1 + FROM public.apps + WHERE app_id = appid)); +END; +$$; + + +ALTER FUNCTION "public"."exist_app_v2"("appid" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.exist_app_versions( + exist_app_versions.appid, + exist_app_versions.name_version, + public.get_apikey_header() + ); +END; +$$; + + +ALTER FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_id uuid; + v_request_role text; + v_user_id uuid; + v_api_key text; +BEGIN + SELECT owner_org + INTO v_org_id + FROM public.apps + WHERE app_id = exist_app_versions.appid + LIMIT 1; + + IF v_org_id IS NULL THEN + RETURN false; + END IF; + + SELECT public.current_request_role() + INTO v_request_role; + + IF public.is_internal_request_role(v_request_role) THEN + RETURN ( + SELECT EXISTS ( + SELECT 1 + FROM public.app_versions + WHERE app_id = exist_app_versions.appid + AND name = exist_app_versions.name_version + AND owner_org = v_org_id + ) + ); + END IF; + + SELECT auth.uid() + INTO v_user_id; + + v_api_key := exist_app_versions.apikey; + + IF v_api_key = '' THEN + v_api_key := NULL; + END IF; + + IF v_api_key IS NULL THEN + SELECT public.get_apikey_header() + INTO v_api_key; + END IF; + + IF v_user_id IS NULL AND v_api_key IS NULL THEN + RETURN false; + END IF; + + IF public.rbac_check_permission_direct( + public.rbac_perm_app_read_bundles(), + v_user_id, + v_org_id, + exist_app_versions.appid, + NULL::bigint, + v_api_key + ) IS NOT TRUE THEN + RETURN false; + END IF; + + RETURN ( + SELECT EXISTS ( + SELECT 1 + FROM public.app_versions + WHERE app_id = exist_app_versions.appid + AND name = exist_app_versions.name_version + AND owner_org = v_org_id + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."expire_usage_credits"() RETURNS bigint + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + grant_rec public.usage_credit_grants%ROWTYPE; + credits_to_expire numeric; + balance_after numeric; + expired_count bigint := 0; +BEGIN + FOR grant_rec IN + SELECT * + FROM public.usage_credit_grants + WHERE expires_at < NOW() + AND credits_total > credits_consumed + ORDER BY expires_at ASC + FOR UPDATE + LOOP + credits_to_expire := grant_rec.credits_total - grant_rec.credits_consumed; + + UPDATE public.usage_credit_grants + SET credits_consumed = credits_total + WHERE id = grant_rec.id; + + SELECT COALESCE(SUM(GREATEST(credits_total - credits_consumed, 0)), 0) + INTO balance_after + FROM public.usage_credit_grants + WHERE org_id = grant_rec.org_id + AND expires_at >= NOW(); + + INSERT INTO public.usage_credit_transactions ( + org_id, + grant_id, + transaction_type, + amount, + balance_after, + occurred_at, + description, + source_ref + ) + VALUES ( + grant_rec.org_id, + grant_rec.id, + 'expiry', + -credits_to_expire, + balance_after, + NOW(), + 'Expired usage credits', + jsonb_build_object('reason', 'expiry', 'expires_at', grant_rec.expires_at) + ); + + expired_count := expired_count + 1; + END LOOP; + + RETURN expired_count; +END; +$$; + + +ALTER FUNCTION "public"."expire_usage_credits"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."find_apikey_by_value"("key_value" "text") RETURNS SETOF "public"."apikeys" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + apikey_row public.apikeys%ROWTYPE; + key_value_hash text; +BEGIN + IF key_value IS NULL OR key_value = '' THEN + RETURN; + END IF; + + key_value_hash := encode(extensions.digest(key_value, 'sha256'), 'hex'); + + SELECT public.apikeys.* + INTO apikey_row + FROM public.apikeys + WHERE public.apikeys.key_hash = key_value_hash + LIMIT 1; + + IF apikey_row.id IS NULL THEN + SELECT public.apikeys.* + INTO apikey_row + FROM public.apikeys + WHERE public.apikeys.key = key_value + LIMIT 1; + END IF; + + IF apikey_row.id IS NULL THEN + RETURN; + END IF; + + IF NOT public.check_apikey_hashed_key_enforcement(apikey_row) THEN + RETURN; + END IF; + + RETURN NEXT apikey_row; +END; +$$; + + +ALTER FUNCTION "public"."find_apikey_by_value"("key_value" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") IS 'Resolves an API key by hashed key first and legacy plain key second. The two-step lookup keeps API-key RLS checks on indexed paths instead of a broad OR predicate.'; + + + +CREATE OR REPLACE FUNCTION "public"."find_best_plan_v3"("mau" bigint, "bandwidth" double precision, "storage" double precision, "build_time_unit" bigint DEFAULT 0) RETURNS character varying + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN ( + SELECT name + FROM public.plans + WHERE ( + plans.mau >= find_best_plan_v3.mau + AND plans.storage >= find_best_plan_v3.storage + AND plans.bandwidth >= find_best_plan_v3.bandwidth + AND plans.build_time_unit >= find_best_plan_v3.build_time_unit + ) OR plans.name = 'Enterprise' + ORDER BY plans.mau + LIMIT 1 + ); +END; +$$; + + +ALTER FUNCTION "public"."find_best_plan_v3"("mau" bigint, "bandwidth" double precision, "storage" double precision, "build_time_unit" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."find_fit_plan_v3"("mau" bigint, "bandwidth" bigint, "storage" bigint, "build_time_unit" bigint DEFAULT 0) RETURNS TABLE("name" character varying) + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY (SELECT plans.name FROM public.plans + WHERE plans.mau >= find_fit_plan_v3.mau AND plans.storage >= find_fit_plan_v3.storage + AND plans.bandwidth >= find_fit_plan_v3.bandwidth AND plans.build_time_unit >= find_fit_plan_v3.build_time_unit + OR plans.name = 'Enterprise' + ORDER BY plans.mau); +END; +$$; + + +ALTER FUNCTION "public"."find_fit_plan_v3"("mau" bigint, "bandwidth" bigint, "storage" bigint, "build_time_unit" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."force_valid_user_id_on_app"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$BEGIN + NEW.user_id = (SELECT created_by FROM public.orgs WHERE id = (NEW."owner_org")); + + RETURN NEW; +END;$$; + + +ALTER FUNCTION "public"."force_valid_user_id_on_app"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."generate_org_on_user_create"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_record record; + has_sso boolean; + user_provider text; +BEGIN + SELECT raw_app_meta_data->>'provider' + INTO user_provider + FROM auth.users + WHERE id = NEW.id; + + SELECT EXISTS ( + SELECT 1 FROM public.sso_providers sp + JOIN public.orgs o ON o.id = sp.org_id + WHERE sp.domain = lower(btrim(split_part(NEW.email, '@', 2))) + AND sp.status = 'active' + ) INTO has_sso; + + -- Skip org creation only for genuine SAML SSO logins on SSO-managed domains. + IF NOT (user_provider ~ '^sso:' AND has_sso) THEN + INSERT INTO public.orgs (created_by, name, management_email) values (NEW.id, format('%s organization', NEW.first_name), NEW.email) RETURNING * INTO org_record; + END IF; + + RETURN NEW; +END $$; + + +ALTER FUNCTION "public"."generate_org_on_user_create"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + solo_plan_stripe_id VARCHAR; + pending_customer_id VARCHAR; + trial_at_date TIMESTAMPTZ; +BEGIN + INSERT INTO public.org_users (user_id, org_id, user_right) + VALUES (NEW.created_by, NEW.id, 'super_admin'::"public"."user_min_right"); + + IF NEW.customer_id IS NOT NULL THEN + RETURN NEW; + END IF; + + SELECT stripe_id INTO solo_plan_stripe_id + FROM public.plans + WHERE name = 'Solo' + LIMIT 1; + + IF solo_plan_stripe_id IS NULL THEN + RAISE WARNING 'Solo plan not found, skipping sync stripe_info creation for org %', NEW.id; + RETURN NEW; + END IF; + + pending_customer_id := 'pending_' || NEW.id::text; + trial_at_date := NOW() + INTERVAL '15 days'; + + INSERT INTO public.stripe_info ( + customer_id, + product_id, + trial_at, + status, + is_good_plan + ) VALUES ( + pending_customer_id, + solo_plan_stripe_id, + trial_at_date, + NULL, + true + ); + + UPDATE public.orgs + SET customer_id = pending_customer_id + WHERE id = NEW.id; + + RETURN NEW; +END $$; + + +ALTER FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."apps" ( + "created_at" timestamp with time zone DEFAULT "now"(), + "app_id" character varying NOT NULL, + "icon_url" character varying NOT NULL, + "user_id" "uuid", + "name" character varying, + "last_version" character varying, + "updated_at" timestamp with time zone, + "id" "uuid" DEFAULT "gen_random_uuid"(), + "retention" bigint DEFAULT '2592000'::bigint NOT NULL, + "owner_org" "uuid" NOT NULL, + "default_upload_channel" character varying DEFAULT 'production'::character varying NOT NULL, + "transfer_history" "jsonb"[] DEFAULT '{}'::"jsonb"[], + "channel_device_count" bigint DEFAULT 0 NOT NULL, + "manifest_bundle_count" bigint DEFAULT 0 NOT NULL, + "expose_metadata" boolean DEFAULT false NOT NULL, + "allow_preview" boolean DEFAULT false NOT NULL, + "allow_device_custom_id" boolean DEFAULT true NOT NULL, + "need_onboarding" boolean DEFAULT false NOT NULL, + "existing_app" boolean DEFAULT false NOT NULL, + "ios_store_url" "text", + "android_store_url" "text", + "stats_updated_at" timestamp without time zone, + "stats_refresh_requested_at" timestamp without time zone, + "build_timeout_seconds" bigint DEFAULT 900 NOT NULL, + "build_timeout_updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + CONSTRAINT "apps_build_timeout_seconds_check" CHECK ((("build_timeout_seconds" >= 300) AND ("build_timeout_seconds" <= 21600))) +); + + +ALTER TABLE "public"."apps" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."apps"."id" IS 'UUID scope id for RBAC (app-level roles reference this id).'; + + + +COMMENT ON COLUMN "public"."apps"."expose_metadata" IS 'When true, bundle link and comment metadata are exposed to the plugin in update responses'; + + + +COMMENT ON COLUMN "public"."apps"."allow_preview" IS 'When true, bundle preview is enabled for this app'; + + + +COMMENT ON COLUMN "public"."apps"."allow_device_custom_id" IS 'When true, devices can persist custom_id via unauthenticated /stats telemetry. When false, custom_id is ignored and a customIdBlocked stat is emitted.'; + + + +COMMENT ON COLUMN "public"."apps"."need_onboarding" IS 'True while the app is in the guided onboarding flow and may contain temporary onboarding/demo data.'; + + + +COMMENT ON COLUMN "public"."apps"."existing_app" IS 'True when the customer already has an existing mobile app and the CLI should not scaffold a fresh Capacitor app during onboarding.'; + + + +COMMENT ON COLUMN "public"."apps"."ios_store_url" IS 'Optional App Store URL collected during onboarding to prefill metadata for existing apps.'; + + + +COMMENT ON COLUMN "public"."apps"."android_store_url" IS 'Optional Google Play URL collected during onboarding to prefill metadata for existing apps.'; + + + +COMMENT ON COLUMN "public"."apps"."build_timeout_seconds" IS 'Maximum native cloud build runtime in seconds before the job is cancelled and billable time is capped.'; + + + +COMMENT ON COLUMN "public"."apps"."build_timeout_updated_at" IS 'Timestamp when the native cloud build timeout setting last changed.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text" DEFAULT NULL::"text") RETURNS SETOF "public"."apps" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_request_apikey text; + v_api_key public.apikeys%ROWTYPE; +BEGIN + SELECT public.get_apikey_header() INTO v_request_apikey; + + IF v_request_apikey IS NULL OR v_request_apikey = '' THEN + RETURN; + END IF; + + IF apikey IS NOT NULL AND apikey <> '' AND apikey IS DISTINCT FROM v_request_apikey THEN + RETURN; + END IF; + + SELECT * INTO v_api_key + FROM public.find_apikey_by_value(v_request_apikey) + LIMIT 1; + + IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN + RETURN; + END IF; + + RETURN QUERY + SELECT apps.* + FROM public.apps + WHERE public.rbac_check_permission_direct( + public.rbac_perm_app_read(), + v_api_key.user_id, + apps.owner_org, + apps.app_id, + NULL, + v_request_apikey + ) + ORDER BY apps.created_at DESC; +END; +$$; + + +ALTER FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") IS 'Returns apps visible to the request capgkey using RBAC permission checks. The apikey argument is retained for CLI compatibility and must match the header when provided.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_account_removal_date"() RETURNS timestamp with time zone + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + removal_date TIMESTAMPTZ; + auth_uid uuid; +BEGIN + SELECT auth.uid() INTO auth_uid; + IF auth_uid IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT to_delete_accounts.removal_date INTO removal_date + FROM public.to_delete_accounts + WHERE account_id = auth_uid; + + IF removal_date IS NULL THEN + RAISE EXCEPTION + 'Account with ID % is not marked for deletion', + auth_uid; + END IF; + + RETURN removal_date; +END; +$$; + + +ALTER FUNCTION "public"."get_account_removal_date"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_apikey"() RETURNS "text" + LANGUAGE "plpgsql" STABLE SECURITY DEFINER PARALLEL SAFE + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name='apikey'); +END; +$$; + + +ALTER FUNCTION "public"."get_apikey"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_apikey_header"() RETURNS "text" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + headers_text text; +BEGIN + headers_text := "current_setting"('request.headers'::"text", true); + + IF headers_text IS NULL OR headers_text = '' THEN + RETURN NULL; + END IF; + + BEGIN + RETURN (headers_text::"json" ->> 'capgkey'::"text"); + EXCEPTION + WHEN OTHERS THEN + RETURN NULL; + END; +END; +$$; + + +ALTER FUNCTION "public"."get_apikey_header"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_app_access_rbac"("p_app_id" "uuid") RETURNS TABLE("id" "uuid", "principal_type" "text", "principal_id" "uuid", "principal_name" "text", "role_id" "uuid", "role_name" "text", "role_description" "text", "granted_at" timestamp with time zone, "granted_by" "uuid", "expires_at" timestamp with time zone, "reason" "text", "is_direct" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_id uuid; + v_app_id_string text; +BEGIN + -- Get org_id and app_id string from app + SELECT a.owner_org, a.app_id INTO v_org_id, v_app_id_string + FROM public.apps a + WHERE a.id = p_app_id; + + IF v_org_id IS NULL THEN + RAISE EXCEPTION 'APP_NOT_FOUND'; + END IF; + + -- Check if user has permission to view app access + IF NOT public.rbac_check_permission_direct(public.rbac_perm_app_read(), auth.uid(), v_org_id, v_app_id_string, NULL::bigint) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_ACCESS'; + END IF; + + -- Return app access with enriched data + RETURN QUERY + SELECT + rb.id, + rb.principal_type, + rb.principal_id, + CASE + WHEN rb.principal_type = public.rbac_principal_user() THEN u.email + WHEN rb.principal_type = public.rbac_principal_group() THEN g.name + ELSE rb.principal_id::text + END as principal_name, + rb.role_id, + r.name as role_name, + r.description as role_description, + rb.granted_at, + rb.granted_by, + rb.expires_at, + rb.reason, + rb.is_direct + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + LEFT JOIN public.users u ON rb.principal_type = public.rbac_principal_user() AND rb.principal_id = u.id + LEFT JOIN public.groups g ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = g.id + WHERE rb.scope_type = public.rbac_scope_app() + AND rb.app_id = p_app_id + ORDER BY rb.granted_at DESC; +END; +$$; + + +ALTER FUNCTION "public"."get_app_access_rbac"("p_app_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_app_access_rbac"("p_app_id" "uuid") IS 'Retrieves all access bindings for an app with permission checks. Requires app.read permission.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_app_metrics"("org_id" "uuid") RETURNS TABLE("app_id" character varying, "date" "date", "mau" bigint, "storage" bigint, "bandwidth" bigint, "build_time_unit" bigint, "get" bigint, "fail" bigint, "install" bigint, "uninstall" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + caller_id uuid; + cycle_start timestamptz; + cycle_end timestamptz; + org_exists boolean; +BEGIN + SELECT COALESCE( + NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), + NULLIF(pg_catalog.current_setting('role', true), ''), + NULLIF(COALESCE(session_user, current_user), '') + ) INTO caller_role; + + IF caller_role NOT IN ('service_role', 'postgres', 'supabase_admin') THEN + SELECT public.get_identity_org_allowed( + '{read,upload,write,all}'::public.key_mode[], + get_app_metrics.org_id + ) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + get_app_metrics.org_id, + NULL::character varying, + NULL::bigint + ) THEN + RETURN; + END IF; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE orgs.id = get_app_metrics.org_id + ) INTO org_exists; + + IF NOT org_exists THEN + RETURN; + END IF; + + SELECT subscription_anchor_start, subscription_anchor_end + INTO cycle_start, cycle_end + FROM public.get_cycle_info_org(org_id); + + RETURN QUERY + SELECT * + FROM public.get_app_metrics(org_id, cycle_start::date, cycle_end::date); +END; +$$; + + +ALTER FUNCTION "public"."get_app_metrics"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_app_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") RETURNS TABLE("app_id" character varying, "date" "date", "mau" bigint, "storage" bigint, "bandwidth" bigint, "build_time_unit" bigint, "get" bigint, "fail" bigint, "install" bigint, "uninstall" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + cache_entry public.app_metrics_cache%ROWTYPE; + caller_role text; + caller_id uuid; + org_exists boolean; + org_stats_updated_at timestamp without time zone; + v_cache_ttl CONSTANT interval := INTERVAL '5 minutes'; -- NOSONAR: function-local cache TTL + v_privileged_roles CONSTANT text[] := ARRAY['service_role', 'postgres', 'supabase_admin']; -- NOSONAR: function-local privileged role set + v_read_key_modes CONSTANT public.key_mode[] := '{read,upload,write,all}'::public.key_mode[]; -- NOSONAR: function-local key mode set + v_read_min_right CONSTANT public.user_min_right := 'read'::public.user_min_right; +BEGIN + SELECT COALESCE( + NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), -- NOSONAR: request role lookup reused across overloads + NULLIF(pg_catalog.current_setting('role', true), ''), + NULLIF(COALESCE(session_user, current_user), '') + ) INTO caller_role; + + IF caller_role <> ALL(v_privileged_roles) THEN + SELECT public.get_identity_org_allowed( + v_read_key_modes, + get_app_metrics.org_id + ) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + v_read_min_right, + caller_id, + get_app_metrics.org_id, + NULL::character varying, + NULL::bigint + ) THEN + RETURN; + END IF; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE orgs.id = get_app_metrics.org_id + ) INTO org_exists; + + IF NOT org_exists THEN + RETURN; + END IF; + + SELECT o.stats_updated_at + INTO org_stats_updated_at + FROM public.orgs o + WHERE o.id = get_app_metrics.org_id + LIMIT 1; + + SELECT * + INTO cache_entry + FROM public.app_metrics_cache + WHERE app_metrics_cache.org_id = get_app_metrics.org_id; + + IF cache_entry.id IS NULL + OR cache_entry.start_date IS DISTINCT FROM get_app_metrics.start_date + OR cache_entry.end_date IS DISTINCT FROM get_app_metrics.end_date + OR cache_entry.cached_at IS NULL + OR cache_entry.cached_at < (pg_catalog.now() - v_cache_ttl) + OR ( + org_stats_updated_at IS NOT NULL + AND pg_catalog.timezone('UTC', cache_entry.cached_at) < org_stats_updated_at + ) THEN + cache_entry := public.seed_get_app_metrics_caches( + get_app_metrics.org_id, + get_app_metrics.start_date, + get_app_metrics.end_date + ); + END IF; + + IF cache_entry.response IS NULL THEN + RETURN; + END IF; + + RETURN QUERY + SELECT + metrics.app_id, + metrics.date, + metrics.mau, + metrics.storage, + metrics.bandwidth, + metrics.build_time_unit, + metrics.get, + metrics.fail, + metrics.install, + metrics.uninstall + FROM pg_catalog.jsonb_to_recordset(cache_entry.response) AS metrics( + app_id character varying, + date date, + mau bigint, + storage bigint, + bandwidth bigint, + build_time_unit bigint, + get bigint, + fail bigint, + install bigint, + uninstall bigint + ) + ORDER BY metrics.app_id, metrics.date; +END; +$$; + + +ALTER FUNCTION "public"."get_app_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_app_metrics"("p_org_id" "uuid", "p_app_id" character varying, "p_start_date" "date", "p_end_date" "date") RETURNS TABLE("app_id" character varying, "date" "date", "mau" bigint, "storage" bigint, "bandwidth" bigint, "build_time_unit" bigint, "get" bigint, "fail" bigint, "install" bigint, "uninstall" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + cache_entry public.app_metrics_cache%ROWTYPE; + caller_role text; + caller_id uuid; + app_exists boolean; + org_stats_updated_at timestamp without time zone; + v_cache_ttl CONSTANT interval := INTERVAL '5 minutes'; -- NOSONAR: function-local cache TTL + v_privileged_roles CONSTANT text[] := ARRAY['service_role', 'postgres', 'supabase_admin']; -- NOSONAR: function-local privileged role set + v_read_key_modes CONSTANT public.key_mode[] := '{read,upload,write,all}'::public.key_mode[]; -- NOSONAR: function-local key mode set + v_read_min_right CONSTANT public.user_min_right := 'read'::public.user_min_right; +BEGIN + SELECT COALESCE( + NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), -- NOSONAR: request role lookup reused across overloads + NULLIF(pg_catalog.current_setting('role', true), ''), + NULLIF(COALESCE(session_user, current_user), '') + ) INTO caller_role; + + IF caller_role <> ALL(v_privileged_roles) THEN + SELECT public.get_identity_org_appid( + v_read_key_modes, + get_app_metrics.p_org_id, + get_app_metrics.p_app_id + ) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + v_read_min_right, + caller_id, + get_app_metrics.p_org_id, + get_app_metrics.p_app_id, + NULL::bigint + ) THEN + RETURN; + END IF; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.apps + WHERE apps.app_id = get_app_metrics.p_app_id + AND apps.owner_org = get_app_metrics.p_org_id + ) INTO app_exists; + + IF NOT app_exists THEN + RETURN; + END IF; + + SELECT o.stats_updated_at + INTO org_stats_updated_at + FROM public.orgs o + WHERE o.id = get_app_metrics.p_org_id + LIMIT 1; + + SELECT * + INTO cache_entry + FROM public.app_metrics_cache + WHERE app_metrics_cache.org_id = get_app_metrics.p_org_id; + + IF cache_entry.id IS NULL + OR cache_entry.start_date IS DISTINCT FROM get_app_metrics.p_start_date + OR cache_entry.end_date IS DISTINCT FROM get_app_metrics.p_end_date + OR cache_entry.cached_at IS NULL + OR cache_entry.cached_at < (pg_catalog.now() - v_cache_ttl) + OR ( + org_stats_updated_at IS NOT NULL + AND pg_catalog.timezone('UTC', cache_entry.cached_at) < org_stats_updated_at + ) THEN + cache_entry := public.seed_get_app_metrics_caches( + get_app_metrics.p_org_id, + get_app_metrics.p_start_date, + get_app_metrics.p_end_date + ); + END IF; + + IF cache_entry.response IS NULL THEN + RETURN; + END IF; + + RETURN QUERY + SELECT + metrics.app_id, + metrics.date, + metrics.mau, + metrics.storage, + metrics.bandwidth, + metrics.build_time_unit, + metrics.get, + metrics.fail, + metrics.install, + metrics.uninstall + FROM pg_catalog.jsonb_to_recordset(cache_entry.response) AS metrics( + app_id character varying, + date date, + mau bigint, + storage bigint, + bandwidth bigint, + build_time_unit bigint, + get bigint, + fail bigint, + install bigint, + uninstall bigint + ) + WHERE metrics.app_id = get_app_metrics.p_app_id + ORDER BY metrics.date; +END; +$$; + + +ALTER FUNCTION "public"."get_app_metrics"("p_org_id" "uuid", "p_app_id" character varying, "p_start_date" "date", "p_end_date" "date") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") RETURNS integer + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_id uuid; + v_user_id uuid; +BEGIN + SELECT owner_org + INTO v_org_id + FROM public.apps + WHERE app_id = get_app_versions.appid + LIMIT 1; + + IF v_org_id IS NULL THEN + RETURN NULL; + END IF; + + SELECT public.get_user_id(get_app_versions.apikey) + INTO v_user_id; + + IF public.rbac_check_permission_direct( + public.rbac_perm_app_read_bundles(), + v_user_id, + v_org_id, + get_app_versions.appid, + NULL::bigint, + get_app_versions.apikey + ) IS NOT TRUE THEN + RETURN NULL; + END IF; + + RETURN ( + SELECT id + FROM public.app_versions + WHERE app_id = get_app_versions.appid + AND name = get_app_versions.name_version + AND owner_org = v_org_id + LIMIT 1 + ); +END; +$$; + + +ALTER FUNCTION "public"."get_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") RETURNS TABLE("mau" bigint, "bandwidth" bigint, "storage" bigint, "build_time_unit" bigint, "native_build_concurrency" integer) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_request_user uuid; + v_request_role text; + v_is_internal boolean; +BEGIN + SELECT public.current_request_role() INTO v_request_role; + + v_is_internal := public.is_internal_request_role(v_request_role); + + IF NOT v_is_internal THEN + v_request_user := public.get_identity_org_allowed( + public.request_read_key_modes(), + get_current_plan_max_org.orgid + ); + + IF NOT public.request_has_org_read_access(get_current_plan_max_org.orgid) THEN + PERFORM public.pg_log( + 'deny: NO_RIGHTS', + pg_catalog.jsonb_build_object( + 'orgid', + get_current_plan_max_org.orgid, + 'uid', + v_request_user + ) + ); + RETURN; + END IF; + END IF; + + RETURN QUERY + SELECT + p.mau, + p.bandwidth, + p.storage, + p.build_time_unit, + p.native_build_concurrency + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + JOIN public.plans p ON si.product_id = p.stripe_id + WHERE o.id = orgid; +END; +$$; + + +ALTER FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_current_plan_name_org"("orgid" "uuid") RETURNS character varying + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_request_user uuid; + v_is_service_role boolean; +BEGIN + v_is_service_role := ( + ((SELECT auth.jwt() ->> 'role') = 'service_role') + OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') + ); + + IF NOT v_is_service_role THEN + v_request_user := public.get_identity_org_allowed( + '{read,upload,write,all}'::public.key_mode[], + get_current_plan_name_org.orgid + ); + + IF v_request_user IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + v_request_user, + get_current_plan_name_org.orgid, + NULL::varchar, + NULL::bigint + ) THEN + RETURN NULL; + END IF; + END IF; + + RETURN ( + SELECT p.name + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + JOIN public.plans p ON si.product_id = p.stripe_id + WHERE o.id = orgid + LIMIT 1 + ); +END; +$$; + + +ALTER FUNCTION "public"."get_current_plan_name_org"("orgid" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_current_plan_name_org"("orgid" "uuid") IS 'Return the Stripe plan name for the supplied organization after enforcing read-level access; returns NULL when the org is missing or the caller is unauthorized.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_customer_counts"() RETURNS TABLE("yearly" bigint, "monthly" bigint, "total" bigint) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + WITH ActiveSubscriptions AS ( + -- Get the most recent subscription for each customer + SELECT DISTINCT ON (customer_id) + customer_id, + price_id, + status, + trial_at + FROM public.stripe_info + WHERE status = 'succeeded' + ORDER BY customer_id, created_at DESC + ) + SELECT + COUNT(CASE + WHEN s.price_id IN (SELECT price_y_id FROM public.plans WHERE price_y_id IS NOT NULL) + THEN 1 + END) AS yearly, + COUNT(CASE + WHEN s.price_id IN (SELECT price_m_id FROM public.plans WHERE price_m_id IS NOT NULL) + THEN 1 + END) AS monthly, + COUNT(*) AS total + FROM ActiveSubscriptions s; +END; +$$; + + +ALTER FUNCTION "public"."get_customer_counts"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_cycle_info_org"("orgid" "uuid") RETURNS TABLE("subscription_anchor_start" timestamp with time zone, "subscription_anchor_end" timestamp with time zone) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + customer_id_var text; + stripe_info_row public.stripe_info%ROWTYPE; + anchor_day interval; + start_date timestamptz; + end_date timestamptz; + v_request_user uuid; + v_is_service_role boolean; +BEGIN + v_is_service_role := ( + ((SELECT auth.jwt() ->> 'role') = 'service_role') + OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') + ); + + IF NOT v_is_service_role THEN + v_request_user := public.get_identity_org_allowed( + '{read,upload,write,all}'::public.key_mode[], + get_cycle_info_org.orgid + ); + + IF v_request_user IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + v_request_user, + get_cycle_info_org.orgid, + NULL::varchar, + NULL::bigint + ) THEN + RETURN; + END IF; + END IF; + + SELECT customer_id + INTO customer_id_var + FROM public.orgs + WHERE id = orgid; + + SELECT * + INTO stripe_info_row + FROM public.stripe_info + WHERE customer_id = customer_id_var; + + anchor_day := COALESCE( + stripe_info_row.subscription_anchor_start - date_trunc('MONTH', stripe_info_row.subscription_anchor_start), + '0 DAYS'::interval + ); + + IF anchor_day > now() - date_trunc('MONTH', now()) THEN + start_date := date_trunc('MONTH', now() - interval '1 MONTH') + anchor_day; + ELSE + start_date := date_trunc('MONTH', now()) + anchor_day; + END IF; + + end_date := start_date + interval '1 MONTH'; + + RETURN QUERY + SELECT start_date, end_date; +END; +$$; + + +ALTER FUNCTION "public"."get_cycle_info_org"("orgid" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_cycle_info_org"("orgid" "uuid") IS 'Return the billing cycle start and end for the supplied organization after verifying read access, using Stripe anchor dates to compute the boundaries.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_db_url"() RETURNS "text" + LANGUAGE "plpgsql" STABLE SECURITY DEFINER PARALLEL SAFE + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name='db_url'); +END; +$$; + + +ALTER FUNCTION "public"."get_db_url"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_global_metrics"("org_id" "uuid") RETURNS TABLE("date" "date", "mau" bigint, "storage" bigint, "bandwidth" bigint, "get" bigint, "fail" bigint, "install" bigint, "uninstall" bigint) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + caller_id uuid; + cycle_start timestamptz; + cycle_end timestamptz; + org_exists boolean; +BEGIN + SELECT COALESCE( + NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), + NULLIF(pg_catalog.current_setting('role', true), ''), + NULLIF(COALESCE(session_user, current_user), '') + ) INTO caller_role; + + IF caller_role NOT IN ('service_role', 'postgres', 'supabase_admin') THEN + SELECT public.get_identity_org_allowed( + '{read,upload,write,all}'::public.key_mode[], + get_global_metrics.org_id + ) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + get_global_metrics.org_id, + NULL::character varying, + NULL::bigint + ) THEN + RETURN; + END IF; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE orgs.id = get_global_metrics.org_id + ) INTO org_exists; + + IF NOT org_exists THEN + RETURN; + END IF; + + SELECT subscription_anchor_start, subscription_anchor_end + INTO cycle_start, cycle_end + FROM public.get_cycle_info_org(org_id); + + RETURN QUERY + SELECT * + FROM public.get_global_metrics(org_id, cycle_start::date, cycle_end::date); +END; +$$; + + +ALTER FUNCTION "public"."get_global_metrics"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_global_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") RETURNS TABLE("date" "date", "mau" bigint, "storage" bigint, "bandwidth" bigint, "get" bigint, "fail" bigint, "install" bigint, "uninstall" bigint) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + caller_id uuid; +BEGIN + SELECT COALESCE( + NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), + NULLIF(pg_catalog.current_setting('role', true), ''), + NULLIF(COALESCE(session_user, current_user), '') + ) INTO caller_role; + + IF caller_role NOT IN ('service_role', 'postgres', 'supabase_admin') THEN + SELECT public.get_identity_org_allowed( + '{read,upload,write,all}'::public.key_mode[], + get_global_metrics.org_id + ) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + get_global_metrics.org_id, + NULL::character varying, + NULL::bigint + ) THEN + RETURN; + END IF; + END IF; + + RETURN QUERY + SELECT + metrics.date, + SUM(metrics.mau)::bigint AS mau, + SUM(metrics.storage)::bigint AS storage, + SUM(metrics.bandwidth)::bigint AS bandwidth, + SUM(metrics.get)::bigint AS get, + SUM(metrics.fail)::bigint AS fail, + SUM(metrics.install)::bigint AS install, + SUM(metrics.uninstall)::bigint AS uninstall + FROM public.get_app_metrics(org_id, start_date, end_date) AS metrics + GROUP BY metrics.date + ORDER BY metrics.date; +END; +$$; + + +ALTER FUNCTION "public"."get_global_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_identity"() RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + auth_uid uuid; +BEGIN + SELECT auth.uid() INTO auth_uid; + + -- JWT auth.uid is not null, return + IF auth_uid IS NOT NULL THEN + RETURN auth_uid; + END IF; + + -- JWT is null + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."get_identity"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_identity"("keymode" "public"."key_mode"[]) RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + auth_uid uuid; + api_key_text text; + api_key public.apikeys%ROWTYPE; +BEGIN + PERFORM keymode; + SELECT auth.uid() INTO auth_uid; + IF auth_uid IS NOT NULL THEN + RETURN auth_uid; + END IF; + + SELECT public.get_apikey_header() INTO api_key_text; + IF api_key_text IS NULL THEN + RETURN NULL; + END IF; + + SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; + IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + RETURN NULL; + END IF; + + RETURN api_key.user_id; +END; +$$; + + +ALTER FUNCTION "public"."get_identity"("keymode" "public"."key_mode"[]) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_identity_apikey_only"("keymode" "public"."key_mode"[]) RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key_text text; + api_key public.apikeys%ROWTYPE; +BEGIN + PERFORM keymode; + SELECT public.get_apikey_header() INTO api_key_text; + IF api_key_text IS NULL THEN + RETURN NULL; + END IF; + + SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; + IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + RETURN NULL; + END IF; + + RETURN api_key.user_id; +END; +$$; + + +ALTER FUNCTION "public"."get_identity_apikey_only"("keymode" "public"."key_mode"[]) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_identity_for_apikey_creation"() RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + auth_uid uuid; +BEGIN + SELECT auth.uid() INTO auth_uid; + IF auth_uid IS NOT NULL THEN + RETURN auth_uid; + END IF; + + PERFORM public.pg_log('deny: APIKEY_CREATE_WITH_API_KEY_DISABLED', '{}'::jsonb); + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."get_identity_for_apikey_creation"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_identity_for_apikey_creation"() IS 'Returns auth.uid() for JWT callers; API-key creation of API keys stays disabled even when org.create is granted.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_identity_org_allowed"("keymode" "public"."key_mode"[], "org_id" "uuid") RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + auth_uid uuid; + api_key_text text; + api_key public.apikeys%ROWTYPE; + required_permission text; +BEGIN + SELECT auth.uid() INTO auth_uid; + IF auth_uid IS NOT NULL THEN + RETURN auth_uid; + END IF; + + SELECT public.get_apikey_header() INTO api_key_text; + IF api_key_text IS NULL THEN + RETURN NULL; + END IF; + + SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; + IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + RETURN NULL; + END IF; + + required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_org()); + IF public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, org_id, NULL, NULL) THEN + RETURN api_key.user_id; + END IF; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."get_identity_org_allowed"("keymode" "public"."key_mode"[], "org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_identity_org_allowed_apikey_only"("keymode" "public"."key_mode"[], "org_id" "uuid") RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key_text text; + api_key public.apikeys%ROWTYPE; + required_permission text; +BEGIN + SELECT public.get_apikey_header() INTO api_key_text; + IF api_key_text IS NULL THEN + RETURN NULL; + END IF; + + SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; + IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + RETURN NULL; + END IF; + + required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_org()); + IF public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, org_id, NULL, NULL) THEN + RETURN api_key.user_id; + END IF; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."get_identity_org_allowed_apikey_only"("keymode" "public"."key_mode"[], "org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_identity_org_appid"("keymode" "public"."key_mode"[], "org_id" "uuid", "app_id" character varying) RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + auth_uid uuid; + api_key_text text; + api_key public.apikeys%ROWTYPE; + required_permission text; +BEGIN + SELECT auth.uid() INTO auth_uid; + IF auth_uid IS NOT NULL THEN + RETURN auth_uid; + END IF; + + SELECT public.get_apikey_header() INTO api_key_text; + IF api_key_text IS NULL THEN + RETURN NULL; + END IF; + + SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; + IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + RETURN NULL; + END IF; + + required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_app()); + IF public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, org_id, app_id, NULL) THEN + RETURN api_key.user_id; + END IF; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."get_identity_org_appid"("keymode" "public"."key_mode"[], "org_id" "uuid", "app_id" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_invite_by_magic_lookup"("lookup" "text") RETURNS TABLE("org_name" "text", "org_logo" "text", "role" "text") + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + o.name AS org_name, + o.logo AS org_logo, + COALESCE(tmp.rbac_role_name, tmp.role::text) AS role + FROM public.tmp_users tmp + JOIN public.orgs o ON tmp.org_id = o.id + WHERE tmp.invite_magic_string = get_invite_by_magic_lookup.lookup + AND tmp.cancelled_at IS NULL + AND GREATEST(tmp.updated_at, tmp.created_at) > (CURRENT_TIMESTAMP - INTERVAL '7 days'); +END; +$$; + + +ALTER FUNCTION "public"."get_invite_by_magic_lookup"("lookup" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_mfa_email_otp_enforced_at"() RETURNS timestamp with time zone + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +DECLARE + v_setting text; +BEGIN + SELECT decrypted_secret + INTO v_setting + FROM vault.decrypted_secrets + WHERE name = 'CAPGO_MFA_EMAIL_OTP_ENFORCED_AT' + LIMIT 1; + + IF v_setting IS NULL OR btrim(v_setting) = '' THEN + RETURN NULL; + END IF; + + BEGIN + RETURN v_setting::timestamptz; + EXCEPTION WHEN others THEN + RETURN NULL; + END; +END; +$$; + + +ALTER FUNCTION "public"."get_mfa_email_otp_enforced_at"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_next_cron_time"("p_schedule" "text", "p_timestamp" timestamp with time zone) RETURNS timestamp with time zone + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + parts text[]; + minute_pattern text; + hour_pattern text; + next_minute int; + next_hour int; + next_time timestamptz; +BEGIN + parts := regexp_split_to_array(p_schedule, '\s+'); + minute_pattern := parts[1]; + hour_pattern := parts[2]; + next_minute := public.get_next_cron_value(minute_pattern, EXTRACT(MINUTE FROM p_timestamp)::int, 60); + next_hour := public.get_next_cron_value(hour_pattern, EXTRACT(HOUR FROM p_timestamp)::int, 24); + next_time := date_trunc('hour', p_timestamp) + make_interval(hours => next_hour - EXTRACT(HOUR FROM p_timestamp)::int, mins => next_minute); + IF next_time <= p_timestamp THEN + IF hour_pattern LIKE '*/%' THEN + next_time := next_time + make_interval(hours => public.parse_step_pattern(hour_pattern)); + ELSIF minute_pattern LIKE '*/%' THEN + next_time := next_time + make_interval(mins => public.parse_step_pattern(minute_pattern)); + ELSE + next_time := next_time + interval '1 day'; + END IF; + END IF; + RETURN next_time; +END; +$$; + + +ALTER FUNCTION "public"."get_next_cron_time"("p_schedule" "text", "p_timestamp" timestamp with time zone) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_next_cron_value"("pattern" "text", "current_val" integer, "max_val" integer) RETURNS integer + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + IF pattern = '*' THEN + RETURN current_val; + ELSIF pattern LIKE '*/%' THEN + DECLARE step int := public.parse_step_pattern(pattern); + temp_next int := current_val + (step - (current_val % step)); + BEGIN + IF temp_next >= max_val THEN RETURN step; ELSE RETURN temp_next; END IF; + END; + ELSE + RETURN pattern::int; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."get_next_cron_value"("pattern" "text", "current_val" integer, "max_val" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_next_stats_update_date"("org" "uuid") RETURNS timestamp with time zone + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + cron_schedule constant text := '0 3 * * *'; + next_run timestamptz; + preceding_count integer := 0; + is_target boolean := false; +BEGIN + next_run := public.get_next_cron_time(cron_schedule, NOW()); + WITH paying_orgs AS ( + SELECT o.id + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + WHERE ( + -- Paying customers with active subscription + (si.status = 'succeeded' + AND (si.canceled_at IS NULL OR si.canceled_at > next_run) + AND si.subscription_anchor_end > next_run) + -- Trial customers + OR si.trial_at > next_run + ) + ORDER BY o.id ASC + ) + SELECT + COUNT(*) FILTER (WHERE id < org)::int, + COALESCE(BOOL_OR(id = org), false) + INTO preceding_count, is_target + FROM paying_orgs; + + IF NOT is_target THEN + RETURN NULL; + END IF; + + RETURN next_run + make_interval(mins => preceding_count * 4); +END; +$$; + + +ALTER FUNCTION "public"."get_next_stats_update_date"("org" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") RETURNS TABLE("id" bigint, "rbac_id" "uuid", "name" "text", "user_id" "uuid", "owner_email" character varying, "created_at" timestamp with time zone, "expires_at" timestamp with time zone) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + IF NOT public.rbac_check_permission_direct( + public.rbac_perm_org_update_user_roles(), + auth.uid(), + p_org_id, + NULL, + NULL, + NULL + ) THEN + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + RETURN QUERY + SELECT DISTINCT + ak.id, + ak.rbac_id, + ak.name::text, + ak.user_id, + users.email, + ak.created_at, + ak.expires_at + FROM public.apikeys ak + JOIN public.users users ON users.id = ak.user_id + JOIN public.role_bindings rb + ON rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = ak.rbac_id + AND rb.org_id = p_org_id + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ORDER BY ak.created_at DESC; +END; +$$; + + +ALTER FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_org_apps_with_last_upload"("p_org_id" "uuid", "p_search" "text" DEFAULT NULL::"text", "p_sort_by" "text" DEFAULT 'last_upload_at'::"text", "p_sort_desc" boolean DEFAULT true, "p_limit" integer DEFAULT 10, "p_offset" integer DEFAULT 0) RETURNS TABLE("created_at" timestamp with time zone, "app_id" character varying, "icon_url" character varying, "user_id" "uuid", "name" character varying, "last_version" character varying, "updated_at" timestamp with time zone, "id" "uuid", "retention" bigint, "owner_org" "uuid", "default_upload_channel" character varying, "transfer_history" "jsonb"[], "channel_device_count" bigint, "manifest_bundle_count" bigint, "expose_metadata" boolean, "allow_preview" boolean, "allow_device_custom_id" boolean, "need_onboarding" boolean, "existing_app" boolean, "ios_store_url" "text", "android_store_url" "text", "stats_updated_at" timestamp without time zone, "stats_refresh_requested_at" timestamp without time zone, "build_timeout_seconds" bigint, "build_timeout_updated_at" timestamp with time zone, "last_upload_at" timestamp with time zone, "total_count" bigint) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + v_limit integer := LEAST(GREATEST(COALESCE(p_limit, 10), 1), 100); + v_offset integer := GREATEST(COALESCE(p_offset, 0), 0); + v_search text := NULLIF(btrim(COALESCE(p_search, '')), ''); + -- Whitelist sort keys to avoid dynamic-SQL injection via p_sort_by. + v_sort text := CASE + WHEN p_sort_by IN ('name', 'last_version', 'updated_at', 'created_at', 'last_upload_at') + THEN p_sort_by + ELSE 'last_upload_at' + END; + v_desc boolean := COALESCE(p_sort_desc, true); +BEGIN + RETURN QUERY + WITH scoped AS ( + SELECT + a.*, + lv.created_at AS last_upload_at + FROM public.apps a + LEFT JOIN LATERAL ( + SELECT av.created_at + FROM public.app_versions av + WHERE av.app_id = a.app_id + AND av.name = a.last_version + AND av.deleted = false + ORDER BY av.created_at DESC + LIMIT 1 + ) lv ON a.last_version IS NOT NULL + WHERE a.owner_org = p_org_id + AND ( + v_search IS NULL + OR a.name ILIKE '%' || v_search || '%' + OR a.app_id ILIKE '%' || v_search || '%' + ) + ) + SELECT + s.*, + COUNT(*) OVER () AS total_count + FROM scoped s + ORDER BY + -- NULLS LAST in both directions so apps without uploads sort to the bottom. + CASE WHEN v_sort = 'last_upload_at' AND v_desc THEN s.last_upload_at END DESC NULLS LAST, + CASE WHEN v_sort = 'last_upload_at' AND NOT v_desc THEN s.last_upload_at END ASC NULLS LAST, + CASE WHEN v_sort = 'updated_at' AND v_desc THEN s.updated_at END DESC NULLS LAST, + CASE WHEN v_sort = 'updated_at' AND NOT v_desc THEN s.updated_at END ASC NULLS LAST, + CASE WHEN v_sort = 'created_at' AND v_desc THEN s.created_at END DESC NULLS LAST, + CASE WHEN v_sort = 'created_at' AND NOT v_desc THEN s.created_at END ASC NULLS LAST, + CASE WHEN v_sort = 'name' AND v_desc THEN s.name END DESC NULLS LAST, + CASE WHEN v_sort = 'name' AND NOT v_desc THEN s.name END ASC NULLS LAST, + CASE WHEN v_sort = 'last_version' AND v_desc THEN s.last_version END DESC NULLS LAST, + CASE WHEN v_sort = 'last_version' AND NOT v_desc THEN s.last_version END ASC NULLS LAST, + -- Stable tiebreaker so pagination is deterministic across pages. + s.app_id ASC + LIMIT v_limit + OFFSET v_offset; +END; +$$; + + +ALTER FUNCTION "public"."get_org_apps_with_last_upload"("p_org_id" "uuid", "p_search" "text", "p_sort_by" "text", "p_sort_desc" boolean, "p_limit" integer, "p_offset" integer) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_org_apps_with_last_upload"("p_org_id" "uuid", "p_search" "text", "p_sort_by" "text", "p_sort_desc" boolean, "p_limit" integer, "p_offset" integer) IS 'Paginated apps for one org with a derived last_upload_at (created_at of the bundle matching apps.last_version). Returns the full apps row plus last_upload_at and total_count. SECURITY INVOKER so RLS on apps/app_versions enforces visibility; p_org_id is an indexed narrowing filter on top of RLS. Search/sort/pagination/total_count are computed in SQL so page order matches the displayed last-upload sort.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_org_build_time_unit"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") RETURNS TABLE("total_build_time_unit" bigint, "total_builds" bigint) + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT COALESCE(SUM(dbt.build_time_unit), 0)::bigint, COALESCE(SUM(dbt.build_count), 0)::bigint + FROM public.daily_build_time dbt + INNER JOIN public.apps a ON a.app_id = dbt.app_id + WHERE a.owner_org = p_org_id AND dbt.date >= p_start_date AND dbt.date <= p_end_date; +END; +$$; + + +ALTER FUNCTION "public"."get_org_build_time_unit"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_org_members"("guild_id" "uuid") RETURNS TABLE("aid" bigint, "uid" "uuid", "email" character varying, "image_url" character varying, "role" "public"."user_min_right", "is_tmp" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_is_service_role boolean; +BEGIN + v_user_id := public.get_identity( + '{read,upload,write,all}'::public.key_mode[] + ); + v_is_service_role := ( + (SELECT auth.jwt() ->> 'role') = 'service_role' + OR (SELECT session_user) IS NOT DISTINCT FROM 'postgres' + ); + + IF NOT v_is_service_role THEN + IF v_user_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + v_user_id, + get_org_members.guild_id, + NULL::character varying, + NULL::bigint + ) THEN + PERFORM public.pg_log( + 'deny: NO_RIGHTS', + jsonb_build_object( + 'guild_id', get_org_members.guild_id, + 'uid', v_user_id + ) + ); + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + END IF; + + RETURN QUERY + SELECT * + FROM public.get_org_members(v_user_id, get_org_members.guild_id); +END; +$$; + + +ALTER FUNCTION "public"."get_org_members"("guild_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_org_members"("user_id" "uuid", "guild_id" "uuid") RETURNS TABLE("aid" bigint, "uid" "uuid", "email" character varying, "image_url" character varying, "role" "public"."user_min_right", "is_tmp" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_is_service_role boolean; +BEGIN + v_is_service_role := ( + (SELECT auth.jwt() ->> 'role') = 'service_role' + OR (SELECT session_user) IS NOT DISTINCT FROM 'postgres' + ); + + IF NOT v_is_service_role THEN + v_user_id := public.get_identity( + '{read,upload,write,all}'::public.key_mode[] + ); + + IF v_user_id IS NULL + OR v_user_id IS DISTINCT FROM get_org_members.user_id THEN + PERFORM public.pg_log( + 'deny: NO_RIGHTS', + jsonb_build_object( + 'guild_id', get_org_members.guild_id, + 'uid', v_user_id, + 'requested_uid', get_org_members.user_id + ) + ); + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + IF NOT public.check_min_rights( + 'read'::public.user_min_right, + v_user_id, + get_org_members.guild_id, + NULL::character varying, + NULL::bigint + ) THEN + PERFORM public.pg_log( + 'deny: NO_RIGHTS', + jsonb_build_object( + 'guild_id', get_org_members.guild_id, + 'uid', v_user_id + ) + ); + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + END IF; + + RETURN QUERY + -- Get existing org members + SELECT + o.id AS aid, + users.id AS uid, + users.email, + users.image_url, + o.user_right AS role, + false AS is_tmp + FROM public.org_users o + JOIN public.users ON users.id = o.user_id + WHERE o.org_id = get_org_members.guild_id + UNION + -- Get pending invitations from tmp_users + SELECT + (-tmp.id)::bigint AS aid, + tmp.future_uuid AS uid, + tmp.email::varchar, + ''::varchar AS image_url, + public.transform_role_to_invite(tmp.role) AS role, + true AS is_tmp + FROM public.tmp_users tmp + WHERE tmp.org_id = get_org_members.guild_id + AND tmp.cancelled_at IS NULL + AND GREATEST(tmp.updated_at, tmp.created_at) + > (CURRENT_TIMESTAMP - INTERVAL '7 days'); +END; +$$; + + +ALTER FUNCTION "public"."get_org_members"("user_id" "uuid", "guild_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") RETURNS TABLE("user_id" "uuid", "email" character varying, "image_url" character varying, "role_name" "text", "role_id" "uuid", "binding_id" "uuid", "granted_at" timestamp with time zone, "is_invite" boolean, "is_tmp" boolean, "org_user_id" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key_text text; +BEGIN + SELECT public.get_apikey_header() INTO api_key_text; + + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_read(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_MEMBERS'; + END IF; + + RETURN QUERY + WITH rbac_members AS ( + SELECT + u.id AS user_id, + u.email, + u.image_url, + r.name AS role_name, + rb.role_id, + rb.id AS binding_id, + rb.granted_at, + false AS is_invite, + false AS is_tmp, + NULL::bigint AS org_user_id + FROM public.users u + INNER JOIN public.role_bindings rb ON rb.principal_id = u.id + AND rb.principal_type = public.rbac_principal_user() + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id = p_org_id + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE r.scope_type = public.rbac_scope_org() + AND r.name LIKE 'org_%' + ), + legacy_invites AS ( + SELECT + u.id AS user_id, + u.email, + u.image_url, + COALESCE( + ou.rbac_role_name, + CASE public.transform_role_to_non_invite(ou.user_right) + WHEN public.rbac_right_super_admin() THEN public.rbac_role_org_super_admin() + WHEN public.rbac_right_admin() THEN public.rbac_role_org_admin() + ELSE public.rbac_role_org_member() + END + ) AS role_name, + NULL::uuid AS role_id, + NULL::uuid AS binding_id, + ou.created_at AS granted_at, + true AS is_invite, + false AS is_tmp, + ou.id AS org_user_id + FROM public.org_users ou + INNER JOIN public.users u ON u.id = ou.user_id + WHERE ou.org_id = p_org_id + AND ou.user_right::text LIKE 'invite_%' + ), + tmp_invites AS ( + SELECT + tmp.future_uuid AS user_id, + tmp.email, + ''::character varying AS image_url, + COALESCE( + tmp.rbac_role_name, + CASE tmp.role + WHEN public.rbac_right_super_admin() THEN public.rbac_role_org_super_admin() + WHEN public.rbac_right_admin() THEN public.rbac_role_org_admin() + ELSE public.rbac_role_org_member() + END + ) AS role_name, + NULL::uuid AS role_id, + NULL::uuid AS binding_id, + GREATEST(tmp.updated_at, tmp.created_at) AS granted_at, + true AS is_invite, + true AS is_tmp, + NULL::bigint AS org_user_id + FROM public.tmp_users tmp + WHERE tmp.org_id = p_org_id + AND tmp.cancelled_at IS NULL + AND GREATEST(tmp.updated_at, tmp.created_at) > (CURRENT_TIMESTAMP - INTERVAL '7 days') + ) + SELECT * + FROM ( + SELECT * FROM rbac_members + UNION ALL + SELECT * FROM legacy_invites + UNION ALL + SELECT * FROM tmp_invites + ) AS combined + ORDER BY + combined.is_invite, + CASE combined.role_name + WHEN public.rbac_role_org_super_admin() THEN 1 + WHEN public.rbac_role_org_admin() THEN 2 + WHEN public.rbac_role_org_billing_admin() THEN 3 + WHEN public.rbac_role_org_member() THEN 4 + ELSE 5 + END, + combined.email; +END; +$$; + + +ALTER FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") IS ' +Returns organization members and pending invites with their RBAC roles. Requires +org.read permission. +'; + + + +CREATE OR REPLACE FUNCTION "public"."get_org_owner_id"("apikey" "text", "app_id" "text") RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +Declare + org_owner_id uuid; + real_user_id uuid; + org_id uuid; +Begin + SELECT apps.user_id FROM public.apps WHERE apps.app_id=get_org_owner_id.app_id into org_owner_id; + SELECT public.get_user_main_org_id_by_app_id(app_id) INTO org_id; + + SELECT user_id + INTO real_user_id + FROM public.apikeys + WHERE key=apikey; + + IF (public.is_member_of_org(real_user_id, org_id) IS FALSE) + THEN + PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('app_id', get_org_owner_id.app_id, 'org_id', org_id, 'real_user_id', real_user_id)); + raise exception 'NO_RIGHTS'; + END IF; + + RETURN org_owner_id; +End; +$$; + + +ALTER FUNCTION "public"."get_org_owner_id"("apikey" "text", "app_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_org_perm_for_apikey"("apikey" "text", "app_id" "text") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +<> +DECLARE + apikey_user_id uuid; + org_id uuid; + api_key record; +BEGIN + SELECT * FROM public.find_apikey_by_value(apikey) INTO api_key; + apikey_user_id := api_key.user_id; + + IF apikey_user_id IS NULL THEN + PERFORM public.pg_log('deny: INVALID_APIKEY', jsonb_build_object('app_id', get_org_perm_for_apikey.app_id)); + RETURN 'INVALID_APIKEY'; + END IF; + + SELECT owner_org + INTO org_id + FROM public.apps + WHERE apps.app_id = get_org_perm_for_apikey.app_id + LIMIT 1; + + IF org_id IS NULL THEN + PERFORM public.pg_log('deny: NO_APP', jsonb_build_object('app_id', get_org_perm_for_apikey.app_id)); + RETURN 'NO_APP'; + END IF; + + IF public.rbac_check_permission_direct(public.rbac_perm_app_transfer(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN + RETURN 'perm_owner'; + END IF; + + IF public.rbac_check_permission_direct(public.rbac_perm_app_delete(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN + RETURN 'perm_admin'; + END IF; + + IF public.rbac_check_permission_direct(public.rbac_perm_app_update_settings(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN + RETURN 'perm_write'; + END IF; + + IF public.rbac_check_permission_direct(public.rbac_perm_app_upload_bundle(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN + RETURN 'perm_upload'; + END IF; + + IF public.rbac_check_permission_direct(public.rbac_perm_app_read(), apikey_user_id, org_id, get_org_perm_for_apikey.app_id, NULL::bigint, apikey) THEN + RETURN 'perm_read'; + END IF; + + PERFORM public.pg_log('deny: perm_none', jsonb_build_object('org_id', org_id, 'apikey_user_id', apikey_user_id)); + RETURN 'perm_none'; +END; +$$; + + +ALTER FUNCTION "public"."get_org_perm_for_apikey"("apikey" "text", "app_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_org_id uuid; + v_use_rbac boolean; +BEGIN + -- Resolve user from API key (supports hashed keys) + SELECT user_id INTO v_user_id + FROM public.find_apikey_by_value(get_org_perm_for_apikey_v2.apikey) + LIMIT 1; + + IF v_user_id IS NULL THEN + RETURN 'INVALID_APIKEY'; + END IF; + + -- Resolve org from app + SELECT owner_org INTO v_org_id + FROM public.apps + WHERE public.apps.app_id = get_org_perm_for_apikey_v2.app_id + LIMIT 1; + + IF v_org_id IS NULL THEN + RETURN 'NO_APP'; + END IF; + + -- Route to legacy function for non-RBAC orgs + v_use_rbac := public.rbac_is_enabled_for_org(v_org_id); + IF NOT v_use_rbac THEN + RETURN public.get_org_perm_for_apikey(get_org_perm_for_apikey_v2.apikey, get_org_perm_for_apikey_v2.app_id); + END IF; + + -- RBAC path: probe permissions from highest to lowest, return first match. + -- rbac_check_permission_direct handles "key bindings take priority" logic internally. + + IF public.rbac_check_permission_direct( + 'org.delete', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, + get_org_perm_for_apikey_v2.apikey + ) THEN + RETURN 'perm_owner'; + END IF; + + IF public.rbac_check_permission_direct( + 'app.delete', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, + get_org_perm_for_apikey_v2.apikey + ) THEN + RETURN 'perm_admin'; + END IF; + + IF public.rbac_check_permission_direct( + 'app.create_channel', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, + get_org_perm_for_apikey_v2.apikey + ) THEN + RETURN 'perm_write'; + END IF; + + IF public.rbac_check_permission_direct( + 'app.upload_bundle', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, + get_org_perm_for_apikey_v2.apikey + ) THEN + RETURN 'perm_upload'; + END IF; + + IF public.rbac_check_permission_direct( + 'app.read', v_user_id, v_org_id, get_org_perm_for_apikey_v2.app_id::varchar, NULL, + get_org_perm_for_apikey_v2.apikey + ) THEN + RETURN 'perm_read'; + END IF; + + RETURN 'perm_none'; +END; +$$; + + +ALTER FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_org_user_access_rbac"("p_user_id" "uuid", "p_org_id" "uuid") RETURNS TABLE("id" "uuid", "principal_type" "text", "principal_id" "uuid", "role_id" "uuid", "role_name" "text", "role_description" "text", "scope_type" "text", "org_id" "uuid", "app_id" "uuid", "channel_id" "uuid", "granted_at" timestamp with time zone, "granted_by" "uuid", "expires_at" timestamp with time zone, "reason" "text", "is_direct" boolean, "principal_name" "text", "user_email" "text", "group_name" "text") + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_BINDINGS'; + END IF; + + IF auth.uid() IS DISTINCT FROM p_user_id AND NOT public.rbac_check_permission_direct(public.rbac_perm_org_read(), auth.uid(), p_org_id, NULL::text, NULL::bigint) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_VIEW_BINDINGS'; + END IF; + + RETURN QUERY + SELECT + rb.id, + rb.principal_type, + rb.principal_id, + rb.role_id, + r.name as role_name, + r.description as role_description, + rb.scope_type, + rb.org_id, + rb.app_id, + rb.channel_id, + rb.granted_at, + rb.granted_by, + rb.expires_at, + rb.reason, + rb.is_direct, + CASE + WHEN rb.principal_type = public.rbac_principal_user() THEN u.email::text + WHEN rb.principal_type = public.rbac_principal_group() THEN g.name::text + ELSE rb.principal_id::text + END as principal_name, + u.email::text as user_email, + g.name::text as group_name + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + LEFT JOIN public.users u ON rb.principal_type = public.rbac_principal_user() AND rb.principal_id = u.id + LEFT JOIN public.groups g ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = g.id + WHERE rb.org_id = p_org_id + AND rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = p_user_id + ORDER BY rb.granted_at DESC; +END; +$$; + + +ALTER FUNCTION "public"."get_org_user_access_rbac"("p_user_id" "uuid", "p_org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") RETURNS "jsonb"[] + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + messages jsonb[] := ARRAY[]::jsonb[]; + request_apikey text; + api_key public.apikeys%ROWTYPE; + fallback_app_id text; + has_org_read boolean; +BEGIN + PERFORM cli_version; + + has_org_read := public.cli_check_permission( + permission_key := public.rbac_perm_org_read(), + org_id := orgid + ); + + IF NOT has_org_read THEN + SELECT public.get_apikey_header() INTO request_apikey; + + IF request_apikey IS NOT NULL AND request_apikey <> '' THEN + SELECT * + INTO api_key + FROM public.find_apikey_by_value(request_apikey) + LIMIT 1; + + IF api_key.id IS NOT NULL + AND NOT public.is_apikey_expired(api_key.expires_at) + THEN + SELECT public.apps.app_id + INTO fallback_app_id + FROM public.role_bindings rb + JOIN public.apps ON public.apps.id = rb.app_id + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = api_key.rbac_id + AND rb.scope_type = public.rbac_scope_app() + AND rb.app_id IS NOT NULL + AND public.apps.owner_org = orgid + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ORDER BY public.apps.app_id + LIMIT 1; + + IF fallback_app_id IS NOT NULL THEN + has_org_read := public.cli_check_permission( + permission_key := public.rbac_perm_app_read(), + org_id := orgid, + app_id := fallback_app_id + ); + END IF; + END IF; + END IF; + END IF; + + IF NOT has_org_read THEN + messages := array_append(messages, jsonb_build_object( + 'message', 'API key does not have read access to this organization', + 'fatal', true + )); + RETURN messages; + END IF; + + IF ( + public.is_paying_and_good_plan_org_action(orgid, ARRAY['mau']::public.action_type[]) = true + AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::public.action_type[]) = true + AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::public.action_type[]) = false + ) THEN + messages := array_append(messages, jsonb_build_object( + 'message', 'You have exceeded your storage limit.\nUpload will fail, but you can still download your data.\nMAU and bandwidth limits are not exceeded.\nIn order to upload your plan, please upgrade your plan here: https://console.capgo.app/settings/plans.', + 'fatal', true + )); + END IF; + + RETURN messages; +END; +$$; + + +ALTER FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") IS 'CLI compatibility warning helper backed by RBAC API key bindings. App-scoped V2 keys are accepted for old CLI warning checks when they can read at least one app in the requested org.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_orgs_v6"() RETURNS TABLE("gid" "uuid", "created_by" "uuid", "logo" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "use_new_rbac" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_api_key_text text; + v_api_key public.apikeys%ROWTYPE; +BEGIN + SELECT public.get_apikey_header() INTO v_api_key_text; + IF v_api_key_text IS NOT NULL THEN + SELECT * INTO v_api_key FROM public.find_apikey_by_value(v_api_key_text) LIMIT 1; + IF v_api_key.id IS NULL THEN + RAISE EXCEPTION 'Invalid API key provided'; + END IF; + IF public.is_apikey_expired(v_api_key.expires_at) THEN + RAISE EXCEPTION 'API key has expired'; + END IF; + v_user_id := v_api_key.user_id; + ELSE + SELECT public.get_identity() INTO v_user_id; + END IF; + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'No authentication provided - API key or valid session required'; + END IF; + + RETURN QUERY + SELECT + orgs.gid, + orgs.created_by, + orgs.logo, + orgs.name, + orgs.role, + orgs.paying, + orgs.trial_left, + orgs.can_use_more, + orgs.is_canceled, + orgs.app_count, + orgs.subscription_start, + orgs.subscription_end, + orgs.management_email, + orgs.is_yearly, + orgs.use_new_rbac + FROM public.get_orgs_v7(v_user_id) orgs + JOIN public.get_user_org_ids() allowed_orgs ON allowed_orgs.org_id = orgs.gid; +END; +$$; + + +ALTER FUNCTION "public"."get_orgs_v6"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_orgs_v6"() IS 'Legacy V6 organization shape for old CLI compatibility. Authorization is backed by RBAC.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_orgs_v6"("userid" "uuid") RETURNS TABLE("gid" "uuid", "created_by" "uuid", "logo" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "use_new_rbac" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + orgs.gid, + orgs.created_by, + orgs.logo, + orgs.name, + orgs.role, + orgs.paying, + orgs.trial_left, + orgs.can_use_more, + orgs.is_canceled, + orgs.app_count, + orgs.subscription_start, + orgs.subscription_end, + orgs.management_email, + orgs.is_yearly, + orgs.use_new_rbac + FROM public.get_orgs_v7(userid) orgs; +END; +$$; + + +ALTER FUNCTION "public"."get_orgs_v6"("userid" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_orgs_v6"("userid" "uuid") IS 'Legacy V6 organization shape for service-role compatibility. Authorization is backed by RBAC via get_orgs_v7.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_orgs_v7"() RETURNS TABLE("gid" "uuid", "created_by" "uuid", "created_at" timestamp with time zone, "logo" "text", "website" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "stats_updated_at" timestamp without time zone, "stats_refresh_requested_at" timestamp without time zone, "next_stats_update_at" timestamp with time zone, "credit_available" numeric, "credit_total" numeric, "credit_next_expiration" timestamp with time zone, "enforcing_2fa" boolean, "2fa_has_access" boolean, "enforce_hashed_api_keys" boolean, "password_policy_config" "jsonb", "password_has_access" boolean, "require_apikey_expiration" boolean, "max_apikey_expiration_days" integer, "enforce_encrypted_bundles" boolean, "required_encryption_key" character varying, "use_new_rbac" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_api_key_text text; + v_api_key public.apikeys%ROWTYPE; +BEGIN + SELECT public.get_apikey_header() INTO v_api_key_text; + IF v_api_key_text IS NOT NULL THEN + SELECT * INTO v_api_key FROM public.find_apikey_by_value(v_api_key_text) LIMIT 1; + IF v_api_key.id IS NULL THEN + RAISE EXCEPTION 'Invalid API key provided'; + END IF; + IF public.is_apikey_expired(v_api_key.expires_at) THEN + RAISE EXCEPTION 'API key has expired'; + END IF; + v_user_id := v_api_key.user_id; + ELSE + SELECT public.get_identity() INTO v_user_id; + END IF; + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'No authentication provided - API key or valid session required'; + END IF; + + RETURN QUERY + SELECT orgs.* + FROM public.get_orgs_v7(v_user_id) orgs + JOIN public.get_user_org_ids() allowed_orgs ON allowed_orgs.org_id = orgs.gid; +END; +$$; + + +ALTER FUNCTION "public"."get_orgs_v7"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_orgs_v7"("userid" "uuid") RETURNS TABLE("gid" "uuid", "created_by" "uuid", "created_at" timestamp with time zone, "logo" "text", "website" "text", "name" "text", "role" character varying, "paying" boolean, "trial_left" integer, "can_use_more" boolean, "is_canceled" boolean, "app_count" bigint, "subscription_start" timestamp with time zone, "subscription_end" timestamp with time zone, "management_email" "text", "is_yearly" boolean, "stats_updated_at" timestamp without time zone, "stats_refresh_requested_at" timestamp without time zone, "next_stats_update_at" timestamp with time zone, "credit_available" numeric, "credit_total" numeric, "credit_next_expiration" timestamp with time zone, "enforcing_2fa" boolean, "2fa_has_access" boolean, "enforce_hashed_api_keys" boolean, "password_policy_config" "jsonb", "password_has_access" boolean, "require_apikey_expiration" boolean, "max_apikey_expiration_days" integer, "enforce_encrypted_bundles" boolean, "required_encryption_key" character varying, "use_new_rbac" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + WITH app_counts AS ( + SELECT owner_org, COUNT(*) AS cnt + FROM public.apps + GROUP BY owner_org + ), + rbac_role_candidates AS ( + SELECT rb.org_id, r.name, r.priority_rank + FROM public.role_bindings rb + JOIN public.roles r ON rb.role_id = r.id + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = userid + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + UNION ALL + SELECT rb.org_id, r.name, r.priority_rank + FROM public.role_bindings rb + JOIN public.group_members gm ON gm.group_id = rb.principal_id + JOIN public.roles r ON rb.role_id = r.id + WHERE rb.principal_type = public.rbac_principal_group() + AND gm.user_id = userid + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ), + rbac_org_roles AS ( + SELECT org_id, (ARRAY_AGG(rbac_role_candidates.name ORDER BY rbac_role_candidates.priority_rank DESC))[1] AS role_name + FROM rbac_role_candidates + GROUP BY org_id + ), + rbac_org_ids AS ( + SELECT org_id + FROM rbac_org_roles + UNION + SELECT apps.owner_org + FROM public.role_bindings rb + JOIN public.apps ON apps.id = rb.app_id + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = userid + AND rb.app_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + UNION + SELECT apps.owner_org + FROM public.role_bindings rb + JOIN public.channels ch ON ch.rbac_id = rb.channel_id + JOIN public.apps ON apps.app_id = ch.app_id + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = userid + AND rb.channel_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + UNION + SELECT rb.org_id + FROM public.role_bindings rb + JOIN public.group_members gm ON gm.group_id = rb.principal_id + WHERE rb.principal_type = public.rbac_principal_group() + AND gm.user_id = userid + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + UNION + SELECT apps.owner_org + FROM public.role_bindings rb + JOIN public.group_members gm ON gm.group_id = rb.principal_id + JOIN public.apps ON apps.id = rb.app_id + WHERE rb.principal_type = public.rbac_principal_group() + AND gm.user_id = userid + AND rb.app_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + UNION + SELECT apps.owner_org + FROM public.role_bindings rb + JOIN public.group_members gm ON gm.group_id = rb.principal_id + JOIN public.channels ch ON ch.rbac_id = rb.channel_id + JOIN public.apps ON apps.app_id = ch.app_id + WHERE rb.principal_type = public.rbac_principal_group() + AND gm.user_id = userid + AND rb.channel_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ), + user_orgs AS ( + SELECT rbac_org_ids.org_id + FROM rbac_org_ids + WHERE rbac_org_ids.org_id IS NOT NULL + UNION + SELECT ou.org_id + FROM public.org_users ou + WHERE ou.user_id = userid + AND ou.user_right::text LIKE 'invite_%' + ), + time_constants AS ( + SELECT + NOW() AS current_time, + date_trunc('MONTH', NOW()) AS current_month_start, + '0 DAYS'::INTERVAL AS zero_day_interval + ), + paying_orgs_ordered AS ( + SELECT + o.id, + ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 AS preceding_count + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + CROSS JOIN time_constants tc + WHERE ( + (si.status = 'succeeded' + AND (si.canceled_at IS NULL OR si.canceled_at > tc.current_time) + AND si.subscription_anchor_end > tc.current_time) + OR si.trial_at > tc.current_time + ) + ), + billing_cycles AS ( + SELECT + o.id AS org_id, + CASE + WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) + > tc.current_time - tc.current_month_start + THEN date_trunc('MONTH', tc.current_time - INTERVAL '1 MONTH') + + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) + ELSE tc.current_month_start + + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), tc.zero_day_interval) + END AS cycle_start + FROM public.orgs o + CROSS JOIN time_constants tc + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + ), + two_fa_access AS ( + SELECT + o.id AS org_id, + o.enforcing_2fa, + CASE + WHEN o.enforcing_2fa = false THEN true + ELSE public.has_2fa_enabled(userid) + END AS "2fa_has_access", + (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa + FROM public.orgs o + JOIN user_orgs uo ON uo.org_id = o.id + ), + password_policy_access AS ( + SELECT + o.id AS org_id, + o.password_policy_config, + public.user_meets_password_policy(userid, o.id) AS password_has_access, + NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password + FROM public.orgs o + JOIN user_orgs uo ON uo.org_id = o.id + ) + SELECT + o.id AS gid, + o.created_by, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz + ELSE o.created_at + END AS created_at, + o.logo, + o.website, + o.name, + COALESCE(ou.user_right::varchar, ror.role_name::varchar, public.rbac_role_org_member()::varchar) AS role, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false + ELSE COALESCE(si.status = 'succeeded', false) + END AS paying, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 + ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer + END AS trial_left, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false + ELSE COALESCE((si.status = 'succeeded' AND si.is_good_plan = true) + OR (si.trial_at::date - NOW()::date > 0) + OR COALESCE(ucb.available_credits, 0) > 0, false) + END AS can_use_more, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false + ELSE COALESCE(si.status = 'canceled', false) + END AS is_canceled, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint + ELSE COALESCE(ac.cnt, 0) + END AS app_count, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz + ELSE bc.cycle_start + END AS subscription_start, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz + ELSE (bc.cycle_start + INTERVAL '1 MONTH') + END AS subscription_end, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text + ELSE o.management_email + END AS management_email, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false + ELSE COALESCE(si.price_id = p.price_y_id, false) + END AS is_yearly, + o.stats_updated_at, + o.stats_refresh_requested_at, + CASE + WHEN poo.id IS NOT NULL THEN + public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) + ELSE NULL + END AS next_stats_update_at, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric + ELSE COALESCE(ucb.available_credits, 0) + END AS credit_available, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric + ELSE COALESCE(ucb.total_credits, 0) + END AS credit_total, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz + ELSE ucb.next_expiration + END AS credit_next_expiration, + tfa.enforcing_2fa, + tfa."2fa_has_access", + o.enforce_hashed_api_keys, + ppa.password_policy_config, + ppa.password_has_access, + o.require_apikey_expiration, + o.max_apikey_expiration_days, + o.enforce_encrypted_bundles, + o.required_encryption_key, + true AS use_new_rbac + FROM public.orgs o + JOIN user_orgs uo ON uo.org_id = o.id + LEFT JOIN public.org_users ou + ON ou.user_id = userid + AND o.id = ou.org_id + AND ou.user_right::text LIKE 'invite_%' + LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id + LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id + LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + LEFT JOIN public.plans p ON si.product_id = p.stripe_id + LEFT JOIN app_counts ac ON ac.owner_org = o.id + LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id + LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id + LEFT JOIN billing_cycles bc ON bc.org_id = o.id; +END; +$$; + + +ALTER FUNCTION "public"."get_orgs_v7"("userid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_owner_org_by_app_id_internal"("p_app_id" "text") RETURNS "uuid" + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ + SELECT owner_org FROM public.apps WHERE apps.app_id = p_app_id LIMIT 1; +$$; + + +ALTER FUNCTION "public"."get_owner_org_by_app_id_internal"("p_app_id" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_owner_org_by_app_id_internal"("p_app_id" "text") IS 'Internal helper for the auto_owner_org_by_app_id trigger only. Resolves the owning org for an app without performing auth checks — the trigger fires after RLS has already validated the caller.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_password_policy_hash"("policy_config" "jsonb") RETURNS "text" + LANGUAGE "plpgsql" IMMUTABLE + SET "search_path" TO '' + AS $$ +BEGIN + IF policy_config IS NULL THEN + RETURN NULL; + END IF; + -- Create a deterministic hash of the policy config + RETURN md5(policy_config::text); +END; +$$; + + +ALTER FUNCTION "public"."get_password_policy_hash"("policy_config" "jsonb") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_plan_usage_and_fit"("orgid" "uuid") RETURNS TABLE("is_good_plan" boolean, "total_percent" double precision, "mau_percent" double precision, "bandwidth_percent" double precision, "storage_percent" double precision, "build_time_percent" double precision) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_start_date date; + v_end_date date; + v_plan_mau bigint; + v_plan_bandwidth bigint; + v_plan_storage bigint; + v_plan_build_time bigint; + v_anchor_day integer; + v_current_month_start date; + v_current_month_anchor date; + v_target_month_start date; + v_target_month_last_day date; + v_next_target_month_start date; + v_next_target_month_last_day date; + v_plan_name text; + total_stats RECORD; + percent_mau double precision; + percent_bandwidth double precision; + percent_storage double precision; + percent_build_time double precision; + v_is_good_plan boolean; +BEGIN + SELECT + COALESCE(EXTRACT(DAY FROM si.subscription_anchor_start)::integer, 1), + p.mau, + p.bandwidth, + p.storage, + p.build_time_unit, + p.name + INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time, v_plan_name + FROM public.orgs o + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + LEFT JOIN public.plans p ON si.product_id = p.stripe_id + WHERE o.id = orgid; + + v_current_month_start := date_trunc('MONTH', NOW())::date; + v_current_month_anchor := v_current_month_start + ( + LEAST( + v_anchor_day, + EXTRACT(DAY FROM (v_current_month_start + INTERVAL '1 MONTH - 1 day'))::integer + ) - 1 + ); + + IF NOW()::date < v_current_month_anchor THEN + v_target_month_start := (v_current_month_start - INTERVAL '1 MONTH')::date; + ELSE + v_target_month_start := v_current_month_start; + END IF; + + v_target_month_last_day := (v_target_month_start + INTERVAL '1 MONTH - 1 day')::date; + v_start_date := v_target_month_start + ( + LEAST(v_anchor_day, EXTRACT(DAY FROM v_target_month_last_day)::integer) - 1 + ); + + v_next_target_month_start := (v_target_month_start + INTERVAL '1 MONTH')::date; + v_next_target_month_last_day := (v_next_target_month_start + INTERVAL '1 MONTH - 1 day')::date; + v_end_date := v_next_target_month_start + ( + LEAST(v_anchor_day, EXTRACT(DAY FROM v_next_target_month_last_day)::integer) - 1 + ); + + SELECT * INTO total_stats + FROM public.get_total_metrics(orgid, v_start_date, v_end_date); + + percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); + percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); + percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); + percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); + + IF v_plan_name = 'Enterprise' THEN + v_is_good_plan := TRUE; + ELSIF v_plan_name IS NULL THEN + v_is_good_plan := FALSE; + ELSE + v_is_good_plan := v_plan_mau >= total_stats.mau + AND v_plan_bandwidth >= total_stats.bandwidth + AND v_plan_storage >= total_stats.storage + AND v_plan_build_time >= COALESCE(total_stats.build_time_unit, 0); + END IF; + + RETURN QUERY SELECT + v_is_good_plan, + GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), + percent_mau, + percent_bandwidth, + percent_storage, + percent_build_time; +END; +$$; + + +ALTER FUNCTION "public"."get_plan_usage_and_fit"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_plan_usage_and_fit_uncached"("orgid" "uuid") RETURNS TABLE("is_good_plan" boolean, "total_percent" double precision, "mau_percent" double precision, "bandwidth_percent" double precision, "storage_percent" double precision, "build_time_percent" double precision) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_start_date date; + v_end_date date; + v_plan_mau bigint; + v_plan_bandwidth bigint; + v_plan_storage bigint; + v_plan_build_time bigint; + v_anchor_day integer; + v_current_month_start date; + v_current_month_anchor date; + v_target_month_start date; + v_target_month_last_day date; + v_next_target_month_start date; + v_next_target_month_last_day date; + v_plan_name text; + total_stats RECORD; + percent_mau double precision; + percent_bandwidth double precision; + percent_storage double precision; + percent_build_time double precision; + v_is_good_plan boolean; +BEGIN + SELECT + COALESCE(EXTRACT(DAY FROM si.subscription_anchor_start)::integer, 1), + p.mau, + p.bandwidth, + p.storage, + p.build_time_unit, + p.name + INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time, v_plan_name + FROM public.orgs o + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + LEFT JOIN public.plans p ON si.product_id = p.stripe_id + WHERE o.id = orgid; + + v_current_month_start := date_trunc('MONTH', NOW())::date; + v_current_month_anchor := v_current_month_start + ( + LEAST( + v_anchor_day, + EXTRACT(DAY FROM (v_current_month_start + INTERVAL '1 MONTH - 1 day'))::integer + ) - 1 + ); + + IF NOW()::date < v_current_month_anchor THEN + v_target_month_start := (v_current_month_start - INTERVAL '1 MONTH')::date; + ELSE + v_target_month_start := v_current_month_start; + END IF; + + v_target_month_last_day := (v_target_month_start + INTERVAL '1 MONTH - 1 day')::date; + v_start_date := v_target_month_start + ( + LEAST(v_anchor_day, EXTRACT(DAY FROM v_target_month_last_day)::integer) - 1 + ); + + v_next_target_month_start := (v_target_month_start + INTERVAL '1 MONTH')::date; + v_next_target_month_last_day := (v_next_target_month_start + INTERVAL '1 MONTH - 1 day')::date; + v_end_date := v_next_target_month_start + ( + LEAST(v_anchor_day, EXTRACT(DAY FROM v_next_target_month_last_day)::integer) - 1 + ); + + SELECT * INTO total_stats + FROM public.seed_org_metrics_cache(orgid, v_start_date, v_end_date); + + percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); + percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); + percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); + percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); + + IF v_plan_name = 'Enterprise' THEN + v_is_good_plan := TRUE; + ELSIF v_plan_name IS NULL THEN + v_is_good_plan := FALSE; + ELSE + v_is_good_plan := v_plan_mau >= total_stats.mau + AND v_plan_bandwidth >= total_stats.bandwidth + AND v_plan_storage >= total_stats.storage + AND v_plan_build_time >= COALESCE(total_stats.build_time_unit, 0); + END IF; + + RETURN QUERY SELECT + v_is_good_plan, + GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), + percent_mau, + percent_bandwidth, + percent_storage, + percent_build_time; +END; +$$; + + +ALTER FUNCTION "public"."get_plan_usage_and_fit_uncached"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid") RETURNS TABLE("total_percent" double precision, "mau_percent" double precision, "bandwidth_percent" double precision, "storage_percent" double precision, "build_time_percent" double precision) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_start_date date; + v_end_date date; + v_plan_mau bigint; + v_plan_bandwidth bigint; + v_plan_storage bigint; + v_plan_build_time bigint; + v_anchor_day interval; + total_stats record; + percent_mau double precision; + percent_bandwidth double precision; + percent_storage double precision; + percent_build_time double precision; + v_request_user uuid; + v_is_service_role boolean; + v_tx_read_only boolean := current_setting('transaction_read_only') = 'on'; +BEGIN + v_is_service_role := ( + ((SELECT auth.jwt() ->> 'role') = 'service_role') + OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') + ); + + IF NOT v_is_service_role THEN + v_request_user := public.get_identity_org_allowed( + '{read,upload,write,all}'::public.key_mode[], + get_plan_usage_percent_detailed.orgid + ); + + IF v_request_user IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + v_request_user, + get_plan_usage_percent_detailed.orgid, + NULL::varchar, + NULL::bigint + ) THEN + RETURN; + END IF; + END IF; + + SELECT + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::interval), + p.mau, + p.bandwidth, + p.storage, + p.build_time_unit + INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time + FROM public.orgs o + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + LEFT JOIN public.plans p ON si.product_id = p.stripe_id + WHERE o.id = orgid; + + IF v_anchor_day > now() - date_trunc('MONTH', now()) THEN + v_start_date := (date_trunc('MONTH', now() - interval '1 MONTH') + v_anchor_day)::date; + ELSE + v_start_date := (date_trunc('MONTH', now()) + v_anchor_day)::date; + END IF; + v_end_date := (v_start_date + interval '1 MONTH')::date; + + IF v_tx_read_only THEN + -- User-facing RPCs must stay read-only so they work from the hardened + -- read-only test harness and replica paths. Internal cache refreshes still + -- happen through get_total_metrics()/get_plan_usage_and_fit(). + SELECT * + INTO total_stats + FROM public.calculate_org_metrics_cache_entry(orgid, v_start_date, v_end_date); + ELSE + SELECT * + INTO total_stats + FROM public.get_total_metrics(orgid, v_start_date, v_end_date); + END IF; + + percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); + percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); + percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); + percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); + + RETURN QUERY + SELECT + GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), + percent_mau, + percent_bandwidth, + percent_storage, + percent_build_time; +END; +$$; + + +ALTER FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid") IS 'Return current-cycle plan usage percentages (total and per metric) for the supplied organization while respecting read permissions and delegating to cached metrics when running in read-only transactions.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid", "cycle_start" "date", "cycle_end" "date") RETURNS TABLE("total_percent" double precision, "mau_percent" double precision, "bandwidth_percent" double precision, "storage_percent" double precision, "build_time_percent" double precision) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_plan_mau bigint; + v_plan_bandwidth bigint; + v_plan_storage bigint; + v_plan_build_time bigint; + total_stats record; + percent_mau double precision; + percent_bandwidth double precision; + percent_storage double precision; + percent_build_time double precision; + v_request_user uuid; + v_is_service_role boolean; + v_tx_read_only boolean := current_setting('transaction_read_only') = 'on'; +BEGIN + v_is_service_role := ( + ((SELECT auth.jwt() ->> 'role') = 'service_role') + OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') + ); + + IF NOT v_is_service_role THEN + v_request_user := public.get_identity_org_allowed( + '{read,upload,write,all}'::public.key_mode[], + get_plan_usage_percent_detailed.orgid + ); + + IF v_request_user IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + v_request_user, + get_plan_usage_percent_detailed.orgid, + NULL::varchar, + NULL::bigint + ) THEN + RETURN; + END IF; + END IF; + + SELECT p.mau, p.bandwidth, p.storage, p.build_time_unit + INTO v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + JOIN public.plans p ON si.product_id = p.stripe_id + WHERE o.id = orgid; + + IF v_tx_read_only THEN + -- Keep this RPC read-only for authenticated callers. Cache refreshes are + -- handled by the internal metrics helpers instead of this public entrypoint. + SELECT * + INTO total_stats + FROM public.calculate_org_metrics_cache_entry(orgid, cycle_start, cycle_end); + ELSE + SELECT * + INTO total_stats + FROM public.get_total_metrics(orgid, cycle_start, cycle_end); + END IF; + + percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau); + percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth); + percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage); + percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time); + + RETURN QUERY + SELECT + GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time), + percent_mau, + percent_bandwidth, + percent_storage, + percent_build_time; +END; +$$; + + +ALTER FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid", "cycle_start" "date", "cycle_end" "date") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid", "cycle_start" "date", "cycle_end" "date") IS 'Return plan usage percentages for the supplied date range after verifying read access; read-only callers stay read-only by using the cached metrics helper.'; + + + +CREATE OR REPLACE FUNCTION "public"."get_sso_enforcement_by_domain"("p_domain" "text") RETURNS TABLE("org_id" "uuid", "enforce_sso" boolean) + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ + SELECT + sp.org_id, + sp.enforce_sso + FROM "public"."sso_providers" sp + JOIN "public"."orgs" o ON o.id = sp.org_id + WHERE sp.domain = lower(btrim(p_domain)) + AND sp.status = 'active' + LIMIT 1; +$$; + + +ALTER FUNCTION "public"."get_sso_enforcement_by_domain"("p_domain" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) RETURNS double precision + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + total_size double precision := 0; + caller_role text; +BEGIN + SELECT public.current_request_role() INTO caller_role; + + IF NOT public.is_internal_request_role(caller_role) THEN + IF NOT public.request_has_app_read_access( + get_total_app_storage_size_orgs.org_id, + get_total_app_storage_size_orgs.app_id + ) THEN + RETURN 0; + END IF; + END IF; + + SELECT COALESCE(SUM(app_versions_meta.size), 0) INTO total_size + FROM public.app_versions + INNER JOIN public.app_versions_meta ON app_versions.id = app_versions_meta.id + WHERE app_versions.owner_org = org_id + AND app_versions.app_id = get_total_app_storage_size_orgs.app_id + AND app_versions.deleted = false; + + RETURN total_size; +END; +$$; + + +ALTER FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_total_metrics"() RETURNS TABLE("mau" bigint, "storage" bigint, "bandwidth" bigint, "build_time_unit" bigint, "get" bigint, "fail" bigint, "install" bigint, "uninstall" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_request_org_id uuid; + v_org_id_text text; + v_auth_uid uuid; + v_request_apikey text; +BEGIN + SELECT auth.uid() INTO v_auth_uid; + SELECT public.get_apikey_header() INTO v_request_apikey; + + IF v_auth_uid IS NULL AND (v_request_apikey IS NULL OR v_request_apikey = '') THEN + RETURN; + END IF; + + SELECT current_setting('request.jwt.claim.org_id', true) INTO v_org_id_text; + + IF v_org_id_text IS NOT NULL AND v_org_id_text <> '' THEN + BEGIN + v_request_org_id := v_org_id_text::uuid; + EXCEPTION WHEN invalid_text_representation THEN + v_request_org_id := NULL; + END; + END IF; + + IF v_request_org_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM public.get_user_org_ids() allowed_orgs + WHERE allowed_orgs.org_id = v_request_org_id + ) THEN + RETURN; + END IF; + + IF v_request_org_id IS NULL THEN + SELECT allowed_orgs.org_id + INTO v_request_org_id + FROM public.get_user_org_ids() allowed_orgs + ORDER BY allowed_orgs.org_id + LIMIT 1; + END IF; + + IF v_request_org_id IS NULL THEN + RETURN; + END IF; + + RETURN QUERY + SELECT + metrics.mau, + metrics.storage, + metrics.bandwidth, + metrics.build_time_unit, + metrics.get, + metrics.fail, + metrics.install, + metrics.uninstall + FROM public.get_total_metrics(v_request_org_id) AS metrics; +END; +$$; + + +ALTER FUNCTION "public"."get_total_metrics"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_total_metrics"("org_id" "uuid") RETURNS TABLE("mau" bigint, "storage" bigint, "bandwidth" bigint, "build_time_unit" bigint, "get" bigint, "fail" bigint, "install" bigint, "uninstall" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_start_date date; + v_end_date date; + v_anchor_day interval; +BEGIN + SELECT + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) + INTO v_anchor_day + FROM public.orgs o + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + WHERE o.id = get_total_metrics.org_id; + + IF NOT FOUND THEN + RETURN; + END IF; + + IF v_anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN + v_start_date := (date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + v_anchor_day)::date; + ELSE + v_start_date := (date_trunc('MONTH', NOW()) + v_anchor_day)::date; + END IF; + v_end_date := (v_start_date + INTERVAL '1 MONTH')::date; + + RETURN QUERY + SELECT + metrics.mau, + metrics.storage, + metrics.bandwidth, + metrics.build_time_unit, + metrics.get, + metrics.fail, + metrics.install, + metrics.uninstall + FROM public.get_total_metrics(org_id, v_start_date, v_end_date) AS metrics; +END; +$$; + + +ALTER FUNCTION "public"."get_total_metrics"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_total_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") RETURNS TABLE("mau" bigint, "storage" bigint, "bandwidth" bigint, "build_time_unit" bigint, "get" bigint, "fail" bigint, "install" bigint, "uninstall" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + cache_entry public.org_metrics_cache%ROWTYPE; + cache_ttl interval := '5 minutes'::interval; + tx_read_only boolean := COALESCE(current_setting('transaction_read_only', true), 'off') = 'on'; +BEGIN + IF start_date IS NULL OR end_date IS NULL THEN + RETURN; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE orgs.id = get_total_metrics.org_id + ) THEN + RETURN; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_stat_xact_user_tables + WHERE relname IN ( + 'apps', + 'deleted_apps', + 'daily_mau', + 'daily_bandwidth', + 'daily_build_time', + 'daily_version', + 'app_versions', + 'app_versions_meta' + ) + AND (n_tup_ins > 0 OR n_tup_upd > 0 OR n_tup_del > 0) + ) THEN + IF tx_read_only THEN + RETURN QUERY + SELECT + metrics.mau, + metrics.storage, + metrics.bandwidth, + metrics.build_time_unit, + metrics.get, + metrics.fail, + metrics.install, + metrics.uninstall + FROM public.calculate_org_metrics_cache_entry(org_id, start_date, end_date) AS metrics; + RETURN; + END IF; + + cache_entry := public.seed_org_metrics_cache(get_total_metrics.org_id, start_date, end_date); + + RETURN QUERY SELECT + cache_entry.mau, + cache_entry.storage, + cache_entry.bandwidth, + cache_entry.build_time_unit, + cache_entry.get, + cache_entry.fail, + cache_entry.install, + cache_entry.uninstall; + RETURN; + END IF; + + SELECT * INTO cache_entry + FROM public.org_metrics_cache + WHERE org_metrics_cache.org_id = get_total_metrics.org_id; + + IF FOUND + AND cache_entry.start_date = start_date + AND cache_entry.end_date = end_date + AND cache_entry.cached_at > clock_timestamp() - cache_ttl + THEN + RETURN QUERY SELECT + cache_entry.mau, + cache_entry.storage, + cache_entry.bandwidth, + cache_entry.build_time_unit, + cache_entry.get, + cache_entry.fail, + cache_entry.install, + cache_entry.uninstall; + RETURN; + END IF; + + IF tx_read_only THEN + RETURN QUERY + SELECT + metrics.mau, + metrics.storage, + metrics.bandwidth, + metrics.build_time_unit, + metrics.get, + metrics.fail, + metrics.install, + metrics.uninstall + FROM public.calculate_org_metrics_cache_entry(org_id, start_date, end_date) AS metrics; + RETURN; + END IF; + + cache_entry := public.seed_org_metrics_cache(get_total_metrics.org_id, start_date, end_date); + + RETURN QUERY SELECT + cache_entry.mau, + cache_entry.storage, + cache_entry.bandwidth, + cache_entry.build_time_unit, + cache_entry.get, + cache_entry.fail, + cache_entry.install, + cache_entry.uninstall; +END; +$$; + + +ALTER FUNCTION "public"."get_total_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") RETURNS double precision + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + total_size double precision := 0; + caller_role text; +BEGIN + SELECT public.current_request_role() INTO caller_role; + + IF NOT public.is_internal_request_role(caller_role) THEN + IF NOT public.request_has_org_read_access(get_total_storage_size_org.org_id) THEN + RETURN 0; + END IF; + END IF; + + SELECT COALESCE(SUM(app_versions_meta.size), 0) INTO total_size + FROM public.app_versions + INNER JOIN public.app_versions_meta ON app_versions.id = app_versions_meta.id + WHERE app_versions.owner_org = org_id + AND app_versions.deleted = false; + + RETURN total_size; +END; +$$; + + +ALTER FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_update_stats"() RETURNS TABLE("app_id" character varying, "failed" bigint, "install" bigint, "get" bigint, "success_rate" numeric, "healthy" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + WITH stats AS ( + SELECT + version_usage.app_id, + COALESCE(SUM(CASE WHEN action = 'fail' THEN 1 ELSE 0 END), 0) AS failed, + COALESCE(SUM(CASE WHEN action = 'install' THEN 1 ELSE 0 END), 0) AS install, + COALESCE(SUM(CASE WHEN action = 'get' THEN 1 ELSE 0 END), 0) AS get + FROM + public.version_usage + WHERE + timestamp >= (date_trunc('minute', NOW()) - INTERVAL '10 minutes') + AND timestamp < (date_trunc('minute', NOW()) - INTERVAL '9 minutes') + GROUP BY + version_usage.app_id + ) + SELECT + stats.app_id, + stats.failed, + stats.install, + stats.get, + CASE + WHEN (stats.install + stats.get) > 0 THEN + ROUND((stats.get::numeric / (stats.install + stats.get)) * 100, 2) + ELSE 100 + END AS success_rate, + CASE + WHEN (stats.install + stats.get) > 0 THEN + ((stats.get::numeric / (stats.install + stats.get)) * 100 >= 70) + ELSE true + END AS healthy + FROM + stats + WHERE + stats.get > 0; +END; +$$; + + +ALTER FUNCTION "public"."get_update_stats"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_user_id"("apikey" "text") RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key record; +BEGIN + -- Use find_apikey_by_value to support both plain and hashed keys + SELECT * FROM public.find_apikey_by_value(apikey) INTO api_key; + + IF api_key.id IS NOT NULL THEN + -- Check if key is expired + IF public.is_apikey_expired(api_key.expires_at) THEN + RETURN NULL; + END IF; + RETURN api_key.user_id; + END IF; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."get_user_id"("apikey" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_user_id"("apikey" "text", "app_id" "text") RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE real_user_id uuid; +BEGIN + PERFORM app_id; + SELECT public.get_user_id(apikey) INTO real_user_id; + RETURN real_user_id; +END; +$$; + + +ALTER FUNCTION "public"."get_user_id"("apikey" "text", "app_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_id uuid; + caller_role text; + caller_id uuid; +BEGIN + SELECT public.current_request_role() INTO caller_role; + + IF NOT public.is_internal_request_role(caller_role) THEN + SELECT auth.uid() INTO caller_id; + IF caller_id IS NULL OR caller_id <> get_user_main_org_id.user_id THEN + RETURN NULL; + END IF; + END IF; + + SELECT orgs.id + INTO org_id + FROM public.orgs + WHERE orgs.created_by = get_user_main_org_id.user_id + LIMIT 1; + + RETURN org_id; +END; +$$; + + +ALTER FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_id uuid; + auth_uid uuid; + auth_role text; + api_user_id uuid; +BEGIN + SELECT apps.owner_org INTO org_id + FROM public.apps + WHERE ((apps.app_id)::text = (get_user_main_org_id_by_app_id.app_id)::text) + LIMIT 1; + + IF org_id IS NULL THEN + RETURN NULL; + END IF; + + -- Allow trusted DB roles (seed/migrations) without JWT context + IF session_user IN ('postgres', 'supabase_admin') THEN + RETURN org_id; + END IF; + + SELECT auth.uid() INTO auth_uid; + IF auth_uid IS NOT NULL THEN + IF public.check_min_rights('read'::public.user_min_right, auth_uid, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN + RETURN org_id; + END IF; + RETURN NULL; + END IF; + + SELECT auth.role() INTO auth_role; + IF auth_role = 'service_role' THEN + RETURN org_id; + END IF; + + SELECT public.get_identity_org_appid('{read,upload,write,all}'::public.key_mode[], org_id, get_user_main_org_id_by_app_id.app_id) INTO api_user_id; + IF api_user_id IS NULL THEN + RETURN NULL; + END IF; + + IF public.check_min_rights('read'::public.user_min_right, api_user_id, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN + RETURN org_id; + END IF; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_user_org_ids"() RETURNS TABLE("org_id" "uuid") + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key_text text; + api_key public.apikeys%ROWTYPE; + v_user_id uuid; +BEGIN + SELECT public.get_apikey_header() INTO api_key_text; + + IF api_key_text IS NOT NULL THEN + SELECT * INTO api_key FROM public.find_apikey_by_value(api_key_text) LIMIT 1; + IF api_key.id IS NULL THEN + RAISE EXCEPTION 'Invalid API key provided'; + END IF; + IF public.is_apikey_expired(api_key.expires_at) THEN + RAISE EXCEPTION 'API key has expired'; + END IF; + + RETURN QUERY + SELECT DISTINCT scoped.org_uuid + FROM ( + SELECT rb.org_id AS org_uuid + FROM public.role_bindings rb + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = api_key.rbac_id + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + + UNION + + SELECT apps.owner_org AS org_uuid + FROM public.role_bindings rb + JOIN public.apps ON apps.id = rb.app_id + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = api_key.rbac_id + AND rb.app_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + + UNION + + SELECT apps.owner_org AS org_uuid + FROM public.role_bindings rb + JOIN public.channels ch ON ch.rbac_id = rb.channel_id + JOIN public.apps ON apps.app_id = ch.app_id + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = api_key.rbac_id + AND rb.channel_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ) scoped + WHERE scoped.org_uuid IS NOT NULL; + RETURN; + END IF; + + SELECT public.get_identity() INTO v_user_id; + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'No authentication provided - API key or valid session required'; + END IF; + + RETURN QUERY + SELECT DISTINCT scoped.org_uuid + FROM ( + SELECT rb.org_id AS org_uuid + FROM public.role_bindings rb + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = v_user_id + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + + UNION + + SELECT apps.owner_org AS org_uuid + FROM public.role_bindings rb + JOIN public.apps ON apps.id = rb.app_id + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = v_user_id + AND rb.app_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + + UNION + + SELECT rb.org_id AS org_uuid + FROM public.role_bindings rb + JOIN public.group_members gm ON gm.group_id = rb.principal_id + WHERE rb.principal_type = public.rbac_principal_group() + AND gm.user_id = v_user_id + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + + UNION + + SELECT ou.org_id AS org_uuid + FROM public.org_users ou + WHERE ou.user_id = v_user_id + AND ou.user_right::text LIKE 'invite_%' + ) scoped + WHERE scoped.org_uuid IS NOT NULL; +END; +$$; + + +ALTER FUNCTION "public"."get_user_org_ids"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_user_org_ids"() IS 'Org id list for authenticated users or RBAC-scoped API keys.'; + + + +CREATE TABLE IF NOT EXISTS "public"."app_versions" ( + "id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"(), + "app_id" character varying NOT NULL, + "name" character varying NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted" boolean DEFAULT false NOT NULL, + "external_url" character varying, + "checksum" character varying, + "session_key" character varying, + "storage_provider" "text" DEFAULT 'r2'::"text" NOT NULL, + "min_update_version" character varying, + "native_packages" "jsonb"[], + "owner_org" "uuid" NOT NULL, + "user_id" "uuid", + "r2_path" character varying, + "manifest" "public"."manifest_entry"[], + "link" "text", + "comment" "text", + "manifest_count" integer DEFAULT 0 NOT NULL, + "key_id" character varying(20), + "cli_version" character varying, + "deleted_at" timestamp with time zone +) +WITH ("autovacuum_vacuum_scale_factor"='0.05', "autovacuum_analyze_scale_factor"='0.02'); + + +ALTER TABLE "public"."app_versions" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."app_versions"."key_id" IS 'First 20 characters of the base64-encoded public key used to encrypt this bundle (identifies which key was used for encryption)'; + + + +COMMENT ON COLUMN "public"."app_versions"."cli_version" IS 'The version of @capgo/cli used to upload this bundle'; + + + +CREATE OR REPLACE FUNCTION "public"."get_versions_with_no_metadata"() RETURNS SETOF "public"."app_versions" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT app_versions.* FROM public.app_versions + LEFT JOIN public.app_versions_meta ON app_versions_meta.id=app_versions.id + WHERE COALESCE(app_versions_meta.size, 0) = 0 + AND app_versions.deleted=false + AND app_versions.storage_provider != 'external' + AND NOW() - app_versions.created_at > interval '120 seconds'; +END; +$$; + + +ALTER FUNCTION "public"."get_versions_with_no_metadata"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_weekly_stats"("app_id" character varying) RETURNS TABLE("all_updates" bigint, "failed_updates" bigint, "open_app" bigint) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE seven_days_ago DATE; +BEGIN + seven_days_ago := CURRENT_DATE - INTERVAL '7 days'; + SELECT COALESCE(SUM(install), 0) INTO all_updates FROM public.daily_version WHERE date BETWEEN seven_days_ago AND CURRENT_DATE AND public.daily_version.app_id = get_weekly_stats.app_id; + SELECT COALESCE(SUM(fail), 0) INTO failed_updates FROM public.daily_version WHERE date BETWEEN seven_days_ago AND CURRENT_DATE AND public.daily_version.app_id = get_weekly_stats.app_id; + SELECT COALESCE(SUM(get), 0) INTO open_app FROM public.daily_version WHERE date BETWEEN seven_days_ago AND CURRENT_DATE AND public.daily_version.app_id = get_weekly_stats.app_id; + RETURN QUERY SELECT all_updates, failed_updates, open_app; +END; +$$; + + +ALTER FUNCTION "public"."get_weekly_stats"("app_id" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."guard_owner_org_reassignment"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + IF NEW.owner_org IS DISTINCT FROM OLD.owner_org + AND current_setting('capgo.allow_owner_org_transfer', true) IS DISTINCT FROM 'true' THEN + RAISE EXCEPTION 'owner_org must be changed through public.transfer_app()'; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."guard_owner_org_reassignment"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."has_2fa_enabled"() RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + -- Check if the current user has any verified MFA factors + RETURN EXISTS( + SELECT 1 + FROM auth.mfa_factors + WHERE (SELECT auth.uid()) = user_id + AND status = 'verified' + ); +END; +$$; + + +ALTER FUNCTION "public"."has_2fa_enabled"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + -- Check if the specified user has any verified MFA factors + RETURN EXISTS( + SELECT 1 + FROM auth.mfa_factors mfa + WHERE mfa.user_id = has_2fa_enabled.user_id + AND mfa.status = 'verified' + ); +END; +$$; + + +ALTER FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."has_app_right"("appid" character varying, "right" "public"."user_min_right") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.has_app_right_userid("appid", "right", (SELECT auth.uid())); +END; +$$; + + +ALTER FUNCTION "public"."has_app_right"("appid" character varying, "right" "public"."user_min_right") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."has_app_right_apikey"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid", "apikey" "text") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key public.apikeys%ROWTYPE; + org_id uuid; + permission_key text; +BEGIN + SELECT * INTO api_key FROM public.find_apikey_by_value(apikey) LIMIT 1; + IF api_key.id IS NULL OR api_key.user_id IS DISTINCT FROM userid THEN + RETURN false; + END IF; + + IF public.is_apikey_expired(api_key.expires_at) THEN + RETURN false; + END IF; + + SELECT owner_org INTO org_id + FROM public.apps + WHERE app_id = appid + LIMIT 1; + + IF org_id IS NULL THEN + RETURN false; + END IF; + + permission_key := CASE + WHEN "right" = 'read'::public.user_min_right THEN public.rbac_perm_app_read() + WHEN "right" = 'upload'::public.user_min_right THEN public.rbac_perm_app_upload_bundle() + WHEN "right" = 'write'::public.user_min_right THEN public.rbac_perm_app_update_settings() + ELSE public.rbac_perm_app_update_user_roles() + END; + + RETURN public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, permission_key, org_id, appid, NULL); +END; +$$; + + +ALTER FUNCTION "public"."has_app_right_apikey"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid", "apikey" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."has_app_right_userid"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_id uuid; + allowed boolean; +BEGIN + org_id := public.get_user_main_org_id_by_app_id("appid"); + + allowed := public.check_min_rights("right", "userid", org_id, "appid", NULL::bigint); + IF NOT allowed THEN + PERFORM public.pg_log('deny: HAS_APP_RIGHT_USERID', jsonb_build_object('appid', "appid", 'org_id', org_id, 'right', "right"::text, 'userid', "userid")); + END IF; + RETURN allowed; +END; +$$; + + +ALTER FUNCTION "public"."has_app_right_userid"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."has_seeded_demo_data"("p_app_id" "text") RETURNS boolean + LANGUAGE "sql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.app_versions + INNER JOIN public.manifest + ON public.manifest.app_version_id = public.app_versions.id + WHERE public.app_versions.app_id = p_app_id + AND public.manifest.s3_path LIKE ('demo/' || p_app_id || '/%') + ); +$$; + + +ALTER FUNCTION "public"."has_seeded_demo_data"("p_app_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."internal_request_db_user_names"() RETURNS "text"[] + LANGUAGE "sql" IMMUTABLE + SET "search_path" TO '' + AS $$ + SELECT ARRAY['postgres', 'supabase_admin']::text[] +$$; + + +ALTER FUNCTION "public"."internal_request_db_user_names"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."internal_request_role_names"() RETURNS "text"[] + LANGUAGE "sql" IMMUTABLE + SET "search_path" TO '' + AS $$ + SELECT ARRAY['service_role', 'postgres', 'supabase_admin']::text[] +$$; + + +ALTER FUNCTION "public"."internal_request_role_names"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") RETURNS character varying + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + legacy_right public.user_min_right; + role_name text; +BEGIN + legacy_right := public.transform_role_to_non_invite(invite_type); + role_name := public.rbac_org_role_for_legacy_right(legacy_right); + + RETURN public.invite_user_to_org_rbac(email, org_id, role_name); +END; +$$; + + +ALTER FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") IS 'Compatibility wrapper for old invite callers. Legacy role inputs are converted to RBAC roles.'; + + + +CREATE OR REPLACE FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") RETURNS character varying + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org record; + invited_user record; + current_record record; + current_tmp_user record; + role_id uuid; + role_priority integer; + caller_max_priority integer := 0; + legacy_right public.user_min_right; + invite_right public.user_min_right; + api_key_text text; + api_key_row public.apikeys%ROWTYPE; + v_granted_by uuid; + v_principal_type text; + v_principal_id uuid; +BEGIN + SELECT * INTO org FROM public.orgs WHERE public.orgs.id = invite_user_to_org_rbac.org_id; + IF org IS NULL THEN + RETURN 'NO_ORG'; + END IF; + + SELECT r.id, r.priority_rank INTO role_id, role_priority + FROM public.roles r + WHERE r.name = invite_user_to_org_rbac.role_name + AND r.scope_type = public.rbac_scope_org() + AND r.is_assignable = true + LIMIT 1; + + IF role_id IS NULL THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + SELECT public.get_apikey_header() INTO api_key_text; + IF api_key_text IS NOT NULL THEN + SELECT * INTO api_key_row FROM public.find_apikey_by_value(api_key_text) LIMIT 1; + v_granted_by := api_key_row.user_id; + v_principal_type := public.rbac_principal_apikey(); + v_principal_id := api_key_row.rbac_id; + ELSE + v_granted_by := auth.uid(); + v_principal_type := public.rbac_principal_user(); + v_principal_id := auth.uid(); + END IF; + + IF invite_user_to_org_rbac.role_name = public.rbac_role_org_super_admin() THEN + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN + RETURN 'NO_RIGHTS'; + END IF; + ELSE + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_invite_user(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN + RETURN 'NO_RIGHTS'; + END IF; + END IF; + + IF v_principal_id IS NULL THEN + RETURN 'NO_RIGHTS'; + END IF; + + SELECT COALESCE(MAX(r.priority_rank), 0) INTO caller_max_priority + FROM public.role_bindings rb + JOIN public.roles r + ON r.id = rb.role_id + AND r.scope_type = rb.scope_type + WHERE rb.principal_type = v_principal_type + AND rb.principal_id = v_principal_id + AND rb.org_id = invite_user_to_org_rbac.org_id + AND (rb.expires_at IS NULL OR rb.expires_at > now()); + + IF caller_max_priority < role_priority THEN + RETURN 'NO_RIGHTS'; + END IF; + + legacy_right := public.rbac_legacy_right_for_org_role(invite_user_to_org_rbac.role_name); + invite_right := public.transform_role_to_invite(legacy_right); + + SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email = invite_user_to_org_rbac.email; + + IF invited_user IS NOT NULL THEN + SELECT public.org_users.id INTO current_record + FROM public.org_users + WHERE public.org_users.user_id = invited_user.id + AND public.org_users.org_id = invite_user_to_org_rbac.org_id; + + IF current_record IS NOT NULL THEN + RETURN 'ALREADY_INVITED'; + ELSE + INSERT INTO public.org_users (user_id, org_id, user_right, rbac_role_name) + VALUES (invited_user.id, invite_user_to_org_rbac.org_id, invite_right, invite_user_to_org_rbac.role_name); + + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, granted_at, expires_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), invited_user.id, role_id, public.rbac_scope_org(), invite_user_to_org_rbac.org_id, + COALESCE(v_granted_by, invited_user.id), now(), now() - INTERVAL '1 second', 'Pending invitation', true + ) ON CONFLICT DO NOTHING; + + RETURN 'OK'; + END IF; + ELSE + SELECT * INTO current_tmp_user + FROM public.tmp_users + WHERE public.tmp_users.email = invite_user_to_org_rbac.email + AND public.tmp_users.org_id = invite_user_to_org_rbac.org_id; + + IF current_tmp_user IS NOT NULL THEN + IF current_tmp_user.cancelled_at IS NOT NULL THEN + IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN + RETURN 'TOO_RECENT_INVITATION_CANCELATION'; + ELSE + RETURN 'NO_EMAIL'; + END IF; + ELSE + RETURN 'ALREADY_INVITED'; + END IF; + ELSE + RETURN 'NO_EMAIL'; + END IF; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") IS ' +Invite a user to an organization using RBAC roles while preserving legacy invite +flow. +'; + + + +CREATE OR REPLACE FUNCTION "public"."is_account_disabled"("user_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + caller_id uuid; +BEGIN + SELECT public.current_request_role() INTO caller_role; + + IF NOT public.is_internal_request_role(caller_role) THEN + SELECT auth.uid() INTO caller_id; + IF caller_id IS NULL OR caller_id <> is_account_disabled.user_id THEN + RETURN false; + END IF; + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM public.to_delete_accounts + WHERE account_id = user_id + ); +END; +$$; + + +ALTER FUNCTION "public"."is_account_disabled"("user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_allowed_action"("apikey" "text", "appid" "text") RETURNS boolean + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + PERFORM apikey; + RETURN public.is_allowed_action_org((select owner_org FROM public.apps where app_id=appid)); +END; +$$; + + +ALTER FUNCTION "public"."is_allowed_action"("apikey" "text", "appid" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_allowed_action_org"("orgid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.is_paying_and_good_plan_org(orgid); +END; +$$; + + +ALTER FUNCTION "public"."is_allowed_action_org"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.is_paying_and_good_plan_org_action(orgid, actions); +END; +$$; + + +ALTER FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.is_paying_and_good_plan_org_action(orgid, actions, appid); +END; +$$; + + +ALTER FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[]) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key public.apikeys%ROWTYPE; + required_org_permission text; + required_app_permission text; +BEGIN + SELECT * INTO api_key FROM public.find_apikey_by_value(apikey) LIMIT 1; + IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + RETURN false; + END IF; + + required_org_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_org()); + required_app_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_app()); + + RETURN EXISTS ( + SELECT 1 + FROM public.role_bindings rb + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = api_key.rbac_id + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + AND public.rbac_has_permission( + public.rbac_principal_apikey(), + api_key.rbac_id, + required_org_permission, + rb.org_id, + NULL::character varying, + NULL::bigint + ) + ) + OR EXISTS ( + SELECT 1 + FROM public.role_bindings rb + JOIN public.apps ON public.apps.id = rb.app_id + WHERE rb.principal_type = public.rbac_principal_apikey() + AND rb.principal_id = api_key.rbac_id + AND rb.app_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + AND public.rbac_has_permission( + public.rbac_principal_apikey(), + api_key.rbac_id, + required_app_permission, + public.apps.owner_org, + public.apps.app_id, + NULL::bigint + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[]) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[], "app_id" character varying) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + api_key public.apikeys%ROWTYPE; + app_org_id uuid; + required_permission text; +BEGIN + SELECT * INTO api_key FROM public.find_apikey_by_value(apikey) LIMIT 1; + IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + RETURN false; + END IF; + + SELECT owner_org INTO app_org_id + FROM public.apps + WHERE apps.app_id = is_allowed_capgkey.app_id + LIMIT 1; + + IF app_org_id IS NULL THEN + RETURN false; + END IF; + + required_permission := public.apikey_permission_for_keymode(keymode, public.rbac_scope_app()); + RETURN public.rbac_has_permission(public.rbac_principal_apikey(), api_key.rbac_id, required_permission, app_org_id, app_id, NULL); +END; +$$; + + +ALTER FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[], "app_id" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_apikey_expired"("key_expires_at" timestamp with time zone) RETURNS boolean + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + -- NULL expires_at means key never expires + IF key_expires_at IS NULL THEN + RETURN false; + END IF; + + -- Check if current time is past expiration + RETURN NOW() > key_expires_at; +END; +$$; + + +ALTER FUNCTION "public"."is_apikey_expired"("key_expires_at" timestamp with time zone) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_app_owner"("appid" character varying) RETURNS boolean + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.is_app_owner((SELECT auth.uid()), appid); +END; +$$; + + +ALTER FUNCTION "public"."is_app_owner"("appid" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_app_owner"("apikey" "text", "appid" character varying) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.is_app_owner(public.get_user_id(apikey), appid); +END; +$$; + + +ALTER FUNCTION "public"."is_app_owner"("apikey" "text", "appid" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_app_owner"("userid" "uuid", "appid" character varying) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (SELECT EXISTS (SELECT 1 + FROM public.apps + WHERE app_id=appid + AND user_id=userid)); +END; +$$; + + +ALTER FUNCTION "public"."is_app_owner"("userid" "uuid", "appid" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_bandwidth_exceeded_by_org"("org_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (SELECT bandwidth_exceeded + FROM public.stripe_info + WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = is_bandwidth_exceeded_by_org.org_id)); +END; +$$; + + +ALTER FUNCTION "public"."is_bandwidth_exceeded_by_org"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_build_time_exceeded_by_org"("org_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (SELECT build_time_exceeded FROM public.stripe_info + WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = is_build_time_exceeded_by_org.org_id)); +END; +$$; + + +ALTER FUNCTION "public"."is_build_time_exceeded_by_org"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_bundle_encrypted"("session_key" "text") RETURNS boolean + LANGUAGE "plpgsql" IMMUTABLE + SET "search_path" TO '' + AS $$ +BEGIN + -- A bundle is considered encrypted if session_key is non-null and non-empty + RETURN session_key IS NOT NULL AND length(btrim(session_key)) > 0; +END; +$$; + + +ALTER FUNCTION "public"."is_bundle_encrypted"("session_key" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_canceled_org"("orgid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + caller_id uuid; +BEGIN + SELECT COALESCE(current_setting('role', true), '') INTO caller_role; + + IF NOT ( + caller_role IN ('service_role', 'postgres', 'supabase_admin') + OR ( + caller_role IN ('', 'none') + AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') + ) + ) THEN + SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_canceled_org.orgid) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + is_canceled_org.orgid, + NULL::character varying, + NULL::bigint + ) THEN + RETURN false; + END IF; + END IF; + + RETURN ( + SELECT EXISTS ( + SELECT 1 + FROM public.stripe_info + WHERE customer_id = (SELECT customer_id FROM public.orgs WHERE id = orgid) + AND status = 'canceled' + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."is_canceled_org"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_product_id text; + v_start_date date; + v_end_date date; + v_plan_name text; + total_metrics RECORD; + v_anchor_day interval; + caller_role text; + caller_id uuid; +BEGIN + SELECT COALESCE(current_setting('role', true), '') INTO caller_role; + + IF NOT ( + caller_role IN ('service_role', 'postgres', 'supabase_admin') + OR ( + caller_role IN ('', 'none') + AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') + ) + ) THEN + SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_good_plan_v5_org.orgid) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + is_good_plan_v5_org.orgid, + NULL::character varying, + NULL::bigint + ) THEN + RETURN false; + END IF; + END IF; + + SELECT + si.product_id, + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::interval) + INTO v_product_id, v_anchor_day + FROM public.orgs o + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + WHERE o.id = orgid; + + IF v_anchor_day > now() - date_trunc('MONTH', now()) THEN + v_start_date := (date_trunc('MONTH', now() - interval '1 MONTH') + v_anchor_day)::date; + ELSE + v_start_date := (date_trunc('MONTH', now()) + v_anchor_day)::date; + END IF; + v_end_date := (v_start_date + interval '1 MONTH')::date; + + SELECT p.name INTO v_plan_name + FROM public.plans p + WHERE p.stripe_id = v_product_id; + + IF v_plan_name = 'Enterprise' THEN + RETURN true; + END IF; + + SELECT * INTO total_metrics + FROM public.get_total_metrics(orgid, v_start_date, v_end_date); + + RETURN EXISTS ( + SELECT 1 + FROM public.plans p + WHERE p.name = v_plan_name + AND p.mau >= total_metrics.mau + AND p.bandwidth >= total_metrics.bandwidth + AND p.storage >= total_metrics.storage + AND p.build_time_unit >= COALESCE(total_metrics.build_time_unit, 0) + ); +END; +$$; + + +ALTER FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_internal_request_role"("caller_role" "text") RETURNS boolean + LANGUAGE "sql" STABLE + SET "search_path" TO '' + AS $$ + SELECT ( + caller_role = ANY (public.internal_request_role_names()) + OR ( + caller_role = ANY (ARRAY['', 'none']::text[]) + AND COALESCE(session_user, current_user) = ANY (public.internal_request_db_user_names()) + ) + ) +$$; + + +ALTER FUNCTION "public"."is_internal_request_role"("caller_role" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_mau_exceeded_by_org"("org_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (SELECT mau_exceeded + FROM public.stripe_info + WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = is_mau_exceeded_by_org.org_id)); +END; +$$; + + +ALTER FUNCTION "public"."is_mau_exceeded_by_org"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + is_found integer; + caller_role text; + caller_id uuid; +BEGIN + SELECT public.current_request_role() INTO caller_role; + + IF NOT public.is_internal_request_role(caller_role) THEN + SELECT public.get_identity_org_allowed(public.request_read_key_modes(), is_member_of_org.org_id) + INTO caller_id; + + IF caller_id IS NULL OR caller_id <> is_member_of_org.user_id OR NOT public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + is_member_of_org.org_id, + NULL::character varying, + NULL::bigint + ) THEN + RETURN false; + END IF; + END IF; + + SELECT count(*) + INTO is_found + FROM public.orgs + JOIN public.org_users ON org_users.org_id = orgs.id + WHERE org_users.user_id = is_member_of_org.user_id + AND orgs.id = is_member_of_org.org_id; + + RETURN is_found != 0; +END; +$$; + + +ALTER FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_not_deleted"("email_check" character varying) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + is_found integer; +BEGIN + SELECT count(*) + INTO is_found + FROM public.deleted_account + WHERE email=email_check; + RETURN is_found = 0; +END; +$$; + + +ALTER FUNCTION "public"."is_not_deleted"("email_check" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_numeric"("text") RETURNS boolean + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $_$ +BEGIN + RETURN $1 ~ '^[0-9]+$'; +END; +$_$; + + +ALTER FUNCTION "public"."is_numeric"("text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_onboarded_org"("orgid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + caller_id uuid; +BEGIN + SELECT COALESCE(current_setting('role', true), '') INTO caller_role; + + IF NOT ( + caller_role IN ('service_role', 'postgres', 'supabase_admin') + OR ( + caller_role IN ('', 'none') + AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') + ) + ) THEN + SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_onboarded_org.orgid) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + is_onboarded_org.orgid, + NULL::character varying, + NULL::bigint + ) THEN + RETURN false; + END IF; + END IF; + + RETURN ( + SELECT EXISTS (SELECT 1 FROM public.apps WHERE owner_org = orgid) + ) AND ( + SELECT EXISTS (SELECT 1 FROM public.app_versions WHERE owner_org = orgid) + ); +END; +$$; + + +ALTER FUNCTION "public"."is_onboarded_org"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + caller_id uuid; +BEGIN + SELECT COALESCE(current_setting('role', true), '') INTO caller_role; + + IF NOT ( + caller_role IN ('service_role', 'postgres', 'supabase_admin') + OR ( + caller_role IN ('', 'none') + AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') + ) + ) THEN + SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_onboarding_needed_org.orgid) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + is_onboarding_needed_org.orgid, + NULL::character varying, + NULL::bigint + ) THEN + RETURN false; + END IF; + END IF; + + RETURN ( + EXISTS ( + SELECT 1 FROM public.orgs + WHERE id = is_onboarding_needed_org.orgid + ) + AND + NOT public.is_onboarded_org(is_onboarding_needed_org.orgid) + AND public.is_trial_org(is_onboarding_needed_org.orgid) = 0 + ); +END; +$$; + + +ALTER FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_org_yearly"("orgid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + is_yearly boolean; + caller_role text; + caller_id uuid; +BEGIN + SELECT COALESCE(current_setting('role', true), '') INTO caller_role; + + IF NOT ( + caller_role IN ('service_role', 'postgres', 'supabase_admin') + OR ( + caller_role IN ('', 'none') + AND COALESCE(session_user, current_user) IN ('postgres', 'supabase_admin') + ) + ) THEN + SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_org_yearly.orgid) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + is_org_yearly.orgid, + NULL::character varying, + NULL::bigint + ) THEN + RETURN false; + END IF; + END IF; + + SELECT + CASE + WHEN si.price_id = p.price_y_id THEN true + ELSE false + END INTO is_yearly + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + JOIN public.plans p ON si.product_id = p.stripe_id + WHERE o.id = orgid + LIMIT 1; + + RETURN COALESCE(is_yearly, false); +END; +$$; + + +ALTER FUNCTION "public"."is_org_yearly"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; +BEGIN + SELECT public.current_request_role() INTO caller_role; + + IF NOT public.is_internal_request_role(caller_role) THEN + IF NOT public.request_has_org_read_access(is_paying_and_good_plan_org.orgid) THEN + RETURN false; + END IF; + END IF; + + RETURN ( + SELECT + EXISTS ( + SELECT 1 + FROM public.usage_credit_balances ucb + WHERE ucb.org_id = orgid + AND COALESCE(ucb.available_credits, 0) > 0 + ) + OR EXISTS ( + SELECT 1 + FROM public.stripe_info + WHERE customer_id = (SELECT customer_id FROM public.orgs WHERE id = orgid) + AND ( + (status = 'succeeded' AND is_good_plan = true) + OR (trial_at::date - NOW()::date > 0) + ) + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + org_customer_id text; + result boolean; + has_credits boolean; +BEGIN + SELECT current_setting('role', true) INTO caller_role; + + IF COALESCE(caller_role, '') NOT IN ('service_role', 'postgres', 'supabase_admin') THEN + IF NOT (public.check_min_rights( + 'read'::public.user_min_right, + (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_paying_and_good_plan_org_action.orgid)), + is_paying_and_good_plan_org_action.orgid, + NULL::character varying, + NULL::bigint + )) THEN + RETURN false; + END IF; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.usage_credit_balances ucb + WHERE ucb.org_id = orgid + AND COALESCE(ucb.available_credits, 0) > 0 + ) INTO has_credits; + + IF has_credits THEN + RETURN true; + END IF; + + SELECT o.customer_id INTO org_customer_id + FROM public.orgs o + WHERE o.id = orgid; + + SELECT (si.trial_at > now()) OR (si.status = 'succeeded' AND NOT ( + (si.mau_exceeded AND 'mau' = ANY(actions)) + OR (si.storage_exceeded AND 'storage' = ANY(actions)) + OR (si.bandwidth_exceeded AND 'bandwidth' = ANY(actions)) + OR (si.build_time_exceeded AND 'build_time' = ANY(actions)) + )) + INTO result + FROM public.stripe_info si + WHERE si.customer_id = org_customer_id + LIMIT 1; + + RETURN COALESCE(result, false); +END; +$$; + + +ALTER FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + org_customer_id text; + result boolean; + has_credits boolean; +BEGIN + SELECT current_setting('role', true) INTO caller_role; + + IF COALESCE(caller_role, '') NOT IN ('service_role', 'postgres', 'supabase_admin') THEN + IF NOT (public.check_min_rights( + 'read'::public.user_min_right, + (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_paying_and_good_plan_org_action.orgid)), + is_paying_and_good_plan_org_action.orgid, + is_paying_and_good_plan_org_action.appid, + NULL::bigint + )) THEN + RETURN false; + END IF; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.usage_credit_balances ucb + WHERE ucb.org_id = orgid + AND COALESCE(ucb.available_credits, 0) > 0 + ) INTO has_credits; + + IF has_credits THEN + RETURN true; + END IF; + + SELECT o.customer_id INTO org_customer_id + FROM public.orgs o + WHERE o.id = orgid; + + SELECT (si.trial_at > now()) OR (si.status = 'succeeded' AND NOT ( + (si.mau_exceeded AND 'mau' = ANY(actions)) + OR (si.storage_exceeded AND 'storage' = ANY(actions)) + OR (si.bandwidth_exceeded AND 'bandwidth' = ANY(actions)) + OR (si.build_time_exceeded AND 'build_time' = ANY(actions)) + )) + INTO result + FROM public.stripe_info si + WHERE si.customer_id = org_customer_id + LIMIT 1; + + RETURN COALESCE(result, false); +END; +$$; + + +ALTER FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_paying_org"("orgid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; +BEGIN + SELECT public.current_request_role() INTO caller_role; + + IF NOT public.is_internal_request_role(caller_role) THEN + IF NOT public.request_has_org_read_access(is_paying_org.orgid) THEN + RETURN false; + END IF; + END IF; + + RETURN ( + SELECT EXISTS ( + SELECT 1 + FROM public.stripe_info + WHERE customer_id = (SELECT customer_id FROM public.orgs WHERE id = orgid) + AND status = 'succeeded' + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."is_paying_org"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_platform_admin"() RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.is_platform_admin((SELECT auth.uid())); +END; +$$; + + +ALTER FUNCTION "public"."is_platform_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_platform_admin"("userid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + admin_ids_jsonb jsonb; + is_platform_admin_from_secret boolean; + mfa_verified boolean; +BEGIN + SELECT public.verify_mfa() INTO mfa_verified; + IF NOT mfa_verified THEN + RETURN false; + END IF; + + SELECT decrypted_secret::jsonb + INTO admin_ids_jsonb + FROM vault.decrypted_secrets + WHERE name = 'admin_users'; + + is_platform_admin_from_secret := COALESCE(admin_ids_jsonb ? userid::text, false); + + RETURN is_platform_admin_from_secret; +END; +$$; + + +ALTER FUNCTION "public"."is_platform_admin"("userid" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."is_platform_admin"("userid" "uuid") IS 'Checks platform admin status from admin_users and requires MFA.'; + + + +CREATE OR REPLACE FUNCTION "public"."is_rbac_enabled_globally"() RETURNS boolean + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +DECLARE + v_setting text; +BEGIN + SELECT decrypted_secret + INTO v_setting + FROM vault.decrypted_secrets + WHERE name = 'CAPGO_RBAC_ENABLED' + LIMIT 1; + + IF v_setting IS NULL OR btrim(v_setting) = '' THEN + RETURN false; + END IF; + + RETURN lower(v_setting) IN ('1', 'true', 'on', 'yes'); +END; +$$; + + +ALTER FUNCTION "public"."is_rbac_enabled_globally"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_recent_email_otp_verified"("p_user_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +DECLARE + verified_at timestamptz; +BEGIN + SELECT public.user_security.email_otp_verified_at + INTO verified_at + FROM public.user_security + WHERE public.user_security.user_id = p_user_id; + + RETURN verified_at IS NOT NULL + AND verified_at > (NOW() - INTERVAL '1 hour'); +END; +$$; + + +ALTER FUNCTION "public"."is_recent_email_otp_verified"("p_user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_storage_exceeded_by_org"("org_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (SELECT storage_exceeded + FROM public.stripe_info + WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = is_storage_exceeded_by_org.org_id)); +END; +$$; + + +ALTER FUNCTION "public"."is_storage_exceeded_by_org"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_trial_org"("orgid" "uuid") RETURNS integer + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; +BEGIN + SELECT public.current_request_role() INTO caller_role; + + IF NOT public.is_internal_request_role(caller_role) THEN + IF NOT public.request_has_org_read_access(is_trial_org.orgid) THEN + RETURN 0; + END IF; + END IF; + + RETURN COALESCE( + ( + SELECT GREATEST((trial_at::date - NOW()::date), 0) + FROM public.stripe_info + WHERE customer_id = (SELECT customer_id FROM public.orgs WHERE id = orgid) + ), + 0 + ); +END; +$$; + + +ALTER FUNCTION "public"."is_trial_org"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_user_app_admin"("p_user_id" "uuid", "p_app_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_id uuid; +BEGIN + SELECT owner_org INTO v_org_id + FROM public.apps + WHERE id = p_app_id + LIMIT 1; + + IF v_org_id IS NULL THEN + RETURN false; + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = p_user_id + AND ( + (rb.scope_type = public.rbac_scope_app() AND rb.app_id = p_app_id) + OR (rb.scope_type = public.rbac_scope_org() AND rb.org_id = v_org_id) + ) + AND r.name IN (public.rbac_role_app_admin(), public.rbac_role_org_super_admin(), public.rbac_role_org_admin()) + ); +END; +$$; + + +ALTER FUNCTION "public"."is_user_app_admin"("p_user_id" "uuid", "p_app_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."is_user_app_admin"("p_user_id" "uuid", "p_app_id" "uuid") IS 'Checks whether a user has an admin role for an app, including inherited org-level admin roles (bypasses RLS to avoid recursion).'; + + + +CREATE OR REPLACE FUNCTION "public"."is_user_org_admin"("p_user_id" "uuid", "p_org_id" "uuid") RETURNS boolean + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = p_user_id + AND rb.org_id = p_org_id + AND rb.scope_type = public.rbac_scope_org() + AND r.name IN (public.rbac_role_org_super_admin(), public.rbac_role_org_admin()) + ); +$$; + + +ALTER FUNCTION "public"."is_user_org_admin"("p_user_id" "uuid", "p_org_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."is_user_org_admin"("p_user_id" "uuid", "p_org_id" "uuid") IS 'Checks whether a user has an admin role in an organization (bypasses RLS to avoid recursion).'; + + + +CREATE OR REPLACE FUNCTION "public"."mark_app_stats_refreshed"("p_app_id" character varying) RETURNS timestamp without time zone + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_now_utc timestamp without time zone := pg_catalog.timezone('UTC', pg_catalog.clock_timestamp()); +BEGIN + IF p_app_id IS NULL OR p_app_id = '' THEN -- NOSONAR: explicit empty-string guard + RETURN NULL; + END IF; + + UPDATE public.apps + SET stats_updated_at = v_now_utc + WHERE app_id = p_app_id; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + RETURN v_now_utc; +END; +$$; + + +ALTER FUNCTION "public"."mark_app_stats_refreshed"("p_app_id" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."mass_edit_queue_messages_cf_ids"("updates" "public"."message_update"[]) RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $_$ +DECLARE + update_record public.message_update; + current_message jsonb; + current_cf_ids jsonb; +BEGIN + FOR update_record IN SELECT * FROM unnest(updates) + LOOP + -- Get the current message using dynamic SQL + EXECUTE format( + 'SELECT message FROM pgmq.q_%I WHERE msg_id = $1', + update_record.queue + ) INTO current_message USING update_record.msg_id; + + IF current_message IS NOT NULL THEN + -- Check if cf_ids exists and is an array + current_cf_ids := current_message->'cf_ids'; + + IF current_cf_ids IS NULL OR NOT jsonb_typeof(current_cf_ids) = 'array' THEN + -- Create new cf_ids array with single element + current_message := jsonb_set( + current_message, + '{cf_ids}', + jsonb_build_array(update_record.cf_id) + ); + ELSE + -- Append new cf_id to existing array + current_message := jsonb_set( + current_message, + '{cf_ids}', + current_cf_ids || jsonb_build_array(update_record.cf_id) + ); + END IF; + + -- Update the message + EXECUTE format( + 'UPDATE pgmq.q_%I SET message = $1 WHERE msg_id = $2', + update_record.queue + ) USING current_message, update_record.msg_id; + END IF; + END LOOP; +END; +$_$; + + +ALTER FUNCTION "public"."mass_edit_queue_messages_cf_ids"("updates" "public"."message_update"[]) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."modify_permissions_tmp"("email" "text", "org_id" "uuid", "new_role" "public"."user_min_right") RETURNS character varying + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + tmp_user record; + non_invite_role public.user_min_right; + v_rbac_role_name text; +BEGIN + non_invite_role := public.transform_role_to_non_invite(new_role); + v_rbac_role_name := public.rbac_org_role_for_legacy_right(non_invite_role); + + PERFORM 1 FROM public.orgs WHERE public.orgs.id = modify_permissions_tmp.org_id; + IF NOT FOUND THEN + RETURN 'NO_ORG'; + END IF; + + IF NOT public.check_min_rights( + 'admin'::public.user_min_right, + (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], modify_permissions_tmp.org_id)), + modify_permissions_tmp.org_id, + NULL::varchar, + NULL::bigint + ) THEN + RETURN 'NO_RIGHTS'; + END IF; + + IF non_invite_role = 'super_admin'::public.user_min_right + AND NOT public.check_min_rights( + 'super_admin'::public.user_min_right, + (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], modify_permissions_tmp.org_id)), + modify_permissions_tmp.org_id, + NULL::varchar, + NULL::bigint + ) + THEN + RETURN 'NO_RIGHTS_FOR_SUPER_ADMIN'; + END IF; + + SELECT * INTO tmp_user + FROM public.tmp_users + WHERE public.tmp_users.email = modify_permissions_tmp.email + AND public.tmp_users.org_id = modify_permissions_tmp.org_id; + + IF NOT FOUND THEN + RETURN 'NO_INVITATION'; + END IF; + IF tmp_user.cancelled_at IS NOT NULL THEN + RETURN 'INVITATION_CANCELLED'; + END IF; + + UPDATE public.tmp_users + SET role = non_invite_role, + rbac_role_name = v_rbac_role_name, + updated_at = CURRENT_TIMESTAMP + WHERE public.tmp_users.id = tmp_user.id; + + RETURN 'OK'; +END; +$$; + + +ALTER FUNCTION "public"."modify_permissions_tmp"("email" "text", "org_id" "uuid", "new_role" "public"."user_min_right") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."normalize_public_channel_overlap"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + -- Serialize public-channel changes per app so concurrent writers cannot + -- reintroduce overlapping public state between the normalization update and + -- the row write itself. Taking this lock before the cross-row UPDATE also + -- makes same-app writers wait here instead of deadlocking on channel rows. + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(NEW.app_id)); + + IF NEW.public IS DISTINCT FROM true THEN + RETURN NEW; + END IF; + + UPDATE public.channels AS existing + SET public = false + WHERE existing.app_id = NEW.app_id + AND existing.public = true + AND existing.id IS DISTINCT FROM NEW.id + AND ( + (NEW.ios = true AND existing.ios = true) + OR (NEW.android = true AND existing.android = true) + OR (NEW.electron = true AND existing.electron = true) + ); + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."normalize_public_channel_overlap"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."normalize_sso_provider_domain"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + NEW.domain := lower(btrim(NEW.domain)); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."normalize_sso_provider_domain"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."noupdate"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $_$ +DECLARE + val RECORD; + is_different boolean; +BEGIN + -- API key? We do not care + IF (SELECT auth.uid()) IS NULL THEN + RETURN NEW; + END IF; + + -- If the user has the 'admin' role then we do not care + IF public.check_min_rights('admin'::"public"."user_min_right", (SELECT auth.uid()), OLD.owner_org, NULL::character varying, NULL::bigint) THEN + RETURN NEW; + END IF; + + FOR val IN + SELECT * from json_each_text(row_to_json(NEW)) + LOOP + -- raise warning '?? % % %', val.key, val.value, format('SELECT (NEW."%s" <> OLD."%s")', val.key, val.key); + + EXECUTE format('SELECT ($1."%s" is distinct from $2."%s")', val.key, val.key) USING NEW, OLD + INTO is_different; + + IF is_different AND val.key <> 'version' AND val.key <> 'updated_at' THEN + RAISE EXCEPTION 'not allowed %', val.key; + END IF; + END LOOP; + + RETURN NEW; +END;$_$; + + +ALTER FUNCTION "public"."noupdate"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."one_month_ahead"() RETURNS timestamp without time zone + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN NOW() + INTERVAL '1 month'; +END; +$$; + + +ALTER FUNCTION "public"."one_month_ahead"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."parse_cron_field"("field" "text", "current_val" integer, "max_val" integer) RETURNS integer + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + IF field = '*' THEN + RETURN current_val; + ELSIF public.is_numeric(field) THEN + RETURN field::int; + ELSIF field LIKE '*/%' THEN + DECLARE + step int := regexp_replace(field, '\*/(\d+)', '\1')::int; + next_val int := current_val + (step - (current_val % step)); + BEGIN + IF next_val >= max_val THEN + RETURN step; + ELSE + RETURN next_val; + END IF; + END; + ELSE + RETURN 0; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."parse_cron_field"("field" "text", "current_val" integer, "max_val" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."parse_step_pattern"("pattern" "text") RETURNS integer + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN (regexp_replace(pattern, '\*/(\d+)', '\1'))::int; +END; +$$; + + +ALTER FUNCTION "public"."parse_step_pattern"("pattern" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."pg_log"("decision" "text", "input" "jsonb" DEFAULT '{}'::"jsonb") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $_$ +DECLARE + uid uuid; + req_id text; + role text; + ctx text; + fn text; +BEGIN + uid := auth.uid(); + req_id := current_setting('request.header.x-request-id', true); + role := current_setting('request.jwt.claim.role', true); + + -- Best-effort: extract caller from the PL/pgSQL context + GET DIAGNOSTICS ctx = PG_CONTEXT; + fn := ( + SELECT regexp_replace(line, '^PL/pgSQL function ([^(]+\([^)]*\)).*$', '\1') + FROM regexp_split_to_table(ctx, E'\n') AS line + WHERE line LIKE 'PL/pgSQL function %' + AND line NOT ILIKE '%pg_log(%' + AND line NOT ILIKE '%pg_debug(%' + LIMIT 1 + ); + IF fn IS NULL THEN + fn := 'unknown'; + END IF; + + -- Trim overly large payloads to avoid noisy logs + IF length(coalesce(input::text, '{}')) > 2000 THEN + input := jsonb_build_object('truncated', true); + END IF; + + RAISE LOG 'RLS LOG: fn=%, decision=%, uid=%, role=%, req_id=%, input=%' + , fn + , decision + , uid + , coalesce(role, 'null') + , coalesce(req_id, 'null') + , coalesce(input::text, '{}'); +EXCEPTION WHEN OTHERS THEN + -- Never let logging break execution paths + NULL; +END; +$_$; + + +ALTER FUNCTION "public"."pg_log"("decision" "text", "input" "jsonb") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."prevent_last_super_admin_binding_delete"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_remaining_count integer; + v_org_exists boolean; +BEGIN + + -- Only check org-level super_admin bindings + IF OLD.scope_type != public.rbac_scope_org() THEN + RETURN OLD; + END IF; + + -- Only check if the deleted binding is a super_admin role + IF NOT EXISTS ( + SELECT 1 FROM public.roles r + WHERE r.id = OLD.role_id + AND r.name = public.rbac_role_org_super_admin() + ) THEN + RETURN OLD; + END IF; + + -- Allow deletion if the org itself is being deleted (CASCADE scenario) + SELECT EXISTS( + SELECT 1 FROM public.orgs WHERE id = OLD.org_id + ) INTO v_org_exists; + + IF NOT v_org_exists THEN + RETURN OLD; + END IF; + + -- Serialize operations on this org's super_admin bindings using advisory lock + -- This prevents write-skew anomalies under concurrent deletes without FOR UPDATE deadlocks + PERFORM pg_advisory_xact_lock(hashtext(OLD.org_id::text)); + + -- Count remaining super_admin bindings in this org (excluding the one being deleted) + SELECT COUNT(*) INTO v_remaining_count + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.scope_type = public.rbac_scope_org() + AND rb.org_id = OLD.org_id + AND rb.principal_type = public.rbac_principal_user() + AND r.name = public.rbac_role_org_super_admin() + AND rb.id != OLD.id; + + IF v_remaining_count < 1 THEN + RAISE EXCEPTION 'CANNOT_DELETE_LAST_SUPER_ADMIN_BINDING' + USING HINT = 'At least one super_admin binding must remain in the org'; + END IF; + + RETURN OLD; +END; +$$; + + +ALTER FUNCTION "public"."prevent_last_super_admin_binding_delete"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."prevent_last_super_admin_binding_update"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_remaining_count integer; + v_org_exists boolean; +BEGIN + IF OLD.role_id IS NOT DISTINCT FROM NEW.role_id THEN + RETURN NEW; + END IF; + + IF OLD.scope_type != public.rbac_scope_org() THEN + RETURN NEW; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.roles r + WHERE r.id = OLD.role_id + AND r.name = public.rbac_role_org_super_admin() + ) THEN + RETURN NEW; + END IF; + + IF EXISTS ( + SELECT 1 + FROM public.roles r + WHERE r.id = NEW.role_id + AND r.name = public.rbac_role_org_super_admin() + ) THEN + RETURN NEW; + END IF; + + SELECT EXISTS( + SELECT 1 + FROM public.orgs + WHERE id = OLD.org_id + ) INTO v_org_exists; + + IF NOT v_org_exists THEN + RETURN NEW; + END IF; + + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(OLD.org_id::text)); + + SELECT COUNT(*) INTO v_remaining_count + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.scope_type = public.rbac_scope_org() + AND rb.org_id = OLD.org_id + AND rb.principal_type = public.rbac_principal_user() + AND r.name = public.rbac_role_org_super_admin() + AND rb.id != OLD.id; + + IF v_remaining_count < 1 THEN + RAISE EXCEPTION 'CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING' + USING HINT = 'At least one super_admin binding must remain in the org'; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."prevent_last_super_admin_binding_update"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_admin_stats"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + PERFORM pgmq.send('admin_stats', jsonb_build_object('function_name','logsnag_insights','function_type','cloudflare','payload',jsonb_build_object())); +END; +$$; + + +ALTER FUNCTION "public"."process_admin_stats"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_all_cron_tasks"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + current_hour int; + current_minute int; + current_second int; + current_dow int; + current_day int; + task RECORD; + queue_names text[]; + should_run boolean; + lock_acquired boolean; +BEGIN + -- Try to acquire an advisory lock (non-blocking) + -- Lock ID 1 is reserved for process_all_cron_tasks + -- pg_try_advisory_lock returns true if lock acquired, false if already held + lock_acquired := pg_try_advisory_lock(1); + + IF NOT lock_acquired THEN + -- Another instance is already running, skip this execution + RAISE NOTICE 'process_all_cron_tasks: skipped, another instance is already running'; + RETURN; + END IF; + + -- Wrap everything in a block so we can ensure the lock is released + BEGIN + -- Get current time components in UTC + current_hour := EXTRACT(HOUR FROM NOW()); + current_minute := EXTRACT(MINUTE FROM NOW()); + current_second := EXTRACT(SECOND FROM NOW()); + current_dow := EXTRACT(DOW FROM NOW()); + current_day := EXTRACT(DAY FROM NOW()); + + -- Loop through all enabled tasks + FOR task IN SELECT * FROM public.cron_tasks WHERE enabled = true LOOP + should_run := false; + + -- Check if task should run based on its schedule + IF task.second_interval IS NOT NULL THEN + -- Run every N seconds + -- Since pg_cron interval is not clock-aligned, we run on every invocation + -- for second_interval tasks (the cron job itself runs every 10 seconds) + should_run := true; + ELSIF task.minute_interval IS NOT NULL THEN + -- Run every N minutes + -- Use current_second < 10 to catch first run of each minute (works with any cron offset) + should_run := (current_minute % task.minute_interval = 0) + AND (current_second < 10); + ELSIF task.hour_interval IS NOT NULL THEN + -- Run every N hours at specific minute + -- Use current_second < 10 to catch first run + should_run := (current_hour % task.hour_interval = 0) + AND (current_minute = COALESCE(task.run_at_minute, 0)) + AND (current_second < 10); + ELSIF task.run_at_hour IS NOT NULL THEN + -- Run at specific time + -- Use current_second < 10 to catch first run + should_run := (current_hour = task.run_at_hour) + AND (current_minute = COALESCE(task.run_at_minute, 0)) + AND (current_second < 10); + + -- Check day of week constraint + IF should_run AND task.run_on_dow IS NOT NULL THEN + should_run := (current_dow = task.run_on_dow); + END IF; + + -- Check day of month constraint + IF should_run AND task.run_on_day IS NOT NULL THEN + should_run := (current_day = task.run_on_day); + END IF; + END IF; + + -- Execute the task if it should run + IF should_run THEN + BEGIN + CASE task.task_type + WHEN 'function' THEN + EXECUTE 'SELECT ' || task.target; + + WHEN 'queue' THEN + PERFORM pgmq.send( + task.target, + COALESCE(task.payload, jsonb_build_object('function_name', task.target)) + ); + + WHEN 'function_queue' THEN + -- Parse JSON array of queue names + SELECT array_agg(value::text) INTO queue_names + FROM jsonb_array_elements_text(task.target::jsonb); + + IF task.healthcheck_url IS NOT NULL THEN + PERFORM public.process_queue_with_healthcheck( + COALESCE(queue_names, ARRAY[]::text[]), + COALESCE(task.batch_size, 950), + task.healthcheck_url + ); + ELSIF task.batch_size IS NOT NULL THEN + PERFORM public.process_function_queue(queue_names, task.batch_size); + ELSE + PERFORM public.process_function_queue(queue_names); + END IF; + END CASE; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'cron task "%" failed: %', task.name, SQLERRM; + END; + END IF; + END LOOP; + + EXCEPTION WHEN OTHERS THEN + -- Release the lock even if an error occurred + PERFORM pg_advisory_unlock(1); + RAISE; + END; + + -- Release the advisory lock + PERFORM pg_advisory_unlock(1); +END; +$$; + + +ALTER FUNCTION "public"."process_all_cron_tasks"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."process_all_cron_tasks"() IS 'Consolidated cron task processor that runs every 10 seconds. Uses advisory +lock (ID=1) to prevent concurrent execution - if a previous run is still +executing, the new invocation will skip.'; + + + +CREATE OR REPLACE FUNCTION "public"."process_billing_period_stats_email"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + org_record RECORD; +BEGIN + -- Find all orgs whose billing cycle ends today + -- We calculate the PREVIOUS cycle's dates to ensure we report on completed data + FOR org_record IN ( + SELECT + o.id AS org_id, + o.management_email, + si.subscription_anchor_start, + -- Calculate the previous billing cycle dates + -- We use (NOW() - interval '1 day') to get yesterday's cycle end date calculation + -- This ensures we're always looking at the just-completed cycle + CASE + WHEN COALESCE( + si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), + '0 DAYS'::INTERVAL + ) > (NOW() - interval '1 day') - date_trunc('MONTH', NOW() - interval '1 day') + THEN date_trunc('MONTH', (NOW() - interval '1 day') - INTERVAL '1 MONTH') + + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) + ELSE date_trunc('MONTH', NOW() - interval '1 day') + + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) + END AS prev_cycle_start, + CASE + WHEN COALESCE( + si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), + '0 DAYS'::INTERVAL + ) > (NOW() - interval '1 day') - date_trunc('MONTH', NOW() - interval '1 day') + THEN (date_trunc('MONTH', (NOW() - interval '1 day') - INTERVAL '1 MONTH') + + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL)) + INTERVAL '1 MONTH' + ELSE (date_trunc('MONTH', NOW() - interval '1 day') + + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL)) + INTERVAL '1 MONTH' + END AS prev_cycle_end + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + WHERE si.status = 'succeeded' + AND o.management_email IS NOT NULL + ) + LOOP + -- If today is the billing cycle end date, queue the email + -- We pass the calculated previous cycle dates to ensure correct data + IF org_record.prev_cycle_end::date = CURRENT_DATE THEN + PERFORM pgmq.send('cron_email', + jsonb_build_object( + 'function_name', 'cron_email', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'email', org_record.management_email, + 'orgId', org_record.org_id, + 'type', 'billing_period_stats', + 'cycleStart', org_record.prev_cycle_start, + 'cycleEnd', org_record.prev_cycle_end + ) + ) + ); + END IF; + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_billing_period_stats_email"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_channel_device_counts_queue"("batch_size" integer DEFAULT 1000) RETURNS bigint + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + message_record RECORD; + v_payload jsonb; + v_app_id text; + v_delta integer; + msg_ids bigint[] := ARRAY[]::bigint[]; + processed bigint := 0; +BEGIN + IF batch_size IS NULL OR batch_size < 1 THEN + batch_size := 100; + END IF; + + FOR message_record IN + SELECT * + FROM pgmq.read('channel_device_counts', 60, batch_size) + LOOP + v_payload := message_record.message; + v_app_id := v_payload ->> 'app_id'; + v_delta := COALESCE((v_payload ->> 'delta')::integer, 0); + + IF v_app_id IS NULL OR v_delta = 0 THEN + msg_ids := array_append(msg_ids, message_record.msg_id); + CONTINUE; + END IF; + + UPDATE public.apps + SET channel_device_count = GREATEST(channel_device_count + v_delta, 0), + updated_at = NOW() + WHERE app_id = v_app_id; + + processed := processed + 1; + msg_ids := array_append(msg_ids, message_record.msg_id); + END LOOP; + + IF array_length(msg_ids, 1) IS NOT NULL THEN + PERFORM pgmq.delete('channel_device_counts', msg_ids); + END IF; + + RETURN processed; +END; +$$; + + +ALTER FUNCTION "public"."process_channel_device_counts_queue"("batch_size" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_cron_stats_jobs"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + app_record RECORD; +BEGIN + FOR app_record IN ( + WITH active_apps AS ( + SELECT DISTINCT av.app_id + FROM public.app_versions av + WHERE av.created_at >= pg_catalog.now() - INTERVAL '30 days' + + UNION + + SELECT DISTINCT dm.app_id + FROM public.daily_mau dm + WHERE dm.date >= pg_catalog.now() - INTERVAL '30 days' AND dm.mau > 0 + + UNION + + SELECT DISTINCT du.app_id + FROM public.device_usage du + WHERE du.timestamp >= pg_catalog.now() - INTERVAL '30 days' + + UNION + + SELECT DISTINCT bu.app_id + FROM public.bandwidth_usage bu + WHERE bu.timestamp >= pg_catalog.now() - INTERVAL '30 days' + ) + SELECT DISTINCT + active_apps.app_id, + a.owner_org + FROM active_apps + INNER JOIN public.apps a ON a.app_id = active_apps.app_id + ) + LOOP + PERFORM public.queue_cron_stat_app_for_app(app_record.app_id, app_record.owner_org); + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_cron_stats_jobs"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_cron_sync_sub_jobs"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + org_record RECORD; +BEGIN + FOR org_record IN + SELECT DISTINCT + o.id, + si.customer_id + FROM public.orgs AS o + INNER JOIN public.stripe_info AS si ON o.customer_id = si.customer_id + WHERE o.customer_id IS NOT NULL + AND si.customer_id IS NOT NULL + LOOP + PERFORM pgmq.send( + 'cron_sync_sub', + pg_catalog.jsonb_build_object( + 'function_name', 'cron_sync_sub', + 'function_type', NULL, + 'payload', pg_catalog.jsonb_build_object( + 'orgId', org_record.id, + 'customerId', org_record.customer_id + ) + ) + ); + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_cron_sync_sub_jobs"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_daily_fail_ratio_email"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + record RECORD; + fail_threshold numeric := 0.30; -- 30% fail rate threshold + min_installs integer := 10; -- Minimum installs to avoid false positives +BEGIN + -- Get apps with high fail ratios from yesterday's data + -- We use yesterday to ensure we have complete data for the day + FOR record IN + WITH daily_stats AS ( + SELECT + dv.app_id, + SUM(COALESCE(dv.install, 0)) AS total_installs, + SUM(COALESCE(dv.fail, 0)) AS total_fails + FROM public.daily_version dv + WHERE dv.date = CURRENT_DATE - INTERVAL '1 day' + GROUP BY dv.app_id + HAVING SUM(COALESCE(dv.install, 0)) >= min_installs + ), + high_fail_apps AS ( + SELECT + ds.app_id, + ds.total_installs, + ds.total_fails, + -- Cap fail_percentage at 100 to handle edge cases where fails > installs + CASE + WHEN ds.total_installs > 0 THEN LEAST(ROUND((ds.total_fails::numeric / ds.total_installs::numeric) * 100, 2), 100) + ELSE 0 + END AS fail_percentage, + a.owner_org + FROM daily_stats ds + JOIN public.apps a ON a.app_id = ds.app_id + WHERE ds.total_installs > 0 + AND (ds.total_fails::numeric / ds.total_installs::numeric) >= fail_threshold + ), + with_org_email AS ( + SELECT + hfa.*, + o.management_email, + a.name AS app_name + FROM high_fail_apps hfa + JOIN public.orgs o ON o.id = hfa.owner_org + JOIN public.apps a ON a.app_id = hfa.app_id + WHERE o.management_email IS NOT NULL + AND o.management_email != '' + ) + SELECT * FROM with_org_email + LOOP + -- Queue email for each app with high fail ratio (with error handling) + BEGIN + PERFORM pgmq.send('cron_email', + jsonb_build_object( + 'function_name', 'cron_email', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'email', record.management_email, + 'appId', record.app_id, + 'orgId', record.owner_org, + 'type', 'daily_fail_ratio', + 'appName', record.app_name, + 'totalInstalls', record.total_installs, + 'totalFails', record.total_fails, + 'failPercentage', record.fail_percentage, + 'reportDate', (CURRENT_DATE - INTERVAL '1 day')::text + ) + ) + ); + EXCEPTION + WHEN OTHERS THEN + RAISE WARNING 'process_daily_fail_ratio_email: failed to queue email for app_id %, org_id %, email %: % (%)', + record.app_id, + record.owner_org, + record.management_email, + SQLERRM, + SQLSTATE; + END; + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_daily_fail_ratio_email"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_deploy_install_stats_email"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + record RECORD; +BEGIN + FOR record IN + WITH latest AS ( + SELECT DISTINCT ON (dh.app_id, channel_platform) + dh.id, + dh.app_id, + dh.version_id, + dh.deployed_at, + dh.owner_org, + dh.channel_id, + CASE + WHEN c.ios = true AND c.android = false THEN 'ios' + WHEN c.android = true AND c.ios = false THEN 'android' + ELSE 'all' + END AS channel_platform + FROM public.deploy_history dh + JOIN public.channels c ON c.id = dh.channel_id + WHERE c.public = true + AND (c.ios = true OR c.android = true) + ORDER BY dh.app_id, channel_platform, dh.deployed_at DESC NULLS LAST + ), + eligible AS ( + SELECT l.* + FROM latest l + WHERE l.deployed_at IS NOT NULL + AND l.deployed_at <= NOW() - interval '24 hours' + ), + updated AS ( + UPDATE public.deploy_history dh + SET install_stats_email_sent_at = NOW() + FROM eligible e + WHERE dh.id = e.id + AND dh.install_stats_email_sent_at IS NULL + RETURNING dh.id, dh.app_id, dh.version_id, dh.deployed_at, dh.owner_org, dh.channel_id + ), + details AS ( + SELECT + u.id, + u.app_id, + u.version_id, + u.deployed_at, + u.owner_org, + u.channel_id, + e.channel_platform, + o.management_email, + c.name AS channel_name, + v.name AS version_name, + a.name AS app_name + FROM updated u + JOIN eligible e ON e.id = u.id + JOIN public.orgs o ON o.id = u.owner_org + JOIN public.channels c ON c.id = u.channel_id + JOIN public.app_versions v ON v.id = u.version_id + JOIN public.apps a ON a.app_id = u.app_id + ) + SELECT + d.* + FROM details d + LOOP + IF record.management_email IS NULL OR record.management_email = '' THEN + CONTINUE; + END IF; + + PERFORM pgmq.send('cron_email', + jsonb_build_object( + 'function_name', 'cron_email', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'email', record.management_email, + 'appId', record.app_id, + 'type', 'deploy_install_stats', + 'deployId', record.id, + 'versionId', record.version_id, + 'versionName', record.version_name, + 'channelId', record.channel_id, + 'channelName', record.channel_name, + 'platform', record.channel_platform, + 'appName', record.app_name, + 'deployedAt', record.deployed_at + ) + ) + ); + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_deploy_install_stats_email"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_failed_uploads"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + failed_version RECORD; +BEGIN + FOR failed_version IN ( + SELECT * FROM public.get_versions_with_no_metadata() + ) + LOOP + PERFORM pgmq.send('cron_clear_versions', + jsonb_build_object( + 'function_name', 'cron_clear_versions', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object('version', failed_version) + ) + ); + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_failed_uploads"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_free_trial_expired"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + UPDATE public.stripe_info + SET is_good_plan = false + WHERE status <> 'succeeded' AND trial_at < NOW(); +END; +$$; + + +ALTER FUNCTION "public"."process_free_trial_expired"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_function_queue"("queue_names" "text"[], "batch_size" integer DEFAULT 950) RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + queue_name text; +BEGIN + -- Process each queue in the array with individual exception handling + FOREACH queue_name IN ARRAY queue_names + LOOP + BEGIN + -- Call the existing single-queue function (fire-and-forget) + PERFORM public.process_function_queue(queue_name, batch_size); + EXCEPTION WHEN OTHERS THEN + -- Log the error but continue processing other queues + RAISE WARNING 'process_function_queue failed for queue "%": %', queue_name, SQLERRM; + END; + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_function_queue"("queue_names" "text"[], "batch_size" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_function_queue"("queue_name" "text", "batch_size" integer DEFAULT 950) RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + headers jsonb; + url text; + queue_size bigint; + calls_needed int; +BEGIN + -- Check if the queue has elements + EXECUTE format('SELECT count(*) FROM pgmq.q_%I', queue_name) INTO queue_size; + + -- Only make the HTTP request if the queue is not empty + IF queue_size > 0 THEN + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'apisecret', public.get_apikey() + ); + url := public.get_db_url() || '/functions/v1/triggers/queue_consumer/sync'; + + -- Calculate how many times to call the sync endpoint (1 call per batch_size items, max 10 calls) + calls_needed := least(ceil(queue_size / batch_size::float)::int, 10); + + -- Call the endpoint multiple times if needed (fire-and-forget) + FOR i IN 1..calls_needed LOOP + PERFORM net.http_post( + url := url, + headers := headers, + body := jsonb_build_object('queue_name', queue_name, 'batch_size', batch_size), + timeout_milliseconds := 8000 + ); + END LOOP; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."process_function_queue"("queue_name" "text", "batch_size" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_queue_with_healthcheck"("queue_names" "text"[], "batch_size" integer, "healthcheck_url" "text") RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + calls_needed int; + headers jsonb; + queue_name text; + queue_size bigint; + url text; +BEGIN + IF batch_size IS NULL OR batch_size <= 0 THEN + RAISE EXCEPTION 'batch_size must be positive'; + END IF; + + headers := pg_catalog.jsonb_build_object( + 'Content-Type', 'application/json', + 'apisecret', public.get_apikey() + ); + url := public.get_db_url() || '/functions/v1/triggers/queue_consumer/sync'; + + FOREACH queue_name IN ARRAY queue_names LOOP + BEGIN + EXECUTE pg_catalog.format('SELECT count(*) FROM pgmq.%I', 'q_' || queue_name) + INTO queue_size; + + IF queue_size > 0 THEN + calls_needed := LEAST( + pg_catalog.ceil(queue_size / batch_size::double precision)::int, + 10 + ); + ELSE + calls_needed := 1; + END IF; + + FOR i IN 1..calls_needed LOOP + PERFORM net.http_post( + url := url, + headers := headers, + body := pg_catalog.jsonb_strip_nulls(pg_catalog.jsonb_build_object( + 'queue_name', queue_name, + 'batch_size', batch_size, + 'healthcheck_url', healthcheck_url + )), + timeout_milliseconds := 8000 + ); + END LOOP; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'process_queue_with_healthcheck failed for queue "%": %', queue_name, SQLERRM; + END; + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_queue_with_healthcheck"("queue_names" "text"[], "batch_size" integer, "healthcheck_url" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_stats_email_monthly"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + app_record RECORD; +BEGIN + FOR app_record IN ( + SELECT a.app_id, o.management_email + FROM public.apps a + JOIN public.orgs o ON a.owner_org = o.id + ) + LOOP + PERFORM pgmq.send('cron_email', + jsonb_build_object( + 'function_name', 'cron_email', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'email', app_record.management_email, + 'appId', app_record.app_id, + 'type', 'monthly_create_stats' + ) + ) + ); + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_stats_email_monthly"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_stats_email_weekly"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + app_record RECORD; +BEGIN + FOR app_record IN ( + SELECT a.app_id, o.management_email + FROM public.apps a + JOIN public.orgs o ON a.owner_org = o.id + ) + LOOP + PERFORM pgmq.send('cron_email', + jsonb_build_object( + 'function_name', 'cron_email', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'email', app_record.management_email, + 'appId', app_record.app_id, + 'type', 'weekly_install_stats' + ) + ) + ); + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_stats_email_weekly"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."process_subscribed_orgs"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + org_record RECORD; +BEGIN + FOR org_record IN ( + SELECT o.id, o.customer_id + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + WHERE si.status = 'succeeded' + ORDER BY o.id ASC + ) + LOOP + PERFORM pgmq.send('cron_plan', + jsonb_build_object( + 'function_name', 'cron_plan', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'orgId', org_record.id, + 'customerId', org_record.customer_id + ) + ) + ); + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."process_subscribed_orgs"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."queue_cron_stat_app_for_app"("p_app_id" character varying, "p_org_id" "uuid" DEFAULT NULL::"uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_id uuid; + v_now_utc timestamp without time zone; + v_refresh_ttl CONSTANT interval := INTERVAL '5 minutes'; -- NOSONAR: function-local refresh TTL +BEGIN + IF p_app_id IS NULL OR p_app_id = '' THEN + RETURN; + END IF; + + v_now_utc := pg_catalog.timezone('UTC', pg_catalog.clock_timestamp()); + + UPDATE public.apps AS a + SET stats_refresh_requested_at = v_now_utc + WHERE a.app_id = p_app_id + AND (p_org_id IS NULL OR a.owner_org = p_org_id) + AND (a.stats_updated_at IS NULL OR a.stats_updated_at < v_now_utc - v_refresh_ttl) + AND (a.stats_refresh_requested_at IS NULL OR a.stats_refresh_requested_at < v_now_utc - v_refresh_ttl) + RETURNING a.owner_org + INTO v_org_id; + + IF v_org_id IS NULL THEN + RETURN; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pgmq.q_cron_stat_app AS queued_job + WHERE queued_job.message->'payload'->>'appId' = p_app_id + ) THEN + RETURN; + END IF; + + PERFORM pgmq.send('cron_stat_app', + pg_catalog.jsonb_build_object( + 'function_name', 'cron_stat_app', + 'function_type', 'cloudflare', + 'payload', pg_catalog.jsonb_build_object( + 'appId', p_app_id, + 'orgId', v_org_id, + 'todayOnly', false + ) + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."queue_cron_stat_app_for_app"("p_app_id" character varying, "p_org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."queue_cron_stat_org_for_org"("org_id" "uuid", "customer_id" "text") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + + PERFORM pgmq.send('cron_stat_org', + jsonb_build_object( + 'function_name', 'cron_stat_org', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'orgId', org_id, + 'customerId', customer_id + ) + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."queue_cron_stat_org_for_org"("org_id" "uuid", "customer_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_check_permission"("p_permission_key" "text", "p_org_id" "uuid" DEFAULT NULL::"uuid", "p_app_id" character varying DEFAULT NULL::character varying, "p_channel_id" bigint DEFAULT NULL::bigint) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + IF auth.uid() IS NULL THEN + RETURN false; + END IF; + + RETURN public.rbac_check_permission_direct( + p_permission_key, + auth.uid(), + p_org_id, + p_app_id, + p_channel_id, + NULL + ); +END; +$$; + + +ALTER FUNCTION "public"."rbac_check_permission"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_check_permission"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) IS 'Public RBAC permission check for authenticated users. Uses auth.uid() and delegates to rbac_check_permission_direct.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text" DEFAULT NULL::"text") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_allowed boolean := false; + v_effective_org_id uuid := p_org_id; + v_effective_user_id uuid := p_user_id; + v_effective_app_id character varying := p_app_id; + v_api_key public.apikeys%ROWTYPE; + v_channel_org_id uuid; + v_channel_app_id character varying; + v_channel_scope boolean := p_channel_id IS NOT NULL; + v_override boolean; +BEGIN + IF p_permission_key IS NULL OR p_permission_key = '' THEN + RETURN false; + END IF; + + IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN + SELECT owner_org INTO v_effective_org_id + FROM public.apps + WHERE app_id = p_app_id + LIMIT 1; + END IF; + + IF p_channel_id IS NOT NULL THEN + SELECT owner_org, app_id + INTO v_channel_org_id, v_channel_app_id + FROM public.channels + WHERE id = p_channel_id + LIMIT 1; + + IF v_channel_org_id IS NOT NULL THEN + v_effective_org_id := v_channel_org_id; + v_effective_app_id := v_channel_app_id; + END IF; + END IF; + + IF p_apikey IS NOT NULL THEN + SELECT * INTO v_api_key + FROM public.find_apikey_by_value(p_apikey) + LIMIT 1; + + IF v_api_key.id IS NULL + OR (p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id) + OR v_effective_org_id IS NULL + THEN + RETURN false; + END IF; + + IF public.is_apikey_expired(v_api_key.expires_at) THEN + RETURN false; + END IF; + + v_effective_user_id := v_api_key.user_id; + + IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id) + AND NOT public.has_2fa_enabled(v_effective_user_id) + THEN + RETURN false; + END IF; + + IF public.user_meets_password_policy(v_effective_user_id, v_effective_org_id) = false THEN + RETURN false; + END IF; + + v_allowed := public.rbac_has_permission( + public.rbac_principal_apikey(), + v_api_key.rbac_id, + p_permission_key, + v_effective_org_id, + v_effective_app_id, + p_channel_id + ); + + IF v_channel_scope THEN + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_apikey() + AND o.principal_id = v_api_key.rbac_id + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + END IF; + END IF; + + RETURN v_allowed; + END IF; + + IF v_effective_org_id IS NOT NULL THEN + IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id) + AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) + THEN + RETURN false; + END IF; + + IF public.user_meets_password_policy(v_effective_user_id, v_effective_org_id) = false THEN + RETURN false; + END IF; + END IF; + + IF v_effective_user_id IS NULL THEN + RETURN false; + END IF; + + v_allowed := public.rbac_has_permission( + public.rbac_principal_user(), + v_effective_user_id, + p_permission_key, + v_effective_org_id, + v_effective_app_id, + p_channel_id + ); + + IF v_channel_scope THEN + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_user() + AND o.principal_id = v_effective_user_id + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + END IF; + END IF; + + RETURN v_allowed; +END; +$$; + + +ALTER FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") IS 'Direct RBAC permission check with automatic legacy fallback based on org feature flag. Uses channel overrides when present. Supports hashed API keys via find_apikey_by_value.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text" DEFAULT NULL::"text") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_effective_org_id uuid := p_org_id; + v_effective_user_id uuid := p_user_id; + v_effective_app_id character varying := p_app_id; + v_api_key public.apikeys%ROWTYPE; + v_channel_org_id uuid; + v_channel_app_id character varying; +BEGIN + IF p_permission_key IS NULL OR p_permission_key = '' THEN + RETURN false; + END IF; + + IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN + SELECT owner_org INTO v_effective_org_id + FROM public.apps + WHERE app_id = p_app_id + LIMIT 1; + END IF; + + IF p_channel_id IS NOT NULL THEN + SELECT owner_org, app_id + INTO v_channel_org_id, v_channel_app_id + FROM public.channels + WHERE id = p_channel_id + LIMIT 1; + + IF v_channel_org_id IS NOT NULL THEN + v_effective_org_id := v_channel_org_id; + v_effective_app_id := v_channel_app_id; + END IF; + END IF; + + IF p_apikey IS NOT NULL THEN + SELECT * INTO v_api_key + FROM public.find_apikey_by_value(p_apikey) + LIMIT 1; + + IF v_api_key.id IS NULL + OR (p_user_id IS NOT NULL AND p_user_id IS DISTINCT FROM v_api_key.user_id) + OR v_effective_org_id IS NULL + THEN + RETURN false; + END IF; + + IF public.is_apikey_expired(v_api_key.expires_at) THEN + RETURN false; + END IF; + + v_effective_user_id := v_api_key.user_id; + + IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id) + AND NOT public.has_2fa_enabled(v_effective_user_id) + THEN + RETURN false; + END IF; + + RETURN public.rbac_has_permission( + public.rbac_principal_apikey(), + v_api_key.rbac_id, + p_permission_key, + v_effective_org_id, + v_effective_app_id, + p_channel_id + ); + END IF; + + IF v_effective_org_id IS NOT NULL THEN + IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id) + AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) + THEN + RETURN false; + END IF; + END IF; + + IF v_effective_user_id IS NULL THEN + RETURN false; + END IF; + + RETURN public.rbac_has_permission( + public.rbac_principal_user(), + v_effective_user_id, + p_permission_key, + v_effective_org_id, + v_effective_app_id, + p_channel_id + ); +END; +$$; + + +ALTER FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_no_password_policy"("p_permission_key" "text", "p_org_id" "uuid" DEFAULT NULL::"uuid", "p_app_id" character varying DEFAULT NULL::character varying, "p_channel_id" bigint DEFAULT NULL::bigint) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + IF auth.uid() IS NULL THEN + RETURN false; + END IF; + + RETURN public.rbac_check_permission_direct_no_password_policy( + p_permission_key, + auth.uid(), + p_org_id, + p_app_id, + p_channel_id, + NULL + ); +END; +$$; + + +ALTER FUNCTION "public"."rbac_check_permission_no_password_policy"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_check_permission_no_password_policy"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) IS 'RBAC permission check without password policy enforcement. Uses auth.uid() and delegates to rbac_check_permission_direct_no_password_policy.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_request"("p_permission_key" "text", "p_org_id" "uuid" DEFAULT NULL::"uuid", "p_app_id" character varying DEFAULT NULL::character varying, "p_channel_id" bigint DEFAULT NULL::bigint) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN public.rbac_check_permission_direct( + p_permission_key, + auth.uid(), + p_org_id, + p_app_id, + p_channel_id, + public.get_apikey_header() + ); +END; +$$; + + +ALTER FUNCTION "public"."rbac_check_permission_request"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_check_permission_request"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) IS 'Request-aware RBAC permission wrapper for RLS and SQL callers. Uses auth.uid() and capgkey header, preserving RBAC/legacy fallback semantics.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_enable_for_org"("p_org_id" "uuid", "p_granted_by" "uuid" DEFAULT NULL::"uuid") RETURNS "jsonb" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_migration_result jsonb; + v_was_enabled boolean; +BEGIN + -- Check if already enabled + SELECT use_new_rbac INTO v_was_enabled FROM public.orgs WHERE id = p_org_id; + IF v_was_enabled THEN + RETURN jsonb_build_object( + 'status', 'already_enabled', + 'org_id', p_org_id, + 'message', 'RBAC was already enabled for this org' + ); + END IF; + + -- Migrate org_users to role_bindings + v_migration_result := public.rbac_migrate_org_users_to_bindings(p_org_id, p_granted_by); + + -- Enable RBAC flag + UPDATE public.orgs SET use_new_rbac = true WHERE id = p_org_id; + + RETURN jsonb_build_object( + 'status', 'success', + 'org_id', p_org_id, + 'migration_result', v_migration_result, + 'rbac_enabled', true + ); +END; +$$; + + +ALTER FUNCTION "public"."rbac_enable_for_org"("p_org_id" "uuid", "p_granted_by" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_enable_for_org"("p_org_id" "uuid", "p_granted_by" "uuid") IS 'Migrates org_users to role_bindings and enables RBAC for an org in one transaction.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_has_permission"("p_principal_type" "text", "p_principal_id" "uuid", "p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_id uuid := p_org_id; + v_app_uuid uuid; + v_app_owner_org uuid; + v_channel_uuid uuid; + v_channel_app_id text; + v_channel_org_id uuid; + v_has boolean := false; +BEGIN + IF p_permission_key IS NULL THEN + RETURN false; + END IF; + + -- Resolve scope identifiers to UUIDs. Preserve the caller org when the app does not exist yet. + IF p_app_id IS NOT NULL THEN + SELECT id, owner_org INTO v_app_uuid, v_app_owner_org + FROM public.apps + WHERE app_id = p_app_id + LIMIT 1; + + IF v_app_owner_org IS NOT NULL THEN + v_org_id := v_app_owner_org; + END IF; + END IF; + + IF p_channel_id IS NOT NULL THEN + SELECT rbac_id, app_id, owner_org INTO v_channel_uuid, v_channel_app_id, v_channel_org_id + FROM public.channels + WHERE id = p_channel_id + LIMIT 1; + + IF v_channel_uuid IS NOT NULL THEN + IF p_app_id IS NOT NULL AND p_app_id IS DISTINCT FROM v_channel_app_id THEN + RETURN false; + END IF; + + IF p_org_id IS NOT NULL AND p_org_id IS DISTINCT FROM v_channel_org_id THEN + RETURN false; + END IF; + + SELECT id INTO v_app_uuid + FROM public.apps + WHERE app_id = v_channel_app_id + LIMIT 1; + + v_org_id := v_channel_org_id; + END IF; + END IF; + + WITH RECURSIVE scope_catalog AS ( + SELECT public.rbac_scope_org()::text AS scope_type, v_org_id AS org_id, NULL::uuid AS app_id, NULL::uuid AS channel_id WHERE v_org_id IS NOT NULL + UNION ALL + SELECT public.rbac_scope_app(), v_org_id, v_app_uuid, NULL::uuid WHERE v_app_uuid IS NOT NULL + UNION ALL + SELECT public.rbac_scope_channel(), v_org_id, v_app_uuid, v_channel_uuid WHERE v_channel_uuid IS NOT NULL + ), + direct_roles AS ( + SELECT rb.role_id, rb.scope_type + FROM scope_catalog s + JOIN public.role_bindings rb ON rb.scope_type = s.scope_type + AND ( + (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR + (rb.scope_type = public.rbac_scope_app() AND rb.org_id = s.org_id AND rb.app_id = s.app_id) OR + (rb.scope_type = public.rbac_scope_channel() AND rb.org_id = s.org_id AND rb.app_id = s.app_id AND rb.channel_id = s.channel_id) + ) + JOIN public.roles r ON r.id = rb.role_id + AND r.scope_type = rb.scope_type + WHERE rb.principal_type = p_principal_type + AND rb.principal_id = p_principal_id + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ), + group_roles AS ( + SELECT rb.role_id, rb.scope_type + FROM scope_catalog s + JOIN public.group_members gm ON gm.user_id = p_principal_id + JOIN public.groups g ON g.id = gm.group_id + JOIN public.role_bindings rb ON rb.principal_type = public.rbac_principal_group() AND rb.principal_id = gm.group_id + JOIN public.roles r ON r.id = rb.role_id + AND r.scope_type = rb.scope_type + WHERE p_principal_type = public.rbac_principal_user() + AND rb.scope_type = s.scope_type + AND ( + (rb.scope_type = public.rbac_scope_org() AND rb.org_id = s.org_id) OR + (rb.scope_type = public.rbac_scope_app() AND rb.org_id = s.org_id AND rb.app_id = s.app_id) OR + (rb.scope_type = public.rbac_scope_channel() AND rb.org_id = s.org_id AND rb.app_id = s.app_id AND rb.channel_id = s.channel_id) + ) + AND (v_org_id IS NULL OR g.org_id = v_org_id) + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ), + combined_roles AS ( + SELECT role_id, scope_type FROM direct_roles + UNION + SELECT role_id, scope_type FROM group_roles + ), + role_closure AS ( + SELECT role_id, scope_type FROM combined_roles + UNION + SELECT rh.child_role_id, rc.scope_type + FROM public.role_hierarchy rh + JOIN role_closure rc ON rc.role_id = rh.parent_role_id + JOIN public.roles child_role ON child_role.id = rh.child_role_id + AND child_role.scope_type = rc.scope_type + ), + perm_set AS ( + SELECT DISTINCT p.key + FROM role_closure rc + JOIN public.role_permissions rp ON rp.role_id = rc.role_id + JOIN public.permissions p ON p.id = rp.permission_id + ) + SELECT EXISTS (SELECT 1 FROM perm_set WHERE key = p_permission_key) INTO v_has; + + RETURN v_has; +END; +$$; + + +ALTER FUNCTION "public"."rbac_has_permission"("p_principal_type" "text", "p_principal_id" "uuid", "p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_has_permission"("p_principal_type" "text", "p_principal_id" "uuid", "p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) IS 'Checks whether a principal has a permission at org/app/channel scope. App and channel bindings must match the resolved owning org so forged cross-org scope rows are ignored.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_is_enabled_for_org"("p_org_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE + SET "search_path" TO '' + AS $$ +BEGIN + PERFORM p_org_id; + RETURN true; +END; +$$; + + +ALTER FUNCTION "public"."rbac_is_enabled_for_org"("p_org_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_is_enabled_for_org"("p_org_id" "uuid") IS 'Compatibility helper retained for old callers. RBAC is always enabled.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_legacy_right_for_org_role"("p_role_name" "text") RETURNS "public"."user_min_right" + LANGUAGE "plpgsql" IMMUTABLE + SET "search_path" TO '' + AS $$ +BEGIN + CASE p_role_name + WHEN public.rbac_role_org_super_admin() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_role_org_admin() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_role_org_billing_admin() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_role_org_member() THEN RETURN public.rbac_right_read(); + ELSE RETURN public.rbac_right_read(); + END CASE; +END; +$$; + + +ALTER FUNCTION "public"."rbac_legacy_right_for_org_role"("p_role_name" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_legacy_right_for_org_role"("p_role_name" "text") IS ' +Maps RBAC org role names to legacy user_min_right values for compatibility with +legacy tables and RLS. +'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") RETURNS "public"."user_min_right" + LANGUAGE "plpgsql" IMMUTABLE + SET "search_path" TO '' + AS $$ +BEGIN + CASE p_permission_key + WHEN public.rbac_perm_org_read() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_org_read_members() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read_bundles() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read_channels() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read_logs() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read_devices() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_channel_read() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_channel_read_history() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_channel_read_forced_devices() THEN RETURN public.rbac_right_read(); + + WHEN public.rbac_perm_app_upload_bundle() THEN RETURN public.rbac_right_upload(); + + WHEN public.rbac_perm_app_update_settings() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_app_create_channel() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_app_manage_devices() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_app_build_native() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_channel_update_settings() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_channel_promote_bundle() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_channel_rollback_bundle() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_channel_manage_forced_devices() THEN RETURN public.rbac_right_write(); + + WHEN public.rbac_perm_org_create_app() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_org_update_settings() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_org_invite_user() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_org_read_billing() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_org_read_invoices() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_org_read_audit() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_app_delete() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_app_read_audit() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_bundle_delete() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_channel_delete() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_channel_read_audit() THEN RETURN public.rbac_right_admin(); + + WHEN public.rbac_perm_org_update_user_roles() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_org_update_billing() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_org_read_billing_audit() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_org_delete() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_app_transfer() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_impersonate_user() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_manage_orgs_any() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_manage_apps_any() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_manage_channels_any() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_run_maintenance_jobs() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_delete_orphan_users() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_read_all_audit() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_db_break_glass() THEN RETURN public.rbac_right_super_admin(); + ELSE RETURN NULL; + END CASE; +END; +$$; + + +ALTER FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") IS 'Maps RBAC permission keys to legacy user_min_right values for fallback checks.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_legacy_role_hint"("p_user_right" "public"."user_min_right", "p_app_id" character varying, "p_channel_id" bigint) RETURNS "text" + LANGUAGE "plpgsql" IMMUTABLE + SET "search_path" TO '' + AS $$ +BEGIN + IF p_channel_id IS NOT NULL THEN + -- No channel-level role mapping for now + RETURN NULL; + ELSIF p_app_id IS NOT NULL THEN + -- App-level legacy mapping to RBAC roles + IF p_user_right >= public.rbac_right_admin()::public.user_min_right THEN + RETURN public.rbac_role_app_admin(); + ELSIF p_user_right = public.rbac_right_write()::public.user_min_right THEN + RETURN public.rbac_role_app_developer(); + ELSIF p_user_right = public.rbac_right_upload()::public.user_min_right THEN + RETURN public.rbac_role_app_uploader(); + ELSIF p_user_right = public.rbac_right_read()::public.user_min_right THEN + RETURN public.rbac_role_app_reader(); + END IF; + RETURN NULL; + ELSE + -- Org-level legacy mapping + IF p_user_right >= public.rbac_right_super_admin()::public.user_min_right THEN + RETURN public.rbac_role_org_super_admin(); + ELSIF p_user_right >= public.rbac_right_admin()::public.user_min_right THEN + RETURN public.rbac_role_org_admin(); + ELSIF p_user_right = public.rbac_right_write()::public.user_min_right THEN + -- Org-level write creates org_member + app_developer for each app + RETURN 'org_member + app_developer(per-app)'; + ELSIF p_user_right = public.rbac_right_upload()::public.user_min_right THEN + -- Org-level upload creates org_member + app_uploader for each app + RETURN 'org_member + app_uploader(per-app)'; + ELSIF p_user_right = public.rbac_right_read()::public.user_min_right THEN + -- Org-level read creates org_member + app_reader for each app + RETURN 'org_member + app_reader(per-app)'; + END IF; + RETURN NULL; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."rbac_legacy_role_hint"("p_user_right" "public"."user_min_right", "p_app_id" character varying, "p_channel_id" bigint) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_legacy_role_hint"("p_user_right" "public"."user_min_right", "p_app_id" character varying, "p_channel_id" bigint) IS 'Heuristic mapping from legacy org_users rows to Phase 1 priority roles. For org-level read/upload/write, returns composite string indicating org_member + per-app role pattern used during migration.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_migrate_org_users_to_bindings"("p_org_id" "uuid", "p_granted_by" "uuid" DEFAULT NULL::"uuid") RETURNS "jsonb" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_granted_by uuid; + v_org_user RECORD; + v_app RECORD; + v_role_name text; + v_app_role_name text; + v_role_id uuid; + v_app_role_id uuid; + v_scope_type text; + v_app_uuid uuid; + v_channel_uuid uuid; + v_binding_id uuid; + v_migrated_count int := 0; + v_skipped_count int := 0; + v_error_count int := 0; + v_errors jsonb := '[]'::jsonb; + v_migration_reason text := 'Migrated from org_users (legacy)'; +BEGIN + -- Use provided granted_by or find org owner + IF p_granted_by IS NULL THEN + SELECT created_by INTO v_granted_by FROM public.orgs WHERE id = p_org_id LIMIT 1; + IF v_granted_by IS NULL THEN + -- Fallback: use first admin user in org + SELECT user_id INTO v_granted_by + FROM public.org_users + WHERE org_id = p_org_id + AND user_right >= public.rbac_right_admin()::public.user_min_right + AND app_id IS NULL + AND channel_id IS NULL + ORDER BY created_at ASC + LIMIT 1; + END IF; + IF v_granted_by IS NULL THEN + RAISE EXCEPTION 'Cannot determine granted_by user for org %', p_org_id; + END IF; + ELSE + v_granted_by := p_granted_by; + END IF; + + -- Iterate through all org_users for this org + FOR v_org_user IN + SELECT id, user_id, org_id, app_id, channel_id, user_right + FROM public.org_users + WHERE org_id = p_org_id + LOOP + BEGIN + -- Special handling for org-level read/upload/write: create org_member + app-level roles + IF v_org_user.app_id IS NULL AND v_org_user.channel_id IS NULL + AND v_org_user.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) THEN + + -- 1) Create org_member binding + SELECT id INTO v_role_id FROM public.roles WHERE name = public.rbac_role_org_member() LIMIT 1; + IF v_role_id IS NOT NULL THEN + -- Check if org_member binding already exists + SELECT id INTO v_binding_id FROM public.role_bindings + WHERE principal_type = public.rbac_principal_user() + AND principal_id = v_org_user.user_id + AND role_id = v_role_id + AND scope_type = public.rbac_scope_org() + AND org_id = p_org_id + LIMIT 1; + + IF v_binding_id IS NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), v_org_user.user_id, v_role_id, public.rbac_scope_org(), p_org_id, + v_granted_by, now(), v_migration_reason, true + ); + v_migrated_count := v_migrated_count + 1; + END IF; + END IF; + + -- 2) Determine app-level role based on user_right + IF v_org_user.user_right = public.rbac_right_read() THEN + v_app_role_name := public.rbac_role_app_reader(); + ELSIF v_org_user.user_right = public.rbac_right_upload() THEN + v_app_role_name := public.rbac_role_app_uploader(); + ELSIF v_org_user.user_right = public.rbac_right_write() THEN + v_app_role_name := public.rbac_role_app_developer(); + END IF; + + SELECT id INTO v_app_role_id FROM public.roles WHERE name = v_app_role_name LIMIT 1; + IF v_app_role_id IS NULL THEN + v_error_count := v_error_count + 1; + v_errors := v_errors || jsonb_build_object( + 'org_user_id', v_org_user.id, + 'reason', 'app_role_not_found', + 'role_name', v_app_role_name + ); + CONTINUE; + END IF; + + -- 3) Create app-level binding for EACH app in the org + FOR v_app IN + SELECT id, app_id FROM public.apps WHERE owner_org = p_org_id + LOOP + -- Check if app binding already exists + SELECT id INTO v_binding_id FROM public.role_bindings + WHERE principal_type = public.rbac_principal_user() + AND principal_id = v_org_user.user_id + AND role_id = v_app_role_id + AND scope_type = public.rbac_scope_app() + AND app_id = v_app.id + LIMIT 1; + + IF v_binding_id IS NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, app_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), v_org_user.user_id, v_app_role_id, public.rbac_scope_app(), p_org_id, v_app.id, + v_granted_by, now(), v_migration_reason, true + ); + v_migrated_count := v_migrated_count + 1; + ELSE + v_skipped_count := v_skipped_count + 1; + END IF; + END LOOP; + + CONTINUE; -- Skip standard processing for this org_user + END IF; + + -- Standard processing for app/channel-specific rights or admin rights + v_role_name := public.rbac_legacy_role_hint( + v_org_user.user_right, + v_org_user.app_id, + v_org_user.channel_id + ); + + -- Skip if no suitable role + IF v_role_name IS NULL THEN + v_skipped_count := v_skipped_count + 1; + v_errors := v_errors || jsonb_build_object( + 'org_user_id', v_org_user.id, + 'user_id', v_org_user.user_id, + 'reason', 'no_suitable_role', + 'user_right', v_org_user.user_right::text, + 'app_id', v_org_user.app_id, + 'channel_id', v_org_user.channel_id + ); + CONTINUE; + END IF; + + -- Get role ID + SELECT id INTO v_role_id FROM public.roles WHERE name = v_role_name LIMIT 1; + IF v_role_id IS NULL THEN + v_error_count := v_error_count + 1; + v_errors := v_errors || jsonb_build_object( + 'org_user_id', v_org_user.id, + 'user_id', v_org_user.user_id, + 'reason', 'role_not_found', + 'role_name', v_role_name + ); + CONTINUE; + END IF; + + -- Determine scope type and resolve IDs + IF v_org_user.channel_id IS NOT NULL THEN + v_scope_type := public.rbac_scope_channel(); + SELECT id INTO v_app_uuid FROM public.apps + WHERE app_id = v_org_user.app_id LIMIT 1; + SELECT rbac_id INTO v_channel_uuid FROM public.channels + WHERE id = v_org_user.channel_id LIMIT 1; + + IF v_app_uuid IS NULL OR v_channel_uuid IS NULL THEN + v_error_count := v_error_count + 1; + v_errors := v_errors || jsonb_build_object( + 'org_user_id', v_org_user.id, + 'reason', 'channel_or_app_not_found', + 'app_id', v_org_user.app_id, + 'channel_id', v_org_user.channel_id + ); + CONTINUE; + END IF; + ELSIF v_org_user.app_id IS NOT NULL THEN + v_scope_type := public.rbac_scope_app(); + SELECT id INTO v_app_uuid FROM public.apps + WHERE app_id = v_org_user.app_id LIMIT 1; + v_channel_uuid := NULL; + + IF v_app_uuid IS NULL THEN + v_error_count := v_error_count + 1; + v_errors := v_errors || jsonb_build_object( + 'org_user_id', v_org_user.id, + 'reason', 'app_not_found', + 'app_id', v_org_user.app_id + ); + CONTINUE; + END IF; + ELSE + v_scope_type := public.rbac_scope_org(); + v_app_uuid := NULL; + v_channel_uuid := NULL; + END IF; + + -- Check if binding already exists (idempotency) + SELECT id INTO v_binding_id FROM public.role_bindings + WHERE principal_type = public.rbac_principal_user() + AND principal_id = v_org_user.user_id + AND role_id = v_role_id + AND scope_type = v_scope_type + AND org_id = p_org_id + AND (app_id = v_app_uuid OR (app_id IS NULL AND v_app_uuid IS NULL)) + AND (channel_id = v_channel_uuid OR (channel_id IS NULL AND v_channel_uuid IS NULL)) + LIMIT 1; + + IF v_binding_id IS NOT NULL THEN + v_skipped_count := v_skipped_count + 1; + CONTINUE; + END IF; + + -- Create role binding + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + channel_id, + granted_by, + granted_at, + reason, + is_direct + ) VALUES ( + public.rbac_principal_user(), + v_org_user.user_id, + v_role_id, + v_scope_type, + p_org_id, + v_app_uuid, + v_channel_uuid, + v_granted_by, + now(), + v_migration_reason, + true + ); + + v_migrated_count := v_migrated_count + 1; + + EXCEPTION WHEN OTHERS THEN + v_error_count := v_error_count + 1; + v_errors := v_errors || jsonb_build_object( + 'org_user_id', v_org_user.id, + 'user_id', v_org_user.user_id, + 'reason', 'exception', + 'error', SQLERRM + ); + END; + END LOOP; + + RETURN jsonb_build_object( + 'org_id', p_org_id, + 'granted_by', v_granted_by, + 'migrated_count', v_migrated_count, + 'skipped_count', v_skipped_count, + 'error_count', v_error_count, + 'errors', v_errors + ); +END; +$$; + + +ALTER FUNCTION "public"."rbac_migrate_org_users_to_bindings"("p_org_id" "uuid", "p_granted_by" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_migrate_org_users_to_bindings"("p_org_id" "uuid", "p_granted_by" "uuid") IS 'Migrates org_users records to role_bindings for a specific org. Idempotent and returns migration report.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_org_role_for_legacy_right"("legacy_right" "public"."user_min_right") RETURNS "text" + LANGUAGE "plpgsql" IMMUTABLE + SET "search_path" TO '' + AS $$ +BEGIN + IF legacy_right >= public.rbac_right_super_admin()::public.user_min_right THEN + RETURN public.rbac_role_org_super_admin(); + ELSIF legacy_right >= public.rbac_right_admin()::public.user_min_right THEN + RETURN public.rbac_role_org_admin(); + END IF; + + RETURN public.rbac_role_org_member(); +END; +$$; + + +ALTER FUNCTION "public"."rbac_org_role_for_legacy_right"("legacy_right" "public"."user_min_right") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_build_native"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.build_native'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_build_native"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_create_channel"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.create_channel'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_create_channel"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_delete"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.delete'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_delete"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_manage_devices"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.manage_devices'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_manage_devices"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_read"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.read'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_read"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_read_audit"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.read_audit'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_read_audit"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_read_bundles"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.read_bundles'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_read_bundles"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_read_channels"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.read_channels'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_read_channels"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_read_devices"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.read_devices'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_read_devices"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_read_logs"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.read_logs'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_read_logs"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_transfer"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.transfer'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_transfer"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_update_settings"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.update_settings'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_update_settings"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_update_user_roles"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.update_user_roles'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_update_user_roles"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_app_upload_bundle"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app.upload_bundle'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_app_upload_bundle"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_bundle_delete"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'bundle.delete'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_bundle_delete"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_bundle_read"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'bundle.read'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_bundle_read"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_bundle_update"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'bundle.update'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_bundle_update"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_channel_delete"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel.delete'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_channel_delete"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_channel_manage_forced_devices"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel.manage_forced_devices'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_channel_manage_forced_devices"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_channel_promote_bundle"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel.promote_bundle'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_channel_promote_bundle"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_channel_read"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel.read'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_channel_read"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_channel_read_audit"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel.read_audit'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_channel_read_audit"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_channel_read_forced_devices"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel.read_forced_devices'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_channel_read_forced_devices"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_channel_read_history"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel.read_history'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_channel_read_history"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_channel_rollback_bundle"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel.rollback_bundle'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_channel_rollback_bundle"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_channel_update_settings"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel.update_settings'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_channel_update_settings"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_create"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE + SET "search_path" TO '' + AS $$ + SELECT 'org.create'::text +$$; + + +ALTER FUNCTION "public"."rbac_perm_org_create"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_perm_org_create"() IS 'Global API-key permission for creating a new organization before an org-scoped RBAC binding can exist.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_create_app"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE + SET "search_path" TO '' + AS $$ SELECT 'org.create_app'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_create_app"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_perm_org_create_app"() IS 'RBAC permission key: create an app within an organization.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_delete"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.delete'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_delete"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_invite_user"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.invite_user'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_invite_user"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_read"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.read'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_read"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_read_audit"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.read_audit'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_read_audit"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_read_billing"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.read_billing'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_read_billing"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_read_billing_audit"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.read_billing_audit'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_read_billing_audit"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_read_invoices"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.read_invoices'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_read_invoices"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_read_members"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.read_members'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_read_members"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_update_billing"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.update_billing'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_update_billing"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_update_settings"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.update_settings'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_update_settings"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_org_update_user_roles"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org.update_user_roles'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_org_update_user_roles"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_platform_db_break_glass"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform.db_break_glass'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_platform_db_break_glass"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_platform_delete_orphan_users"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform.delete_orphan_users'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_platform_delete_orphan_users"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_platform_impersonate_user"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform.impersonate_user'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_platform_impersonate_user"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_platform_manage_apps_any"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform.manage_apps_any'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_platform_manage_apps_any"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_platform_manage_channels_any"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform.manage_channels_any'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_platform_manage_channels_any"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_platform_manage_orgs_any"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform.manage_orgs_any'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_platform_manage_orgs_any"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_platform_read_all_audit"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform.read_all_audit'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_platform_read_all_audit"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_perm_platform_run_maintenance_jobs"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform.run_maintenance_jobs'::text $$; + + +ALTER FUNCTION "public"."rbac_perm_platform_run_maintenance_jobs"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_permission_for_legacy"("p_min_right" "public"."user_min_right", "p_scope" "text") RETURNS "text" + LANGUAGE "plpgsql" IMMUTABLE + SET "search_path" TO '' + AS $$ +BEGIN + IF p_scope = public.rbac_scope_org() THEN + IF p_min_right IN (public.rbac_right_super_admin(), public.rbac_right_admin(), public.rbac_right_invite_super_admin(), public.rbac_right_invite_admin()) THEN + RETURN public.rbac_perm_org_update_user_roles(); + ELSIF p_min_right IN (public.rbac_right_write(), public.rbac_right_upload(), public.rbac_right_invite_write(), public.rbac_right_invite_upload()) THEN + RETURN public.rbac_perm_org_update_settings(); + ELSE + RETURN public.rbac_perm_org_read(); + END IF; + ELSIF p_scope = public.rbac_scope_app() THEN + IF p_min_right IN (public.rbac_right_super_admin(), public.rbac_right_admin(), public.rbac_right_invite_super_admin(), public.rbac_right_invite_admin(), public.rbac_right_write(), public.rbac_right_invite_write()) THEN + RETURN public.rbac_perm_app_update_settings(); + ELSIF p_min_right IN (public.rbac_right_upload(), public.rbac_right_invite_upload()) THEN + RETURN public.rbac_perm_app_upload_bundle(); + ELSE + RETURN public.rbac_perm_app_read(); + END IF; + ELSIF p_scope = public.rbac_scope_channel() THEN + IF p_min_right IN (public.rbac_right_super_admin(), public.rbac_right_admin(), public.rbac_right_invite_super_admin(), public.rbac_right_invite_admin(), public.rbac_right_write(), public.rbac_right_invite_write()) THEN + RETURN public.rbac_perm_channel_update_settings(); + ELSIF p_min_right IN (public.rbac_right_upload(), public.rbac_right_invite_upload()) THEN + RETURN public.rbac_perm_channel_promote_bundle(); + ELSE + RETURN public.rbac_perm_channel_read(); + END IF; + END IF; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."rbac_permission_for_legacy"("p_min_right" "public"."user_min_right", "p_scope" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_permission_for_legacy"("p_min_right" "public"."user_min_right", "p_scope" "text") IS 'Compatibility mapping from legacy min_right + scope to a single RBAC permission key (documented assumptions).'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_preview_migration"("p_org_id" "uuid") RETURNS TABLE("org_user_id" bigint, "user_id" "uuid", "user_right" "text", "app_id" character varying, "channel_id" bigint, "suggested_role" "text", "scope_type" "text", "will_migrate" boolean, "skip_reason" "text") + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + ou.id AS org_user_id, + ou.user_id, + ou.user_right::text AS user_right, + ou.app_id, + ou.channel_id, + public.rbac_legacy_role_hint(ou.user_right, ou.app_id, ou.channel_id) AS suggested_role, + CASE + WHEN ou.channel_id IS NOT NULL THEN public.rbac_scope_channel() + WHEN ou.app_id IS NOT NULL THEN public.rbac_scope_app() + ELSE public.rbac_scope_org() + END AS scope_type, + public.rbac_legacy_role_hint(ou.user_right, ou.app_id, ou.channel_id) IS NOT NULL AS will_migrate, + CASE + WHEN public.rbac_legacy_role_hint(ou.user_right, ou.app_id, ou.channel_id) IS NULL THEN 'no_suitable_role' + ELSE NULL + END AS skip_reason + FROM public.org_users ou + WHERE ou.org_id = p_org_id + ORDER BY ou.user_id, ou.app_id NULLS FIRST, ou.channel_id NULLS FIRST; +END; +$$; + + +ALTER FUNCTION "public"."rbac_preview_migration"("p_org_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_preview_migration"("p_org_id" "uuid") IS 'Preview what would be migrated for an org without making changes.'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_principal_apikey"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'apikey'::text $$; + + +ALTER FUNCTION "public"."rbac_principal_apikey"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_principal_group"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'group'::text $$; + + +ALTER FUNCTION "public"."rbac_principal_group"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_principal_user"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'user'::text $$; + + +ALTER FUNCTION "public"."rbac_principal_user"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_right_admin"() RETURNS "public"."user_min_right" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'admin'::public.user_min_right $$; + + +ALTER FUNCTION "public"."rbac_right_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_right_invite_admin"() RETURNS "public"."user_min_right" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'invite_admin'::public.user_min_right $$; + + +ALTER FUNCTION "public"."rbac_right_invite_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_right_invite_super_admin"() RETURNS "public"."user_min_right" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'invite_super_admin'::public.user_min_right $$; + + +ALTER FUNCTION "public"."rbac_right_invite_super_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_right_invite_upload"() RETURNS "public"."user_min_right" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'invite_upload'::public.user_min_right $$; + + +ALTER FUNCTION "public"."rbac_right_invite_upload"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_right_invite_write"() RETURNS "public"."user_min_right" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'invite_write'::public.user_min_right $$; + + +ALTER FUNCTION "public"."rbac_right_invite_write"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_right_read"() RETURNS "public"."user_min_right" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'read'::public.user_min_right $$; + + +ALTER FUNCTION "public"."rbac_right_read"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_right_super_admin"() RETURNS "public"."user_min_right" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'super_admin'::public.user_min_right $$; + + +ALTER FUNCTION "public"."rbac_right_super_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_right_upload"() RETURNS "public"."user_min_right" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'upload'::public.user_min_right $$; + + +ALTER FUNCTION "public"."rbac_right_upload"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_right_write"() RETURNS "public"."user_min_right" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'write'::public.user_min_right $$; + + +ALTER FUNCTION "public"."rbac_right_write"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_apikey_org_reader"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'apikey_org_reader'::text $$; + + +ALTER FUNCTION "public"."rbac_role_apikey_org_reader"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_app_admin"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app_admin'::text $$; + + +ALTER FUNCTION "public"."rbac_role_app_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_app_developer"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app_developer'::text $$; + + +ALTER FUNCTION "public"."rbac_role_app_developer"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_app_reader"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app_reader'::text $$; + + +ALTER FUNCTION "public"."rbac_role_app_reader"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_app_uploader"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app_uploader'::text $$; + + +ALTER FUNCTION "public"."rbac_role_app_uploader"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_bundle_admin"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'bundle_admin'::text $$; + + +ALTER FUNCTION "public"."rbac_role_bundle_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_bundle_reader"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'bundle_reader'::text $$; + + +ALTER FUNCTION "public"."rbac_role_bundle_reader"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_channel_admin"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel_admin'::text $$; + + +ALTER FUNCTION "public"."rbac_role_channel_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_channel_reader"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel_reader'::text $$; + + +ALTER FUNCTION "public"."rbac_role_channel_reader"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_org_admin"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org_admin'::text $$; + + +ALTER FUNCTION "public"."rbac_role_org_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_org_billing_admin"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org_billing_admin'::text $$; + + +ALTER FUNCTION "public"."rbac_role_org_billing_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_org_member"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org_member'::text $$; + + +ALTER FUNCTION "public"."rbac_role_org_member"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_org_super_admin"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org_super_admin'::text $$; + + +ALTER FUNCTION "public"."rbac_role_org_super_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_role_platform_super_admin"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform_super_admin'::text $$; + + +ALTER FUNCTION "public"."rbac_role_platform_super_admin"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_rollback_org"("p_org_id" "uuid") RETURNS "jsonb" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_deleted_count int; + v_migration_reason text := 'Migrated from org_users (legacy)'; +BEGIN + -- Delete all role_bindings that were migrated from org_users + DELETE FROM public.role_bindings + WHERE org_id = p_org_id + AND reason = v_migration_reason + AND is_direct = true; + + GET DIAGNOSTICS v_deleted_count = ROW_COUNT; + + -- Disable RBAC flag + UPDATE public.orgs SET use_new_rbac = false WHERE id = p_org_id; + + RETURN jsonb_build_object( + 'status', 'success', + 'org_id', p_org_id, + 'deleted_bindings', v_deleted_count, + 'rbac_enabled', false + ); +END; +$$; + + +ALTER FUNCTION "public"."rbac_rollback_org"("p_org_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."rbac_rollback_org"("p_org_id" "uuid") IS 'Removes migrated role_bindings and disables RBAC for an org (rollback migration).'; + + + +CREATE OR REPLACE FUNCTION "public"."rbac_scope_app"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'app'::text $$; + + +ALTER FUNCTION "public"."rbac_scope_app"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_scope_bundle"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'bundle'::text $$; + + +ALTER FUNCTION "public"."rbac_scope_bundle"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_scope_channel"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'channel'::text $$; + + +ALTER FUNCTION "public"."rbac_scope_channel"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_scope_org"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'org'::text $$; + + +ALTER FUNCTION "public"."rbac_scope_org"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rbac_scope_platform"() RETURNS "text" + LANGUAGE "sql" IMMUTABLE PARALLEL SAFE + SET "search_path" TO '' + AS $$ SELECT 'platform'::text $$; + + +ALTER FUNCTION "public"."rbac_scope_platform"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."read_bandwidth_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) RETURNS TABLE("date" timestamp without time zone, "bandwidth" numeric, "app_id" character varying) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + DATE_TRUNC('day', timestamp) AS date, + SUM(file_size) AS bandwidth, + bandwidth_usage.app_id + FROM public.bandwidth_usage + WHERE + timestamp >= p_period_start + AND timestamp < p_period_end + AND bandwidth_usage. app_id = p_app_id + GROUP BY bandwidth_usage.app_id, date + ORDER BY date; +END; +$$; + + +ALTER FUNCTION "public"."read_bandwidth_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."read_device_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) RETURNS TABLE("date" "date", "mau" bigint, "app_id" character varying) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + first_seen.date AS date, + COUNT(*)::bigint AS mau, + p_app_id AS app_id + FROM ( + SELECT + MIN(DATE_TRUNC('day', device_usage.timestamp)::date) AS date, + device_usage.device_id + FROM public.device_usage + WHERE + device_usage.app_id = p_app_id + AND device_usage.timestamp >= p_period_start + AND device_usage.timestamp < p_period_end + GROUP BY device_usage.device_id + ) AS first_seen + GROUP BY first_seen.date + ORDER BY first_seen.date; +END; +$$; + + +ALTER FUNCTION "public"."read_device_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."read_native_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) RETURNS TABLE("date" "date", "platform" character varying, "version_build" character varying, "devices" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + WITH authorized_app AS ( + SELECT apps.app_id + FROM public.apps + WHERE + apps.app_id = p_app_id + AND public.check_min_rights( + 'read'::public.user_min_right, + public.get_identity_org_appid( + '{read,upload,write,all}'::public.key_mode[], + apps.owner_org, + apps.app_id + ), + apps.owner_org, + apps.app_id, + NULL::bigint + ) + ), + daily_version_usage AS ( + SELECT + date_trunc('day', du.timestamp)::date AS usage_date, + COALESCE( + NULLIF(du.platform, ''), + NULLIF(d.platform::text, ''), + 'unknown' + )::character varying AS usage_platform, + COALESCE( + NULLIF(du.version_build, ''), + 'unknown' + )::character varying AS usage_version_build, + du.device_id + FROM public.device_usage AS du + INNER JOIN authorized_app AS aa + ON aa.app_id = du.app_id + LEFT JOIN public.devices AS d + ON d.app_id = du.app_id + AND d.device_id = du.device_id + WHERE + du.timestamp >= p_period_start + AND du.timestamp < p_period_end + ) + SELECT + usage_date AS date, + usage_platform AS platform, + usage_version_build AS version_build, + COUNT(DISTINCT device_id)::bigint AS devices + FROM daily_version_usage + GROUP BY usage_date, usage_platform, usage_version_build + ORDER BY usage_date, usage_platform, usage_version_build; +END; +$$; + + +ALTER FUNCTION "public"."read_native_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."read_native_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) IS 'Authorized aggregate for native version usage by platform. Raw device_usage rows remain denied by RLS.'; + + + +CREATE OR REPLACE FUNCTION "public"."read_storage_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) RETURNS TABLE("app_id" character varying, "date" "date", "storage" bigint) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + p_app_id AS app_id, + DATE_TRUNC('day', timestamp)::DATE AS date, + SUM(size)::BIGINT AS storage + FROM public.version_meta + WHERE + timestamp >= p_period_start + AND timestamp < p_period_end + AND version_meta.app_id = p_app_id + GROUP BY version_meta.app_id, date + ORDER BY date; +END; +$$; + + +ALTER FUNCTION "public"."read_storage_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."read_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) RETURNS TABLE("app_id" character varying, "version_name" character varying, "date" timestamp without time zone, "get" bigint, "fail" bigint, "install" bigint, "uninstall" bigint) + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + vu.app_id, + -- Use version_name if available (new data), otherwise look up from app_versions (old data) + COALESCE(vu.version_name, av.name)::character varying as version_name, + DATE_TRUNC('day', vu.timestamp) AS date, + SUM(CASE WHEN vu.action = 'get' THEN 1 ELSE 0 END) AS get, + SUM(CASE WHEN vu.action = 'fail' THEN 1 ELSE 0 END) AS fail, + SUM(CASE WHEN vu.action = 'install' THEN 1 ELSE 0 END) AS install, + SUM(CASE WHEN vu.action = 'uninstall' THEN 1 ELSE 0 END) AS uninstall + FROM public.version_usage vu + LEFT JOIN public.app_versions av ON vu.version_id = av.id AND vu.version_name IS NULL + WHERE + vu.app_id = p_app_id + AND vu.timestamp >= p_period_start + AND vu.timestamp < p_period_end + GROUP BY date, vu.app_id, COALESCE(vu.version_name, av.name) + ORDER BY date; +END; +$$; + + +ALTER FUNCTION "public"."read_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."reassign_webhook_created_by_before_user_delete"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + -- Preserve org-owned webhooks when a non-owner creator deletes their account. + UPDATE "public"."webhooks" AS "webhook" + SET "created_by" = "orgs"."created_by" + FROM "public"."orgs" AS "orgs" + WHERE "webhook"."org_id" = "orgs"."id" + AND "webhook"."created_by" = OLD."id" + AND "orgs"."created_by" <> OLD."id"; + + RETURN OLD; +END; +$$; + + +ALTER FUNCTION "public"."reassign_webhook_created_by_before_user_delete"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."record_build_time"("p_org_id" "uuid", "p_user_id" "uuid", "p_build_id" character varying, "p_platform" character varying, "p_build_time_unit" bigint, "p_app_id" character varying) RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_build_log_id uuid; + v_multiplier numeric; + v_billable_seconds bigint; + v_caller_user_id uuid; + v_invoking_role text; +BEGIN + -- Reject NULL/empty app_id: daily_build_time is keyed by app_id + IF p_app_id IS NULL OR p_app_id = '' THEN + RAISE EXCEPTION 'INVALID_APP_ID'; + END IF; + + -- Verify the app belongs to the org to prevent wrong attribution + IF NOT EXISTS ( + SELECT 1 FROM public.apps + WHERE app_id = p_app_id AND owner_org = p_org_id + ) THEN + RAISE EXCEPTION 'INVALID_APP_ID'; + END IF; + + SELECT NULLIF(current_setting('role', true), '') INTO v_invoking_role; + + -- Service-role callers do not have JWT/API key context and pass p_user_id directly. + -- Keep this path for internal calls from backend services. + IF v_invoking_role = 'service_role' THEN + v_caller_user_id := p_user_id; + ELSE + -- Use get_identity_org_appid (not get_identity_org_allowed) per project guidelines, + -- since we have app_id available for scoped authorization. + v_caller_user_id := public.get_identity_org_appid( + '{read,upload,write,all}'::public.key_mode[], + p_org_id, + p_app_id + ); + END IF; + + IF v_caller_user_id IS NULL THEN + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + IF NOT public.check_min_rights( + 'write'::public.user_min_right, + v_caller_user_id, + p_org_id, + p_app_id, + NULL::bigint + ) THEN + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + IF p_build_time_unit < 0 THEN + RAISE EXCEPTION 'Build time cannot be negative'; + END IF; + IF p_platform NOT IN ('ios', 'android') THEN + RAISE EXCEPTION 'Invalid platform: %', p_platform; + END IF; + + -- Apply platform multiplier + v_multiplier := CASE p_platform + WHEN 'ios' THEN 2 + WHEN 'android' THEN 1 + ELSE 1 + END; + + v_billable_seconds := (p_build_time_unit * v_multiplier)::bigint; + + INSERT INTO public.build_logs (org_id, user_id, build_id, platform, build_time_unit, billable_seconds, app_id) + VALUES (p_org_id, v_caller_user_id, p_build_id, p_platform, p_build_time_unit, v_billable_seconds, p_app_id) + ON CONFLICT (build_id, org_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + platform = EXCLUDED.platform, + build_time_unit = EXCLUDED.build_time_unit, + billable_seconds = EXCLUDED.billable_seconds, + app_id = EXCLUDED.app_id + RETURNING id INTO v_build_log_id; + + RETURN v_build_log_id; +END; +$$; + + +ALTER FUNCTION "public"."record_build_time"("p_org_id" "uuid", "p_user_id" "uuid", "p_build_id" character varying, "p_platform" character varying, "p_build_time_unit" bigint, "p_app_id" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."record_deployment_history"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + -- Native/builtin channel targets are stored as NULL and cannot be represented + -- in deploy_history.version_id. Record only concrete bundle deployments. + IF OLD.version IS DISTINCT FROM NEW.version AND NEW.version IS NOT NULL THEN + INSERT INTO public.deploy_history ( + channel_id, + app_id, + version_id, + owner_org, + created_by + ) + VALUES ( + NEW.id, + NEW.app_id, + NEW.version, + NEW.owner_org, + COALESCE(public.get_identity()::uuid, NEW.created_by) + ); + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."record_deployment_history"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."record_email_otp_verified"("p_user_id" "uuid") RETURNS timestamp with time zone + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_now timestamptz := NOW(); +BEGIN + IF "p_user_id" IS NULL THEN + RAISE EXCEPTION 'user_id required'; + END IF; + + INSERT INTO "public"."user_security" (user_id, email_otp_verified_at, created_at, updated_at) + VALUES ("p_user_id", v_now, v_now, v_now) + ON CONFLICT (user_id) DO UPDATE + SET email_otp_verified_at = EXCLUDED.email_otp_verified_at, + updated_at = EXCLUDED.updated_at; + + RETURN v_now; +END; +$$; + + +ALTER FUNCTION "public"."record_email_otp_verified"("p_user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."refresh_app_rollups_after_demo_reset"("p_app_uuid" "uuid", "p_app_id" "text", "p_owner_org" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_last_version text; + v_manifest_bundle_count bigint := 0; + v_channel_device_count bigint := 0; +BEGIN + SELECT "name" + INTO v_last_version + FROM "public"."app_versions" + WHERE "app_id" = p_app_id + AND "deleted" IS FALSE + ORDER BY "created_at" DESC, "id" DESC + LIMIT 1; + + SELECT COUNT(*)::bigint + INTO v_manifest_bundle_count + FROM "public"."app_versions" + WHERE "app_id" = p_app_id + AND "deleted" IS FALSE + AND COALESCE("manifest_count", 0) > 0; + + SELECT COUNT(*)::bigint + INTO v_channel_device_count + FROM "public"."channel_devices" + WHERE "app_id" = p_app_id; + + UPDATE "public"."apps" + SET + "last_version" = v_last_version, + "manifest_bundle_count" = v_manifest_bundle_count, + "channel_device_count" = v_channel_device_count + WHERE "id" = p_app_uuid; + + IF p_owner_org IS NOT NULL THEN + DELETE FROM "public"."app_metrics_cache" + WHERE "org_id" = p_owner_org; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."refresh_app_rollups_after_demo_reset"("p_app_uuid" "uuid", "p_app_id" "text", "p_owner_org" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."refresh_orgs_has_usage_credits"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + WITH credit_state AS ( + SELECT + o."id", + COALESCE(g."has_usage_credits", false) AS "has_usage_credits" + FROM "public"."orgs" AS o + LEFT JOIN ( + SELECT + grant_rows."org_id", + bool_or( + grant_rows."expires_at" >= now() + AND grant_rows."credits_consumed" < grant_rows."credits_total" + ) AS "has_usage_credits" + FROM "public"."usage_credit_grants" AS grant_rows + GROUP BY grant_rows."org_id" + ) AS g ON g."org_id" = o."id" + ) + UPDATE "public"."orgs" AS o + SET "has_usage_credits" = credit_state."has_usage_credits" + FROM credit_state + WHERE o."id" = credit_state."id" + AND o."has_usage_credits" IS DISTINCT FROM credit_state."has_usage_credits"; +END; +$$; + + +ALTER FUNCTION "public"."refresh_orgs_has_usage_credits"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."regenerate_hashed_apikey"("p_apikey_id" bigint) RETURNS "public"."apikeys" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; +BEGIN + SELECT public.get_identity_for_apikey_creation() INTO v_user_id; + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'No authentication provided'; + END IF; + + RETURN public.regenerate_hashed_apikey_for_user(p_apikey_id, v_user_id); +END; +$$; + + +ALTER FUNCTION "public"."regenerate_hashed_apikey"("p_apikey_id" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."regenerate_hashed_apikey_for_user"("p_apikey_id" bigint, "p_user_id" "uuid") RETURNS "public"."apikeys" + LANGUAGE "plpgsql" + 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); + + UPDATE public.apikeys + SET key = NULL, + key_hash = encode(extensions.digest(v_plain_key, 'sha256'), 'hex') + WHERE id = p_apikey_id + AND user_id = p_user_id + RETURNING * INTO v_apikey; + + IF NOT FOUND THEN + RAISE EXCEPTION 'apikey_not_found' + USING ERRCODE = 'P0002'; + END IF; + + v_apikey.key := v_plain_key; + + RETURN v_apikey; +END; +$$; + + +ALTER FUNCTION "public"."regenerate_hashed_apikey_for_user"("p_apikey_id" bigint, "p_user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_enforcing_2fa boolean; +BEGIN + -- Check if org exists + IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = reject_access_due_to_2fa.org_id) THEN + RETURN false; + END IF; + + -- Check if org has 2FA enforcement enabled + SELECT enforcing_2fa INTO org_enforcing_2fa + FROM public.orgs + WHERE public.orgs.id = reject_access_due_to_2fa.org_id; + + -- 7.1 If a given org does not enable 2FA enforcement, return false + IF org_enforcing_2fa = false THEN + RETURN false; + END IF; + + -- 7.2 If a given org REQUIRES 2FA, and has_2fa_enabled(user_id) == false, return true + IF org_enforcing_2fa = true AND NOT public.has_2fa_enabled(reject_access_due_to_2fa.user_id) THEN + PERFORM public.pg_log('deny: REJECT_ACCESS_DUE_TO_2FA', jsonb_build_object('org_id', org_id, 'user_id', user_id)); + RETURN true; + END IF; + + -- 7.3 Otherwise, return false + RETURN false; +END; +$$; + + +ALTER FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_owner_org uuid; + v_user_id uuid; + v_org_enforcing_2fa boolean; +BEGIN + -- Get the owner organization for this app + SELECT owner_org INTO v_owner_org + FROM public.apps + WHERE public.apps.app_id = reject_access_due_to_2fa_for_app.app_id; + + -- If app not found or no owner_org, allow (no 2FA enforcement can apply) + IF v_owner_org IS NULL THEN + RETURN false; + END IF; + + -- Get the current user identity (works for both JWT auth and API key) + -- Use get_identity_org_appid to ensure org/app scoping is respected + v_user_id := public.get_identity_org_appid('{read,upload,write,all}'::public.key_mode[], v_owner_org, reject_access_due_to_2fa_for_app.app_id); + + -- If no user identity found, allow (auth failure should be handled elsewhere) + IF v_user_id IS NULL THEN + RETURN false; + END IF; + + -- Check if org has 2FA enforcement enabled + SELECT enforcing_2fa INTO v_org_enforcing_2fa + FROM public.orgs + WHERE public.orgs.id = v_owner_org; + + -- If org not found, allow (no 2FA enforcement can apply) + IF v_org_enforcing_2fa IS NULL THEN + RETURN false; + END IF; + + -- If org does not enforce 2FA, allow access + IF v_org_enforcing_2fa = false THEN + RETURN false; + END IF; + + -- If org enforces 2FA and user doesn't have 2FA enabled, reject access + IF v_org_enforcing_2fa = true AND NOT public.has_2fa_enabled(v_user_id) THEN + RETURN true; + END IF; + + -- Otherwise, allow access + RETURN false; +END; +$$; + + +ALTER FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_org_enforcing_2fa boolean; +BEGIN + -- Get the current user identity (works for both JWT auth and API key) + -- NOTE: We use get_identity_org_allowed (not get_identity like the app version) because + -- this function takes an org_id directly, so we must validate that the API key + -- has access to this specific org before checking 2FA compliance. + -- This prevents org-limited API keys from bypassing org access restrictions. + v_user_id := public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], reject_access_due_to_2fa_for_org.org_id); + + -- If no user identity found, reject access + IF v_user_id IS NULL THEN + RETURN true; + END IF; + + -- Check if org has 2FA enforcement enabled + SELECT enforcing_2fa INTO v_org_enforcing_2fa + FROM public.orgs + WHERE public.orgs.id = reject_access_due_to_2fa_for_org.org_id; + + -- If org not found, allow access (no 2FA enforcement can apply to a non-existent org) + IF v_org_enforcing_2fa IS NULL THEN + RETURN false; + END IF; + + -- If org does not enforce 2FA, allow access + IF v_org_enforcing_2fa = false THEN + RETURN false; + END IF; + + -- If org enforces 2FA and user doesn't have 2FA enabled, reject access + -- Use has_2fa_enabled(user_id) to check the specific user (works for API key auth) + IF v_org_enforcing_2fa = true AND NOT public.has_2fa_enabled(v_user_id) THEN + RETURN true; + END IF; + + -- Otherwise, allow access + RETURN false; +END; +$$; + + +ALTER FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_has_policy boolean; +BEGIN + -- Check if org exists + IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = reject_access_due_to_password_policy.org_id) THEN + RETURN false; + END IF; + + -- Check if org has password policy enabled + SELECT + password_policy_config IS NOT NULL + AND (password_policy_config->>'enabled')::boolean = true + INTO org_has_policy + FROM public.orgs + WHERE public.orgs.id = reject_access_due_to_password_policy.org_id; + + -- If no policy enabled, don't reject + IF NOT COALESCE(org_has_policy, false) THEN + RETURN false; + END IF; + + -- If org requires policy and user doesn't meet it, reject access + IF NOT public.user_meets_password_policy(user_id, org_id) THEN + PERFORM public.pg_log('deny: REJECT_ACCESS_DUE_TO_PASSWORD_POLICY', jsonb_build_object('org_id', org_id, 'user_id', user_id)); + RETURN true; + END IF; + + RETURN false; +END; +$$; + + +ALTER FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."remove_old_jobs"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + DELETE FROM cron.job_run_details + WHERE end_time < NOW() - interval '1 day'; +END; +$$; + + +ALTER FUNCTION "public"."remove_old_jobs"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."request_app_chart_refresh"("app_id" character varying) RETURNS TABLE("requested_at" timestamp without time zone, "queued_app_ids" character varying[], "queued_count" integer, "skipped_count" integer) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + caller_id uuid; + v_org_id uuid; + v_before_requested_at timestamp without time zone; + v_after_requested_at timestamp without time zone; + v_request_started_at timestamp without time zone := pg_catalog.timezone('UTC', pg_catalog.clock_timestamp()); + v_queued boolean := false; + v_privileged_roles CONSTANT text[] := ARRAY['service_role', 'postgres', 'supabase_admin']; -- NOSONAR: function-local privileged role set + v_read_key_modes CONSTANT public.key_mode[] := '{read,upload,write,all}'::public.key_mode[]; -- NOSONAR: function-local key mode set + v_read_min_right CONSTANT public.user_min_right := 'read'::public.user_min_right; +BEGIN + IF request_app_chart_refresh.app_id IS NULL OR request_app_chart_refresh.app_id = '' THEN + RAISE EXCEPTION 'App ID is required'; + END IF; + + SELECT COALESCE( + NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), -- NOSONAR: request role lookup reused across overloads + NULLIF(pg_catalog.current_setting('role', true), ''), + NULLIF(COALESCE(session_user, current_user), '') + ) INTO caller_role; + + SELECT a.owner_org, a.stats_refresh_requested_at + INTO v_org_id, v_before_requested_at + FROM public.apps a + WHERE a.app_id = request_app_chart_refresh.app_id + LIMIT 1; + + IF caller_role = ANY(v_privileged_roles) AND v_org_id IS NULL THEN + RAISE EXCEPTION 'App not found'; + END IF; + + IF caller_role <> ALL(v_privileged_roles) THEN + IF v_org_id IS NULL THEN + RAISE EXCEPTION 'App access denied'; + END IF; + + SELECT public.get_identity_org_appid( + v_read_key_modes, + v_org_id, + request_app_chart_refresh.app_id + ) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + v_read_min_right, + caller_id, + v_org_id, + request_app_chart_refresh.app_id, + NULL::bigint + ) THEN + RAISE EXCEPTION 'App access denied'; + END IF; + END IF; + + PERFORM public.queue_cron_stat_app_for_app(request_app_chart_refresh.app_id, v_org_id); + + SELECT a.stats_refresh_requested_at + INTO v_after_requested_at + FROM public.apps a + WHERE a.app_id = request_app_chart_refresh.app_id + LIMIT 1; + + v_queued := v_after_requested_at IS NOT NULL + AND v_after_requested_at >= v_request_started_at + AND (v_before_requested_at IS NULL OR v_after_requested_at IS DISTINCT FROM v_before_requested_at); + + RETURN QUERY + SELECT + v_after_requested_at, + CASE + WHEN v_queued THEN ARRAY[request_app_chart_refresh.app_id]::character varying[] + ELSE ARRAY[]::character varying[] + END, + CASE WHEN v_queued THEN 1 ELSE 0 END, + CASE WHEN v_queued THEN 0 ELSE 1 END; +END; +$$; + + +ALTER FUNCTION "public"."request_app_chart_refresh"("app_id" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."request_has_app_read_access"("orgid" "uuid", "appid" character varying) RETURNS boolean + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_id uuid; +BEGIN + SELECT public.get_identity_org_appid( + public.request_read_key_modes(), + request_has_app_read_access.orgid, + request_has_app_read_access.appid + ) + INTO caller_id; + + RETURN ( + caller_id IS NOT NULL + AND public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + request_has_app_read_access.orgid, + request_has_app_read_access.appid, + NULL::bigint + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."request_has_app_read_access"("orgid" "uuid", "appid" character varying) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."request_has_org_read_access"("orgid" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_id uuid; +BEGIN + SELECT public.get_identity_org_allowed( + public.request_read_key_modes(), + request_has_org_read_access.orgid + ) + INTO caller_id; + + RETURN ( + caller_id IS NOT NULL + AND public.check_min_rights( + 'read'::public.user_min_right, + caller_id, + request_has_org_read_access.orgid, + NULL::character varying, + NULL::bigint + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."request_has_org_read_access"("orgid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."request_org_chart_refresh"("org_id" "uuid") RETURNS TABLE("requested_at" timestamp without time zone, "queued_app_ids" character varying[], "queued_count" integer, "skipped_count" integer) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + caller_role text; + caller_id uuid; + v_request_started_at timestamp without time zone := pg_catalog.timezone('UTC', pg_catalog.clock_timestamp()); + v_queued_app_ids character varying[] := ARRAY[]::character varying[]; + v_queued_count integer := 0; + v_total_count integer := 0; + v_org_exists boolean := false; + v_org_requested_at_before timestamp without time zone; + v_return_requested_at timestamp without time zone; + v_before_requested_at timestamp without time zone; + v_after_requested_at timestamp without time zone; + app_record record; + v_privileged_roles CONSTANT text[] := ARRAY['service_role', 'postgres', 'supabase_admin']; -- NOSONAR: function-local privileged role set + v_read_key_modes CONSTANT public.key_mode[] := '{read,upload,write,all}'::public.key_mode[]; -- NOSONAR: function-local key mode set + v_read_min_right CONSTANT public.user_min_right := 'read'::public.user_min_right; +BEGIN + IF request_org_chart_refresh.org_id IS NULL THEN + RAISE EXCEPTION 'Org ID is required'; + END IF; + + SELECT COALESCE( + NULLIF(pg_catalog.current_setting('request.jwt.claim.role', true), ''), -- NOSONAR: request role lookup reused across overloads + NULLIF(pg_catalog.current_setting('role', true), ''), + NULLIF(COALESCE(session_user, current_user), '') + ) INTO caller_role; + + SELECT o.stats_refresh_requested_at + INTO v_org_requested_at_before + FROM public.orgs o + WHERE o.id = request_org_chart_refresh.org_id + LIMIT 1; + + v_org_exists := FOUND; + + IF caller_role = ANY(v_privileged_roles) AND NOT v_org_exists THEN + RAISE EXCEPTION 'Organization not found'; + END IF; + + IF caller_role <> ALL(v_privileged_roles) THEN + IF NOT v_org_exists THEN + RAISE EXCEPTION 'Organization access denied'; + END IF; + + SELECT public.get_identity_org_allowed( + v_read_key_modes, + request_org_chart_refresh.org_id + ) + INTO caller_id; + + IF caller_id IS NULL OR NOT public.check_min_rights( + v_read_min_right, + caller_id, + request_org_chart_refresh.org_id, + NULL::character varying, + NULL::bigint + ) THEN + RAISE EXCEPTION 'Organization access denied'; + END IF; + END IF; + + FOR app_record IN + SELECT a.app_id, a.stats_refresh_requested_at + FROM public.apps a + WHERE a.owner_org = request_org_chart_refresh.org_id + ORDER BY a.app_id + LOOP + v_total_count := v_total_count + 1; + v_before_requested_at := app_record.stats_refresh_requested_at; + + PERFORM public.queue_cron_stat_app_for_app(app_record.app_id, request_org_chart_refresh.org_id); + + SELECT a.stats_refresh_requested_at + INTO v_after_requested_at + FROM public.apps a + WHERE a.app_id = app_record.app_id + LIMIT 1; + + IF v_after_requested_at IS NOT NULL + AND v_after_requested_at >= v_request_started_at + AND (v_before_requested_at IS NULL OR v_after_requested_at IS DISTINCT FROM v_before_requested_at) THEN + v_queued_count := v_queued_count + 1; + v_queued_app_ids := array_append(v_queued_app_ids, app_record.app_id); + END IF; + END LOOP; + + IF v_queued_count > 0 THEN + UPDATE public.orgs + SET stats_refresh_requested_at = v_request_started_at + WHERE id = request_org_chart_refresh.org_id; + + v_return_requested_at := v_request_started_at; + ELSE + v_return_requested_at := v_org_requested_at_before; + END IF; + + RETURN QUERY + SELECT + v_return_requested_at, + COALESCE(v_queued_app_ids, ARRAY[]::character varying[]), + v_queued_count, + GREATEST(v_total_count - v_queued_count, 0); +END; +$$; + + +ALTER FUNCTION "public"."request_org_chart_refresh"("org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."request_read_key_modes"() RETURNS "public"."key_mode"[] + LANGUAGE "sql" IMMUTABLE + SET "search_path" TO '' + AS $$ + SELECT '{read,upload,write,all}'::public.key_mode[] +$$; + + +ALTER FUNCTION "public"."request_read_key_modes"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."rescind_invitation"("email" "text", "org_id" "uuid") RETURNS character varying + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + tmp_user record; +BEGIN + IF NOT ( + public.check_min_rights( + 'admin'::public.user_min_right, + ( + SELECT public.get_identity_org_allowed( + '{read,upload,write,all}'::public.key_mode[], + rescind_invitation.org_id + ) + ), + rescind_invitation.org_id, + NULL::varchar, + NULL::bigint + ) + ) THEN + RETURN 'NO_RIGHTS'; + END IF; + + PERFORM 1 + FROM public.orgs + WHERE public.orgs.id = rescind_invitation.org_id; + IF NOT FOUND THEN + RETURN 'NO_RIGHTS'; + END IF; + + SELECT * INTO tmp_user + FROM public.tmp_users + WHERE public.tmp_users.email = rescind_invitation.email + AND public.tmp_users.org_id = rescind_invitation.org_id + FOR UPDATE; + IF NOT FOUND THEN + RETURN 'NO_INVITATION'; + END IF; + + IF tmp_user.cancelled_at IS NOT NULL THEN + RETURN 'ALREADY_CANCELLED'; + END IF; + + UPDATE public.tmp_users + SET cancelled_at = CURRENT_TIMESTAMP + WHERE public.tmp_users.id = tmp_user.id; + RETURN 'OK'; +END; +$$; + + +ALTER FUNCTION "public"."rescind_invitation"("email" "text", "org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."reset_onboarding_demo_app_data"("p_app_uuid" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_app_id text; + v_owner_org uuid; +BEGIN + SELECT "app_id", "owner_org" + INTO v_app_id, v_owner_org + FROM "public"."apps" + WHERE "id" = p_app_uuid; + + IF v_app_id IS NULL THEN + RETURN; + END IF; + + PERFORM "public"."claim_legacy_onboarding_demo_data"(p_app_uuid); + + -- unknown/builtin are system placeholders maintained by app creation. They + -- are allowed in demo-shaped legacy apps, but must never be demo-owned rows. + DELETE FROM "public"."onboarding_demo_data" odd + USING "public"."app_versions" av + WHERE odd."app_id" = v_app_id + AND odd."relation_name" IN ('app_versions', 'app_versions_meta') + AND odd."row_key" = av."id"::text + AND av."app_id" = v_app_id + AND av."name" IN ('unknown', 'builtin'); + + -- Refuse to delete tracked parents when any untracked child row points at + -- them. Without these guards, ON DELETE CASCADE could remove real data that + -- a user attached to a demo-created version or channel. + IF EXISTS ( + WITH tracked_versions AS ( + SELECT "row_key"::bigint AS "id" + FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id + AND "relation_name" = 'app_versions' + ) + SELECT 1 + FROM "public"."channels" c + INNER JOIN tracked_versions tv ON tv."id" = c."version" + WHERE NOT EXISTS ( + SELECT 1 + FROM "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'channels' + AND odd."row_key" = c."id"::text + ) + ) THEN + RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into untracked channels for app %', v_app_id; + END IF; + + IF EXISTS ( + WITH tracked_versions AS ( + SELECT "row_key"::bigint AS "id" + FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id + AND "relation_name" = 'app_versions' + ) + SELECT 1 + FROM "public"."deploy_history" dh + INNER JOIN tracked_versions tv ON tv."id" = dh."version_id" + WHERE NOT EXISTS ( + SELECT 1 + FROM "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'deploy_history' + AND odd."row_key" = dh."id"::text + ) + ) THEN + RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into untracked deploy history for app %', v_app_id; + END IF; + + IF EXISTS ( + WITH tracked_versions AS ( + SELECT "row_key"::bigint AS "id" + FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id + AND "relation_name" = 'app_versions' + ) + SELECT 1 + FROM "public"."manifest" m + INNER JOIN tracked_versions tv ON tv."id" = m."app_version_id" + WHERE NOT EXISTS ( + SELECT 1 + FROM "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'manifest' + AND odd."row_key" = m."id"::text + ) + ) THEN + RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into untracked manifest rows for app %', v_app_id; + END IF; + + IF EXISTS ( + WITH tracked_versions AS ( + SELECT "row_key"::bigint AS "id" + FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id + AND "relation_name" = 'app_versions' + ) + SELECT 1 + FROM "public"."app_versions_meta" avm + INNER JOIN tracked_versions tv ON tv."id" = avm."id" + WHERE NOT EXISTS ( + SELECT 1 + FROM "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'app_versions_meta' + AND odd."row_key" = avm."id"::text + ) + ) THEN + RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into untracked version metadata for app %', v_app_id; + END IF; + + IF EXISTS ( + WITH tracked_versions AS ( + SELECT "row_key"::bigint AS "id" + FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id + AND "relation_name" = 'app_versions' + ) + SELECT 1 + FROM "public"."permissions" p + INNER JOIN tracked_versions tv ON tv."id" = p."bundle_id" + ) OR EXISTS ( + WITH tracked_versions AS ( + SELECT "row_key"::bigint AS "id" + FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id + AND "relation_name" = 'app_versions' + ) + SELECT 1 + FROM "public"."role_bindings" rb + INNER JOIN tracked_versions tv ON tv."id" = rb."bundle_id" + ) THEN + RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo versions into RBAC rows for app %', v_app_id; + END IF; + + IF EXISTS ( + WITH tracked_versions AS ( + SELECT "row_key"::bigint AS "id" + FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id + AND "relation_name" = 'app_versions' + ) + SELECT 1 + FROM "public"."version_meta" vm + INNER JOIN tracked_versions tv ON tv."id" = vm."version_id" + WHERE vm."app_id" = v_app_id + ) THEN + RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to delete demo versions with non-nullable version metrics for app %', v_app_id; + END IF; + + IF EXISTS ( + WITH tracked_channels AS ( + SELECT "row_key"::bigint AS "id" + FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id + AND "relation_name" = 'channels' + ) + SELECT 1 + FROM "public"."deploy_history" dh + INNER JOIN tracked_channels tc ON tc."id" = dh."channel_id" + WHERE NOT EXISTS ( + SELECT 1 + FROM "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'deploy_history' + AND odd."row_key" = dh."id"::text + ) + ) THEN + RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo channels into untracked deploy history for app %', v_app_id; + END IF; + + IF EXISTS ( + WITH tracked_channels AS ( + SELECT "row_key"::bigint AS "id" + FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id + AND "relation_name" = 'channels' + ) + SELECT 1 + FROM "public"."channel_devices" cd + INNER JOIN tracked_channels tc ON tc."id" = cd."channel_id" + WHERE NOT EXISTS ( + SELECT 1 + FROM "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'channel_devices' + AND odd."row_key" = cd."id"::text + ) + ) THEN + RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to delete demo channels with untracked channel devices for app %', v_app_id; + END IF; + + IF EXISTS ( + WITH tracked_channels AS ( + SELECT c."id", c."rbac_id" + FROM "public"."channels" c + INNER JOIN "public"."onboarding_demo_data" odd + ON odd."app_id" = v_app_id + AND odd."relation_name" = 'channels' + AND odd."row_key" = c."id"::text + ) + SELECT 1 + FROM "public"."channel_permission_overrides" cpo + INNER JOIN tracked_channels tc ON tc."id" = cpo."channel_id" + ) OR EXISTS ( + WITH tracked_channels AS ( + SELECT c."id", c."rbac_id" + FROM "public"."channels" c + INNER JOIN "public"."onboarding_demo_data" odd + ON odd."app_id" = v_app_id + AND odd."relation_name" = 'channels' + AND odd."row_key" = c."id"::text + ) + SELECT 1 + FROM "public"."org_users" ou + INNER JOIN tracked_channels tc ON tc."id" = ou."channel_id" + ) OR EXISTS ( + WITH tracked_channels AS ( + SELECT c."id", c."rbac_id" + FROM "public"."channels" c + INNER JOIN "public"."onboarding_demo_data" odd + ON odd."app_id" = v_app_id + AND odd."relation_name" = 'channels' + AND odd."row_key" = c."id"::text + ) + SELECT 1 + FROM "public"."role_bindings" rb + INNER JOIN tracked_channels tc ON tc."rbac_id" = rb."channel_id" + ) THEN + RAISE EXCEPTION 'reset_onboarding_demo_app_data: refusing to cascade from demo channels into access-control rows for app %', v_app_id; + END IF; + + DELETE FROM "public"."channel_devices" cd + USING "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'channel_devices' + AND odd."row_key" = cd."id"::text; + + DELETE FROM "public"."deploy_history" dh + USING "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'deploy_history' + AND odd."row_key" = dh."id"::text; + + DELETE FROM "public"."manifest" m + USING "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'manifest' + AND odd."row_key" = m."id"::text; + + DELETE FROM "public"."build_requests" br + USING "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'build_requests' + AND odd."row_key" = br."id"::text; + + DELETE FROM "public"."devices" d + USING "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'devices' + AND odd."row_key" = d."id"::text; + + DELETE FROM "public"."channels" c + USING "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'channels' + AND odd."row_key" = c."id"::text; + + DELETE FROM "public"."app_versions_meta" avm + USING "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'app_versions_meta' + AND odd."row_key" = avm."id"::text; + + UPDATE "public"."devices" d + SET "version" = NULL + WHERE d."app_id" = v_app_id + AND EXISTS ( + SELECT 1 + FROM "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'app_versions' + AND odd."row_key" = d."version"::text + ); + + UPDATE "public"."daily_version" dv + SET "version_id" = NULL + WHERE dv."app_id" = v_app_id + AND EXISTS ( + SELECT 1 + FROM "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'app_versions' + AND odd."row_key" = dv."version_id"::text + ); + + UPDATE "public"."version_usage" vu + SET "version_id" = NULL + WHERE vu."app_id" = v_app_id + AND EXISTS ( + SELECT 1 + FROM "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'app_versions' + AND odd."row_key" = vu."version_id"::text + ); + + DELETE FROM "public"."app_versions" av + USING "public"."onboarding_demo_data" odd + WHERE odd."app_id" = v_app_id + AND odd."relation_name" = 'app_versions' + AND odd."row_key" = av."id"::text; + + DELETE FROM "public"."onboarding_demo_data" + WHERE "app_id" = v_app_id; + + PERFORM "public"."refresh_app_rollups_after_demo_reset"(p_app_uuid, v_app_id, v_owner_org); +END; +$$; + + +ALTER FUNCTION "public"."reset_onboarding_demo_app_data"("p_app_uuid" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."restore_deleted_account"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + auth_uid uuid; + auth_email text; + last_sign_in_at_ts timestamptz; + hashed_email text; + restored_account_id uuid; +BEGIN + SELECT "auth"."uid"() INTO auth_uid; + IF auth_uid IS NULL THEN + RAISE EXCEPTION 'not_authenticated' USING ERRCODE = '42501'; + END IF; + + SELECT "email", "last_sign_in_at" + INTO auth_email, last_sign_in_at_ts + FROM "auth"."users" + WHERE "id" = auth_uid; + + IF last_sign_in_at_ts IS NULL OR last_sign_in_at_ts < NOW() - INTERVAL '5 minutes' THEN + RAISE EXCEPTION 'reauth_required' USING ERRCODE = 'P0001'; + END IF; + + DELETE FROM "public"."to_delete_accounts" + WHERE "account_id" = auth_uid + AND "removal_date" > NOW() + AND "removal_date" <= NOW() + INTERVAL '30 days' + RETURNING "account_id" INTO restored_account_id; + + IF restored_account_id IS NULL THEN + RAISE EXCEPTION 'restore_window_expired' USING ERRCODE = 'P0001'; + END IF; + + IF auth_email IS NOT NULL AND auth_email <> '' THEN + hashed_email := "encode"("extensions"."digest"(auth_email::text, 'sha256'::text), 'hex'::text); + + DELETE FROM "public"."deleted_account" + WHERE "email" = hashed_email; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."restore_deleted_account"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."restore_deleted_account"() IS 'Restore the authenticated user account while still inside the delayed deletion window. Requires a recent sign-in.'; + + + +CREATE OR REPLACE FUNCTION "public"."resync_org_user_role_bindings"("p_user_id" "uuid", "p_org_id" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_user "public"."org_users"%ROWTYPE; + role_name_to_bind text; + role_id_to_bind uuid; + org_member_role_id uuid; + app_role_name text; + app_role_id uuid; + v_app RECORD; + v_app_uuid uuid; + v_channel_uuid uuid; + v_granted_by uuid; + v_sync_reason text := 'Synced from org_users'; +BEGIN + DELETE FROM "public"."role_bindings" + WHERE "principal_type" = "public"."rbac_principal_user"() + AND "principal_id" = p_user_id + AND "org_id" = p_org_id + AND "reason" IN ( + 'Synced from org_users', + 'Updated from org_users', + 'Migrated from org_users (legacy)' + ); + + FOR v_org_user IN + SELECT * + FROM "public"."org_users" + WHERE "user_id" = p_user_id + AND "org_id" = p_org_id + LOOP + v_granted_by := COALESCE("auth"."uid"(), v_org_user.user_id); + + IF v_org_user.app_id IS NULL AND v_org_user.channel_id IS NULL THEN + IF v_org_user.user_right IN ("public"."rbac_right_super_admin"(), "public"."rbac_right_admin"()) THEN + CASE v_org_user.user_right + WHEN "public"."rbac_right_super_admin"() THEN role_name_to_bind := "public"."rbac_role_org_super_admin"(); + WHEN "public"."rbac_right_admin"() THEN role_name_to_bind := "public"."rbac_role_org_admin"(); + END CASE; + + SELECT id INTO role_id_to_bind + FROM "public"."roles" + WHERE "name" = role_name_to_bind + LIMIT 1; + + IF role_id_to_bind IS NOT NULL THEN + INSERT INTO "public"."role_bindings" ( + "principal_type", "principal_id", "role_id", "scope_type", "org_id", + "granted_by", "granted_at", "reason", "is_direct" + ) VALUES ( + "public"."rbac_principal_user"(), v_org_user.user_id, role_id_to_bind, "public"."rbac_scope_org"(), v_org_user.org_id, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END IF; + ELSIF v_org_user.user_right IN ("public"."rbac_right_read"(), "public"."rbac_right_upload"(), "public"."rbac_right_write"()) THEN + SELECT id INTO org_member_role_id + FROM "public"."roles" + WHERE "name" = "public"."rbac_role_org_member"() + LIMIT 1; + + IF org_member_role_id IS NOT NULL THEN + INSERT INTO "public"."role_bindings" ( + "principal_type", "principal_id", "role_id", "scope_type", "org_id", + "granted_by", "granted_at", "reason", "is_direct" + ) VALUES ( + "public"."rbac_principal_user"(), v_org_user.user_id, org_member_role_id, "public"."rbac_scope_org"(), v_org_user.org_id, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END IF; + + CASE v_org_user.user_right + WHEN "public"."rbac_right_read"() THEN app_role_name := "public"."rbac_role_app_reader"(); + WHEN "public"."rbac_right_upload"() THEN app_role_name := "public"."rbac_role_app_uploader"(); + WHEN "public"."rbac_right_write"() THEN app_role_name := "public"."rbac_role_app_developer"(); + END CASE; + + SELECT id INTO app_role_id + FROM "public"."roles" + WHERE "name" = app_role_name + LIMIT 1; + + IF app_role_id IS NOT NULL THEN + FOR v_app IN + SELECT id + FROM "public"."apps" + WHERE "owner_org" = v_org_user.org_id + LOOP + INSERT INTO "public"."role_bindings" ( + "principal_type", "principal_id", "role_id", "scope_type", "org_id", "app_id", + "granted_by", "granted_at", "reason", "is_direct" + ) VALUES ( + "public"."rbac_principal_user"(), v_org_user.user_id, app_role_id, "public"."rbac_scope_app"(), v_org_user.org_id, v_app.id, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END LOOP; + END IF; + END IF; + ELSIF v_org_user.app_id IS NOT NULL AND v_org_user.channel_id IS NULL THEN + CASE v_org_user.user_right + WHEN "public"."rbac_right_super_admin"() THEN role_name_to_bind := "public"."rbac_role_app_admin"(); + WHEN "public"."rbac_right_admin"() THEN role_name_to_bind := "public"."rbac_role_app_admin"(); + WHEN "public"."rbac_right_write"() THEN role_name_to_bind := "public"."rbac_role_app_developer"(); + WHEN "public"."rbac_right_upload"() THEN role_name_to_bind := "public"."rbac_role_app_uploader"(); + WHEN "public"."rbac_right_read"() THEN role_name_to_bind := "public"."rbac_role_app_reader"(); + ELSE role_name_to_bind := "public"."rbac_role_app_reader"(); + END CASE; + + SELECT id INTO role_id_to_bind + FROM "public"."roles" + WHERE "name" = role_name_to_bind + LIMIT 1; + + SELECT id INTO v_app_uuid + FROM "public"."apps" + WHERE "app_id" = v_org_user.app_id + LIMIT 1; + + IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL THEN + INSERT INTO "public"."role_bindings" ( + "principal_type", "principal_id", "role_id", "scope_type", "org_id", "app_id", + "granted_by", "granted_at", "reason", "is_direct" + ) VALUES ( + "public"."rbac_principal_user"(), v_org_user.user_id, role_id_to_bind, "public"."rbac_scope_app"(), v_org_user.org_id, v_app_uuid, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END IF; + ELSIF v_org_user.app_id IS NOT NULL AND v_org_user.channel_id IS NOT NULL THEN + CASE v_org_user.user_right + WHEN "public"."rbac_right_super_admin"() THEN role_name_to_bind := "public"."rbac_role_channel_admin"(); + WHEN "public"."rbac_right_admin"() THEN role_name_to_bind := "public"."rbac_role_channel_admin"(); + WHEN "public"."rbac_right_write"() THEN role_name_to_bind := 'channel_developer'; + WHEN "public"."rbac_right_upload"() THEN role_name_to_bind := 'channel_uploader'; + WHEN "public"."rbac_right_read"() THEN role_name_to_bind := "public"."rbac_role_channel_reader"(); + ELSE role_name_to_bind := "public"."rbac_role_channel_reader"(); + END CASE; + + SELECT id INTO role_id_to_bind + FROM "public"."roles" + WHERE "name" = role_name_to_bind + LIMIT 1; + + SELECT id INTO v_app_uuid + FROM "public"."apps" + WHERE "app_id" = v_org_user.app_id + LIMIT 1; + + SELECT "rbac_id" INTO v_channel_uuid + FROM "public"."channels" + WHERE "id" = v_org_user.channel_id + LIMIT 1; + + IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL AND v_channel_uuid IS NOT NULL THEN + INSERT INTO "public"."role_bindings" ( + "principal_type", "principal_id", "role_id", "scope_type", "org_id", "app_id", "channel_id", + "granted_by", "granted_at", "reason", "is_direct" + ) VALUES ( + "public"."rbac_principal_user"(), v_org_user.user_id, role_id_to_bind, "public"."rbac_scope_channel"(), v_org_user.org_id, v_app_uuid, v_channel_uuid, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END IF; + END IF; + END LOOP; +END; +$$; + + +ALTER FUNCTION "public"."resync_org_user_role_bindings"("p_user_id" "uuid", "p_org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."sanitize_apps_text_fields"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + NEW."name" := public.strip_html(NEW."name"); + NEW."icon_url" := public.strip_html(NEW."icon_url"); + IF (TG_OP = 'UPDATE') THEN + NEW."updated_at" := now(); + END IF; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."sanitize_apps_text_fields"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."sanitize_orgs_text_fields"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + NEW."name" := public.strip_html(NEW."name"); + NEW."management_email" := public.strip_html(NEW."management_email"); + NEW."logo" := public.strip_html(NEW."logo"); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."sanitize_orgs_text_fields"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."sanitize_tmp_users_text_fields"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + NEW."email" := public.strip_html(NEW."email"); + NEW."first_name" := public.strip_html(NEW."first_name"); + NEW."last_name" := public.strip_html(NEW."last_name"); + IF (TG_OP = 'UPDATE') THEN + NEW."updated_at" := now(); + END IF; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."sanitize_tmp_users_text_fields"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."sanitize_users_text_fields"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + NEW."email" := public.strip_html(NEW."email"); + NEW."first_name" := public.strip_html(NEW."first_name"); + NEW."last_name" := public.strip_html(NEW."last_name"); + NEW."country" := public.strip_html(NEW."country"); + IF (TG_OP = 'UPDATE') THEN + NEW."updated_at" := now(); + END IF; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."sanitize_users_text_fields"() OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."app_metrics_cache" ( + "id" bigint NOT NULL, + "org_id" "uuid" NOT NULL, + "start_date" "date" NOT NULL, + "end_date" "date" NOT NULL, + "response" "jsonb" NOT NULL, + "cached_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."app_metrics_cache" OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."seed_get_app_metrics_caches"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") RETURNS "public"."app_metrics_cache" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + metrics_json jsonb; + cache_record public.app_metrics_cache%ROWTYPE; +BEGIN + WITH DateSeries AS ( + SELECT generate_series(p_start_date, p_end_date, '1 day'::interval)::date AS date + ), + all_apps AS ( + SELECT apps.app_id, apps.owner_org + FROM public.apps + WHERE apps.owner_org = p_org_id + UNION + SELECT deleted_apps.app_id, deleted_apps.owner_org + FROM public.deleted_apps + WHERE deleted_apps.owner_org = p_org_id + ), + deleted_metrics AS ( + SELECT + deleted_apps.app_id, + deleted_apps.deleted_at::date AS date, + COUNT(*) AS deleted_count + FROM public.deleted_apps + WHERE deleted_apps.owner_org = p_org_id + AND deleted_apps.deleted_at::date BETWEEN p_start_date AND p_end_date + GROUP BY deleted_apps.app_id, deleted_apps.deleted_at::date + ), + metrics AS ( + SELECT + aa.app_id, + ds.date::date, + COALESCE(dm.mau, 0) AS mau, + COALESCE(dst.storage, 0) AS storage, + COALESCE(db.bandwidth, 0) AS bandwidth, + COALESCE(dbt.build_time_unit, 0) AS build_time_unit, + COALESCE(SUM(dv.get)::bigint, 0) AS get, + COALESCE(SUM(dv.fail)::bigint, 0) AS fail, + COALESCE(SUM(dv.install)::bigint, 0) AS install, + COALESCE(SUM(dv.uninstall)::bigint, 0) AS uninstall + FROM + all_apps aa + CROSS JOIN + DateSeries ds + LEFT JOIN + public.daily_mau dm ON aa.app_id = dm.app_id AND ds.date = dm.date + LEFT JOIN + public.daily_storage dst ON aa.app_id = dst.app_id AND ds.date = dst.date + LEFT JOIN + public.daily_bandwidth db ON aa.app_id = db.app_id AND ds.date = db.date + LEFT JOIN + public.daily_build_time dbt ON aa.app_id = dbt.app_id AND ds.date = dbt.date + LEFT JOIN + public.daily_version dv ON aa.app_id = dv.app_id AND ds.date = dv.date + LEFT JOIN + deleted_metrics del ON aa.app_id = del.app_id AND ds.date = del.date + GROUP BY + aa.app_id, ds.date, dm.mau, dst.storage, db.bandwidth, dbt.build_time_unit, del.deleted_count + ) + SELECT COALESCE( + jsonb_agg(row_to_json(metrics) ORDER BY metrics.app_id, metrics.date), + '[]'::jsonb + ) + INTO metrics_json + FROM metrics; + + INSERT INTO public.app_metrics_cache (org_id, start_date, end_date, response, cached_at) + VALUES (p_org_id, p_start_date, p_end_date, metrics_json, clock_timestamp()) + ON CONFLICT (org_id) DO UPDATE + SET start_date = EXCLUDED.start_date, + end_date = EXCLUDED.end_date, + response = EXCLUDED.response, + cached_at = EXCLUDED.cached_at + RETURNING * INTO cache_record; + + RETURN cache_record; +END; +$$; + + +ALTER FUNCTION "public"."seed_get_app_metrics_caches"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."seed_org_metrics_cache"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") RETURNS "public"."org_metrics_cache" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + cache_record public.org_metrics_cache%ROWTYPE; +BEGIN + INSERT INTO public.org_metrics_cache ( + org_id, + start_date, + end_date, + mau, + storage, + bandwidth, + build_time_unit, + get, + fail, + install, + uninstall, + cached_at + ) + SELECT + org_id, + start_date, + end_date, + mau, + storage, + bandwidth, + build_time_unit, + get, + fail, + install, + uninstall, + cached_at + FROM public.calculate_org_metrics_cache_entry(p_org_id, p_start_date, p_end_date) + ON CONFLICT (org_id) DO UPDATE + SET start_date = EXCLUDED.start_date, + end_date = EXCLUDED.end_date, + mau = EXCLUDED.mau, + storage = EXCLUDED.storage, + bandwidth = EXCLUDED.bandwidth, + build_time_unit = EXCLUDED.build_time_unit, + get = EXCLUDED.get, + fail = EXCLUDED.fail, + install = EXCLUDED.install, + uninstall = EXCLUDED.uninstall, + cached_at = EXCLUDED.cached_at + RETURNING * INTO cache_record; + + RETURN cache_record; +END; +$$; + + +ALTER FUNCTION "public"."seed_org_metrics_cache"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."set_build_time_exceeded_by_org"("org_id" "uuid", "disabled" boolean) RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + UPDATE public.stripe_info SET build_time_exceeded = disabled + WHERE stripe_info.customer_id = (SELECT customer_id FROM public.orgs WHERE id = set_build_time_exceeded_by_org.org_id); +END; +$$; + + +ALTER FUNCTION "public"."set_build_time_exceeded_by_org"("org_id" "uuid", "disabled" boolean) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."set_deleted_at_on_soft_delete"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + -- Only set deleted_at when deleted changes from false to true + -- and deleted_at is not already set (allows manual override if needed) + IF NEW.deleted = true AND (OLD.deleted = false OR OLD.deleted IS NULL) AND NEW.deleted_at IS NULL THEN + NEW.deleted_at = NOW(); + END IF; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."set_deleted_at_on_soft_delete"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."set_webhook_created_by"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + "creator_id" uuid; +BEGIN + IF (SELECT "public"."get_apikey_header"()) IS NOT NULL THEN + "creator_id" := "public"."get_identity_org_allowed_apikey_only"( + '{all,write,upload}'::"public"."key_mode"[], + NEW."org_id" + ); + ELSE + "creator_id" := "auth"."uid"(); + END IF; + + IF "creator_id" IS NOT NULL THEN + NEW."created_by" := "creator_id"; + ELSIF NEW."created_by" IS NULL THEN + SELECT "orgs"."created_by" + INTO "creator_id" + FROM "public"."orgs" AS "orgs" + WHERE "orgs"."id" = NEW."org_id"; + + NEW."created_by" := "creator_id"; + END IF; + + IF NEW."created_by" IS NULL THEN + RAISE EXCEPTION 'webhooks.created_by cannot be null'; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."set_webhook_created_by"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."strip_html"("input" "text") RETURNS "text" + LANGUAGE "sql" IMMUTABLE + SET "search_path" TO '' + AS $$ + SELECT CASE + WHEN input IS NULL THEN NULL + ELSE btrim(regexp_replace(input, '<[^>]*>', '', 'g')) + END; +$$; + + +ALTER FUNCTION "public"."strip_html"("input" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."sync_org_has_usage_credits_from_grants"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_org_id uuid; +BEGIN + FOR v_org_id IN + SELECT DISTINCT affected."org_id" + FROM (VALUES (NEW."org_id"), (OLD."org_id")) AS affected("org_id") + WHERE affected."org_id" IS NOT NULL + LOOP + UPDATE "public"."orgs" AS o + SET "has_usage_credits" = credit_state."has_usage_credits" + FROM ( + SELECT EXISTS ( + SELECT 1 + FROM "public"."usage_credit_grants" AS g + WHERE g."org_id" = v_org_id + AND g."expires_at" >= now() + AND g."credits_consumed" < g."credits_total" + ) AS "has_usage_credits" + ) AS credit_state + WHERE o."id" = v_org_id + AND o."has_usage_credits" IS DISTINCT FROM credit_state."has_usage_credits"; + END LOOP; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."sync_org_has_usage_credits_from_grants"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."sync_org_user_role_binding_on_delete"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + PERFORM "public"."resync_org_user_role_bindings"(OLD.user_id, OLD.org_id); + RETURN OLD; +END; +$$; + + +ALTER FUNCTION "public"."sync_org_user_role_binding_on_delete"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."sync_org_user_role_binding_on_update"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + old_org_role_name text; + new_org_role_name text; + old_org_role_id uuid; + new_org_role_id uuid; + old_app_role_name text; + new_app_role_name text; + old_app_role_id uuid; + new_app_role_id uuid; + org_member_role_id uuid; + v_app RECORD; + v_granted_by uuid; + v_update_reason text := 'Updated from org_users'; + v_use_rbac boolean; +BEGIN + SELECT use_new_rbac INTO v_use_rbac FROM public.orgs WHERE id = NEW.org_id; + IF v_use_rbac AND (NEW.rbac_role_name IS NOT NULL OR OLD.rbac_role_name IS NOT NULL) THEN + RETURN NEW; + END IF; + + -- Only process if user_right actually changed + IF OLD.user_right = NEW.user_right THEN + RETURN NEW; + END IF; + + -- Only handle org-level rights (no app_id, no channel_id) + IF NEW.app_id IS NOT NULL OR NEW.channel_id IS NOT NULL THEN + RETURN NEW; + END IF; + + v_granted_by := COALESCE(auth.uid(), NEW.user_id); + + -- Map old user_right to role names + CASE OLD.user_right + WHEN public.rbac_right_super_admin() THEN + old_org_role_name := public.rbac_role_org_super_admin(); + old_app_role_name := NULL; + WHEN public.rbac_right_admin() THEN + old_org_role_name := public.rbac_role_org_admin(); + old_app_role_name := NULL; + WHEN public.rbac_right_write() THEN + old_org_role_name := public.rbac_role_org_member(); + old_app_role_name := public.rbac_role_app_developer(); + WHEN public.rbac_right_upload() THEN + old_org_role_name := public.rbac_role_org_member(); + old_app_role_name := public.rbac_role_app_uploader(); + WHEN public.rbac_right_read() THEN + old_org_role_name := public.rbac_role_org_member(); + old_app_role_name := public.rbac_role_app_reader(); + WHEN 'invite_super_admin'::public.user_min_right THEN + old_org_role_name := NULL; + old_app_role_name := NULL; + WHEN 'invite_admin'::public.user_min_right THEN + old_org_role_name := NULL; + old_app_role_name := NULL; + WHEN 'invite_write'::public.user_min_right THEN + old_org_role_name := NULL; + old_app_role_name := NULL; + WHEN 'invite_upload'::public.user_min_right THEN + old_org_role_name := NULL; + old_app_role_name := NULL; + WHEN 'invite_read'::public.user_min_right THEN + old_org_role_name := NULL; + old_app_role_name := NULL; + ELSE + RAISE WARNING 'Unexpected OLD.user_right value: %, skipping role binding sync', OLD.user_right; + RETURN NEW; + END CASE; + + -- Map new user_right to role names + CASE NEW.user_right + WHEN public.rbac_right_super_admin() THEN + new_org_role_name := public.rbac_role_org_super_admin(); + new_app_role_name := NULL; + WHEN public.rbac_right_admin() THEN + new_org_role_name := public.rbac_role_org_admin(); + new_app_role_name := NULL; + WHEN public.rbac_right_write() THEN + new_org_role_name := public.rbac_role_org_member(); + new_app_role_name := public.rbac_role_app_developer(); + WHEN public.rbac_right_upload() THEN + new_org_role_name := public.rbac_role_org_member(); + new_app_role_name := public.rbac_role_app_uploader(); + WHEN public.rbac_right_read() THEN + new_org_role_name := public.rbac_role_org_member(); + new_app_role_name := public.rbac_role_app_reader(); + WHEN 'invite_super_admin'::public.user_min_right THEN + new_org_role_name := NULL; + new_app_role_name := NULL; + WHEN 'invite_admin'::public.user_min_right THEN + new_org_role_name := NULL; + new_app_role_name := NULL; + WHEN 'invite_write'::public.user_min_right THEN + new_org_role_name := NULL; + new_app_role_name := NULL; + WHEN 'invite_upload'::public.user_min_right THEN + new_org_role_name := NULL; + new_app_role_name := NULL; + WHEN 'invite_read'::public.user_min_right THEN + new_org_role_name := NULL; + new_app_role_name := NULL; + ELSE + RAISE WARNING 'Unexpected NEW.user_right value: %, skipping role binding sync', NEW.user_right; + RETURN NEW; + END CASE; + + -- Get role IDs + IF old_org_role_name IS NOT NULL THEN + SELECT id INTO old_org_role_id FROM public.roles WHERE name = old_org_role_name LIMIT 1; + END IF; + + IF new_org_role_name IS NOT NULL THEN + SELECT id INTO new_org_role_id FROM public.roles WHERE name = new_org_role_name LIMIT 1; + END IF; + SELECT id INTO org_member_role_id FROM public.roles WHERE name = public.rbac_role_org_member() LIMIT 1; + + IF old_app_role_name IS NOT NULL THEN + SELECT id INTO old_app_role_id FROM public.roles WHERE name = old_app_role_name LIMIT 1; + END IF; + + IF new_app_role_name IS NOT NULL THEN + SELECT id INTO new_app_role_id FROM public.roles WHERE name = new_app_role_name LIMIT 1; + END IF; + + -- Delete old org-level binding (only if there was a role) + IF old_org_role_id IS NOT NULL THEN + DELETE FROM public.role_bindings + WHERE principal_type = public.rbac_principal_user() + AND principal_id = NEW.user_id + AND scope_type = public.rbac_scope_org() + AND org_id = NEW.org_id + AND role_id = old_org_role_id; + END IF; + + -- Delete old app-level bindings (for read/upload/write users) + IF old_app_role_id IS NOT NULL THEN + DELETE FROM public.role_bindings + WHERE principal_type = public.rbac_principal_user() + AND principal_id = NEW.user_id + AND scope_type = public.rbac_scope_app() + AND org_id = NEW.org_id + AND role_id = old_app_role_id; + END IF; + + -- Create new org-level binding + IF new_org_role_id IS NOT NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.user_id, new_org_role_id, public.rbac_scope_org(), NEW.org_id, + v_granted_by, now(), v_update_reason, true + ) ON CONFLICT DO NOTHING; + END IF; + + -- Create new app-level bindings for each app (for read/upload/write users) + IF new_app_role_id IS NOT NULL THEN + FOR v_app IN SELECT id FROM public.apps WHERE owner_org = NEW.org_id + LOOP + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, app_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.user_id, new_app_role_id, public.rbac_scope_app(), NEW.org_id, v_app.id, + v_granted_by, now(), v_update_reason, true + ) ON CONFLICT DO NOTHING; + END LOOP; + END IF; + + -- Handle transition from admin/super_admin to read/upload/write: + IF OLD.user_right IN (public.rbac_right_super_admin(), public.rbac_right_admin()) + AND NEW.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) THEN + NULL; + END IF; + + -- Handle transition from read/upload/write to admin/super_admin: + IF OLD.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) + AND NEW.user_right IN (public.rbac_right_super_admin(), public.rbac_right_admin()) THEN + IF org_member_role_id IS NOT NULL THEN + DELETE FROM public.role_bindings + WHERE principal_type = public.rbac_principal_user() + AND principal_id = NEW.user_id + AND scope_type = public.rbac_scope_org() + AND org_id = NEW.org_id + AND role_id = org_member_role_id; + END IF; + + DELETE FROM public.role_bindings + WHERE principal_type = public.rbac_principal_user() + AND principal_id = NEW.user_id + AND scope_type = public.rbac_scope_app() + AND org_id = NEW.org_id; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."sync_org_user_role_binding_on_update"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."sync_org_user_role_binding_on_update"() IS 'Automatically updates role_bindings entries when org_users.user_right is modified, ensuring both systems stay in sync. Handles transitions between admin roles and member roles.'; + + + +CREATE OR REPLACE FUNCTION "public"."sync_org_user_to_role_binding"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + role_name_to_bind text; + role_id_to_bind uuid; + org_member_role_id uuid; + app_role_name text; + app_role_id uuid; + v_app RECORD; + v_app_uuid uuid; + v_channel_uuid uuid; + v_granted_by uuid; + v_sync_reason text := 'Synced from org_users'; + v_use_rbac boolean; +BEGIN + SELECT use_new_rbac INTO v_use_rbac FROM public.orgs WHERE id = NEW.org_id; + IF v_use_rbac AND NEW.rbac_role_name IS NOT NULL THEN + RETURN NEW; + END IF; + + v_granted_by := COALESCE(auth.uid(), NEW.user_id); + + -- Handle org-level rights (no app_id, no channel_id) + IF NEW.app_id IS NULL AND NEW.channel_id IS NULL THEN + -- For super_admin and admin: create org-level binding directly + IF NEW.user_right IN (public.rbac_right_super_admin(), public.rbac_right_admin()) THEN + CASE NEW.user_right + WHEN public.rbac_right_super_admin() THEN role_name_to_bind := public.rbac_role_org_super_admin(); + WHEN public.rbac_right_admin() THEN role_name_to_bind := public.rbac_role_org_admin(); + END CASE; + + SELECT id INTO role_id_to_bind FROM public.roles WHERE name = role_name_to_bind LIMIT 1; + + IF role_id_to_bind IS NOT NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.user_id, role_id_to_bind, public.rbac_scope_org(), NEW.org_id, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END IF; + + -- For read/upload/write at org level: create org_member + app-level roles for each app + ELSIF NEW.user_right IN (public.rbac_right_read(), public.rbac_right_upload(), public.rbac_right_write()) THEN + -- 1) Create org_member binding at org level + SELECT id INTO org_member_role_id FROM public.roles WHERE name = public.rbac_role_org_member() LIMIT 1; + IF org_member_role_id IS NOT NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.user_id, org_member_role_id, public.rbac_scope_org(), NEW.org_id, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END IF; + + -- 2) Determine app-level role based on user_right + CASE NEW.user_right + WHEN public.rbac_right_read() THEN app_role_name := public.rbac_role_app_reader(); + WHEN public.rbac_right_upload() THEN app_role_name := public.rbac_role_app_uploader(); + WHEN public.rbac_right_write() THEN app_role_name := public.rbac_role_app_developer(); + END CASE; + + SELECT id INTO app_role_id FROM public.roles WHERE name = app_role_name LIMIT 1; + + -- 3) Create app-level binding for EACH app in the org + IF app_role_id IS NOT NULL THEN + FOR v_app IN SELECT id FROM public.apps WHERE owner_org = NEW.org_id + LOOP + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, app_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.user_id, app_role_id, public.rbac_scope_app(), NEW.org_id, v_app.id, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END LOOP; + END IF; + END IF; + + -- Handle app-level rights (has app_id, no channel_id) + ELSIF NEW.app_id IS NOT NULL AND NEW.channel_id IS NULL THEN + CASE NEW.user_right + WHEN public.rbac_right_super_admin() THEN role_name_to_bind := public.rbac_role_app_admin(); + WHEN public.rbac_right_admin() THEN role_name_to_bind := public.rbac_role_app_admin(); + WHEN public.rbac_right_write() THEN role_name_to_bind := public.rbac_role_app_developer(); + WHEN public.rbac_right_upload() THEN role_name_to_bind := public.rbac_role_app_uploader(); + WHEN public.rbac_right_read() THEN role_name_to_bind := public.rbac_role_app_reader(); + ELSE role_name_to_bind := public.rbac_role_app_reader(); + END CASE; + + SELECT id INTO role_id_to_bind FROM public.roles WHERE name = role_name_to_bind LIMIT 1; + SELECT id INTO v_app_uuid FROM public.apps WHERE app_id = NEW.app_id LIMIT 1; + + IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, app_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.user_id, role_id_to_bind, public.rbac_scope_app(), NEW.org_id, v_app_uuid, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END IF; + + -- Handle channel-level rights (has app_id and channel_id) + ELSIF NEW.app_id IS NOT NULL AND NEW.channel_id IS NOT NULL THEN + CASE NEW.user_right + WHEN public.rbac_right_super_admin() THEN role_name_to_bind := public.rbac_role_channel_admin(); + WHEN public.rbac_right_admin() THEN role_name_to_bind := public.rbac_role_channel_admin(); + WHEN public.rbac_right_write() THEN role_name_to_bind := 'channel_developer'; + WHEN public.rbac_right_upload() THEN role_name_to_bind := 'channel_uploader'; + WHEN public.rbac_right_read() THEN role_name_to_bind := public.rbac_role_channel_reader(); + ELSE role_name_to_bind := public.rbac_role_channel_reader(); + END CASE; + + SELECT id INTO role_id_to_bind FROM public.roles WHERE name = role_name_to_bind LIMIT 1; + SELECT id INTO v_app_uuid FROM public.apps WHERE app_id = NEW.app_id LIMIT 1; + SELECT rbac_id INTO v_channel_uuid FROM public.channels WHERE id = NEW.channel_id LIMIT 1; + + IF role_id_to_bind IS NOT NULL AND v_app_uuid IS NOT NULL AND v_channel_uuid IS NOT NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, app_id, channel_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.user_id, role_id_to_bind, public.rbac_scope_channel(), NEW.org_id, v_app_uuid, v_channel_uuid, + v_granted_by, now(), v_sync_reason, true + ) ON CONFLICT DO NOTHING; + END IF; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."sync_org_user_to_role_binding"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."sync_org_user_to_role_binding"() IS 'Automatically creates/updates role_bindings entries when org_users entries are inserted, ensuring both systems stay in sync. For org-level read/upload/write rights, creates org_member + app-level roles for each app.'; + + + +CREATE OR REPLACE FUNCTION "public"."top_up_usage_credits"("p_org_id" "uuid", "p_amount" numeric, "p_expires_at" timestamp with time zone DEFAULT NULL::timestamp with time zone, "p_source" "text" DEFAULT 'manual'::"text", "p_source_ref" "jsonb" DEFAULT NULL::"jsonb", "p_notes" "text" DEFAULT NULL::"text") RETURNS TABLE("grant_id" "uuid", "transaction_id" bigint, "available_credits" numeric, "total_credits" numeric, "next_expiration" timestamp with time zone) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + c_empty CONSTANT text := ''; + c_service_role CONSTANT text := 'service_role'; + c_default_source CONSTANT text := 'manual'; + c_purchase CONSTANT public.credit_transaction_type := 'purchase'::public.credit_transaction_type; + c_session_id_key CONSTANT text := 'sessionId'; + c_payment_intent_key CONSTANT text := 'paymentIntentId'; + v_request_role text := current_setting('request.jwt.claim.role', true); + v_effective_expires timestamptz := COALESCE(p_expires_at, NOW() + interval '1 year'); + v_source_ref jsonb := p_source_ref; + v_session_id text := NULLIF(v_source_ref ->> c_session_id_key, c_empty); + v_payment_intent_id text := NULLIF(v_source_ref ->> c_payment_intent_key, c_empty); + v_grant_id uuid; + v_transaction_id bigint; + v_available numeric := 0; + v_total numeric := 0; + v_next_expiration timestamptz; + v_existing_transaction_id bigint; + v_existing_grant_id uuid; +BEGIN + IF current_user <> 'postgres' AND COALESCE(v_request_role, c_empty) <> c_service_role THEN + RAISE EXCEPTION 'insufficient_privileges'; + END IF; + + IF p_org_id IS NULL THEN + RAISE EXCEPTION 'org_id is required'; + END IF; + + IF p_amount IS NULL OR p_amount <= 0 THEN + RAISE EXCEPTION 'amount must be positive'; + END IF; + + -- Guard the grant/transaction creation inside a subtransaction so we can detect + -- race-condition duplicates via the new unique indexes and return the existing + -- ledger row instead of creating another grant. + BEGIN + INSERT INTO public.usage_credit_grants ( + org_id, + credits_total, + credits_consumed, + granted_at, + expires_at, + source, + source_ref, + notes + ) + VALUES ( + p_org_id, + p_amount, + 0, + NOW(), + v_effective_expires, + COALESCE(NULLIF(p_source, c_empty), c_default_source), + v_source_ref, + p_notes + ) + RETURNING id INTO v_grant_id; + + SELECT + COALESCE(b.total_credits, 0), + COALESCE(b.available_credits, 0), + b.next_expiration + INTO v_total, v_available, v_next_expiration + FROM public.usage_credit_balances AS b + WHERE b.org_id = p_org_id; + + INSERT INTO public.usage_credit_transactions ( + org_id, + grant_id, + transaction_type, + amount, + balance_after, + description, + source_ref + ) + VALUES ( + p_org_id, + v_grant_id, + c_purchase, + p_amount, + v_available, + p_notes, + v_source_ref + ) + RETURNING id INTO v_transaction_id; + + EXCEPTION WHEN unique_violation THEN + IF v_session_id IS NULL AND v_payment_intent_id IS NULL THEN + RAISE; + END IF; + + SELECT t.id, t.grant_id + INTO v_existing_transaction_id, v_existing_grant_id + FROM public.usage_credit_transactions AS t + WHERE t.org_id = p_org_id + AND t.transaction_type = c_purchase + AND ( + (v_session_id IS NOT NULL AND t.source_ref ->> c_session_id_key = v_session_id) + OR (v_payment_intent_id IS NOT NULL AND t.source_ref ->> c_payment_intent_key = v_payment_intent_id) + ) + ORDER BY t.id DESC + LIMIT 1; + + IF NOT FOUND THEN + RAISE; + END IF; + + SELECT + COALESCE(b.total_credits, 0), + COALESCE(b.available_credits, 0), + b.next_expiration + INTO v_total, v_available, v_next_expiration + FROM public.usage_credit_balances AS b + WHERE b.org_id = p_org_id; + + v_grant_id := v_existing_grant_id; + v_transaction_id := v_existing_transaction_id; + END; + + grant_id := v_grant_id; + transaction_id := v_transaction_id; + available_credits := v_available; + total_credits := v_total; + next_expiration := v_next_expiration; + + RETURN NEXT; + RETURN; +END; +$$; + + +ALTER FUNCTION "public"."top_up_usage_credits"("p_org_id" "uuid", "p_amount" numeric, "p_expires_at" timestamp with time zone, "p_source" "text", "p_source_ref" "jsonb", "p_notes" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."top_up_usage_credits"("p_org_id" "uuid", "p_amount" numeric, "p_expires_at" timestamp with time zone, "p_source" "text", "p_source_ref" "jsonb", "p_notes" "text") IS 'Grants credits to an organization, records the transaction ledger entry, and returns the updated balances.'; + + + +CREATE OR REPLACE FUNCTION "public"."total_bundle_storage_bytes"() RETURNS bigint + LANGUAGE "sql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ + SELECT ( + -- Sum bundle sizes only for active app versions. + COALESCE( + ( + SELECT SUM(avm.size) + FROM public.app_versions_meta avm + INNER JOIN public.app_versions av ON av.id = avm.id + WHERE av.deleted = false + ), + 0 + ) + + -- Sum manifest file sizes only for active app versions. + COALESCE( + ( + SELECT SUM(m.file_size) + FROM public.manifest m + WHERE EXISTS ( + SELECT 1 + FROM public.app_versions av + WHERE av.id = m.app_version_id + AND av.deleted = false + ) + ), + 0 + ) + )::bigint; +$$; + + +ALTER FUNCTION "public"."total_bundle_storage_bytes"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."total_bundle_storage_bytes"() IS 'Returns active bundle storage in bytes including bundle sizes (app_versions_meta.size) and manifest file sizes for non-deleted app versions.'; + + + +CREATE OR REPLACE FUNCTION "public"."track_onboarding_demo_data"("p_app_id" "text", "p_owner_org" "uuid", "p_relation_name" "text", "p_row_keys" "text"[], "p_seed_id" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + IF p_app_id IS NULL OR btrim(p_app_id) = '' THEN + RAISE EXCEPTION 'track_onboarding_demo_data: app_id is required'; + END IF; + + IF p_owner_org IS NULL THEN + RAISE EXCEPTION 'track_onboarding_demo_data: owner_org is required'; + END IF; + + IF p_seed_id IS NULL THEN + RAISE EXCEPTION 'track_onboarding_demo_data: seed_id is required'; + END IF; + + IF p_relation_name IS NULL OR NOT ( + p_relation_name = ANY (ARRAY[ + 'app_versions'::text, + 'app_versions_meta'::text, + 'manifest'::text, + 'channels'::text, + 'channel_devices'::text, + 'deploy_history'::text, + 'devices'::text, + 'build_requests'::text + ]) + ) THEN + RAISE EXCEPTION 'track_onboarding_demo_data: unsupported relation %', p_relation_name; + END IF; + + INSERT INTO "public"."onboarding_demo_data" ( + "app_id", + "owner_org", + "relation_name", + "row_key", + "seed_id" + ) + SELECT + p_app_id, + p_owner_org, + p_relation_name, + key_value, + p_seed_id + FROM "unnest"(p_row_keys) AS keys("key_value") + WHERE "key_value" IS NOT NULL + AND "btrim"("key_value") <> '' + ON CONFLICT ("app_id", "relation_name", "row_key") DO UPDATE + SET + "owner_org" = EXCLUDED."owner_org", + "seed_id" = EXCLUDED."seed_id", + "created_at" = "now"(); +END; +$$; + + +ALTER FUNCTION "public"."track_onboarding_demo_data"("p_app_id" "text", "p_owner_org" "uuid", "p_relation_name" "text", "p_row_keys" "text"[], "p_seed_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."transfer_app"("p_app_id" character varying, "p_new_org_id" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_old_org_id uuid; + v_user_id uuid; + v_last_transfer jsonb; + v_last_transfer_date timestamp; + v_transfer_error constant text := 'Unable to process transfer request.'; + v_app_id_key constant text := 'app_id'; + v_old_org_id_key constant text := 'old_org_id'; + v_new_org_id_key constant text := 'new_org_id'; + v_uid_key constant text := 'uid'; +BEGIN + SELECT owner_org, transfer_history[array_length(transfer_history, 1)] + INTO v_old_org_id, v_last_transfer + FROM public.apps + WHERE app_id = p_app_id; + + IF v_old_org_id IS NULL THEN + RAISE EXCEPTION '%', v_transfer_error; + END IF; + + v_user_id := (SELECT auth.uid()); + + IF v_user_id IS NULL THEN + PERFORM public.pg_log( + 'deny: TRANSFER_NO_AUTH', + jsonb_build_object(v_app_id_key, p_app_id, v_new_org_id_key, p_new_org_id) + ); + RAISE EXCEPTION '%', v_transfer_error; + END IF; + + IF NOT public.rbac_check_permission( + public.rbac_perm_app_transfer(), + v_old_org_id, + p_app_id, + NULL::bigint + ) THEN + PERFORM public.pg_log( + 'deny: TRANSFER_OLD_ORG_RIGHTS', + jsonb_build_object( + v_app_id_key, p_app_id, + v_old_org_id_key, v_old_org_id, + v_new_org_id_key, p_new_org_id, + v_uid_key, v_user_id + ) + ); + RAISE EXCEPTION '%', v_transfer_error; + END IF; + + IF NOT public.rbac_check_permission( + public.rbac_perm_app_transfer(), + p_new_org_id, + NULL::character varying, + NULL::bigint + ) THEN + PERFORM public.pg_log( + 'deny: TRANSFER_NEW_ORG_RIGHTS', + jsonb_build_object( + v_app_id_key, p_app_id, + v_old_org_id_key, v_old_org_id, + v_new_org_id_key, p_new_org_id, + v_uid_key, v_user_id + ) + ); + RAISE EXCEPTION '%', v_transfer_error; + END IF; + + IF v_last_transfer IS NOT NULL THEN + v_last_transfer_date := (v_last_transfer->>'transferred_at')::timestamp; + IF v_last_transfer_date + interval '32 days' > now() THEN + RAISE EXCEPTION + 'Cannot transfer app. Must wait at least 32 days ' + 'between transfers. Last transfer was on %', + v_last_transfer_date; + END IF; + END IF; + + BEGIN + -- Allow the guarded owner_org cascade only inside the approved transfer path. + PERFORM set_config('capgo.allow_owner_org_transfer', 'true', true); + + UPDATE public.apps + SET + owner_org = p_new_org_id, + updated_at = now(), + transfer_history = COALESCE(transfer_history, '{}') || jsonb_build_object( + 'transferred_at', now(), + 'transferred_from', v_old_org_id, + 'transferred_to', p_new_org_id, + 'initiated_by', v_user_id + )::jsonb + WHERE app_id = p_app_id; + + UPDATE public.app_versions + SET owner_org = p_new_org_id + WHERE app_id = p_app_id; + + UPDATE public.app_versions_meta + SET owner_org = p_new_org_id + WHERE app_id = p_app_id; + + UPDATE public.channel_devices + SET owner_org = p_new_org_id + WHERE app_id = p_app_id; + + UPDATE public.channels + SET owner_org = p_new_org_id + WHERE app_id = p_app_id; + + UPDATE public.deploy_history + SET owner_org = p_new_org_id + WHERE app_id = p_app_id; + + PERFORM set_config('capgo.allow_owner_org_transfer', 'false', true); + EXCEPTION + WHEN OTHERS THEN + PERFORM set_config('capgo.allow_owner_org_transfer', 'false', true); + RAISE; + END; + +END; +$$; + + +ALTER FUNCTION "public"."transfer_app"("p_app_id" character varying, "p_new_org_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."transfer_app"("p_app_id" character varying, "p_new_org_id" "uuid") IS 'Transfers an app and all its related data to a new organization. Requires app.transfer permission on both source and destination organizations.'; + + + +CREATE OR REPLACE FUNCTION "public"."transform_role_to_invite"("role_input" "public"."user_min_right") RETURNS "public"."user_min_right" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + CASE role_input + WHEN 'read'::public.user_min_right THEN RETURN 'invite_read'::public.user_min_right; + WHEN 'upload'::public.user_min_right THEN RETURN 'invite_upload'::public.user_min_right; + WHEN 'write'::public.user_min_right THEN RETURN 'invite_write'::public.user_min_right; + WHEN 'admin'::public.user_min_right THEN RETURN 'invite_admin'::public.user_min_right; + WHEN 'super_admin'::public.user_min_right THEN RETURN 'invite_super_admin'::public.user_min_right; + ELSE RETURN role_input; -- If it's already an invite role or unrecognized, return as is + END CASE; +END; +$$; + + +ALTER FUNCTION "public"."transform_role_to_invite"("role_input" "public"."user_min_right") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."transform_role_to_non_invite"("role_input" "public"."user_min_right") RETURNS "public"."user_min_right" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + CASE role_input + WHEN 'invite_read'::public.user_min_right THEN RETURN 'read'::public.user_min_right; + WHEN 'invite_upload'::public.user_min_right THEN RETURN 'upload'::public.user_min_right; + WHEN 'invite_write'::public.user_min_right THEN RETURN 'write'::public.user_min_right; + WHEN 'invite_admin'::public.user_min_right THEN RETURN 'admin'::public.user_min_right; + WHEN 'invite_super_admin'::public.user_min_right THEN RETURN 'super_admin'::public.user_min_right; + ELSE RETURN role_input; -- If it's already a non-invite role or unrecognized, return as is + END CASE; +END; +$$; + + +ALTER FUNCTION "public"."transform_role_to_non_invite"("role_input" "public"."user_min_right") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."trigger_http_queue_post_to_function"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + payload jsonb; +BEGIN + -- Build the base payload + payload := jsonb_build_object( + 'function_name', TG_ARGV[0], + 'function_type', TG_ARGV[1], + 'payload', jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ) + ); + + -- Also send to function-specific queue + IF TG_ARGV[0] IS NOT NULL THEN + PERFORM pgmq.send(TG_ARGV[0], payload); + END IF; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."trigger_http_queue_post_to_function"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."trigger_webhook_on_audit_log"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + -- Queue the audit log event for webhook dispatch + PERFORM pgmq.send( + 'webhook_dispatcher', + jsonb_build_object( + 'function_name', 'webhook_dispatcher', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'audit_log_id', NEW.id, + 'table_name', NEW.table_name, + 'operation', NEW.operation, + 'org_id', NEW.org_id, + 'record_id', NEW.record_id, + 'old_record', NEW.old_record, + 'new_record', NEW.new_record, + 'changed_fields', NEW.changed_fields, + 'user_id', NEW.user_id, + 'created_at', NEW.created_at + ) + ) + ); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."trigger_webhook_on_audit_log"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_app_versions_retention"() RETURNS "void" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + UPDATE public.app_versions + SET deleted = true + WHERE app_versions.deleted = false + AND (SELECT retention FROM public.apps WHERE apps.app_id = app_versions.app_id) >= 0 + AND (SELECT retention FROM public.apps WHERE apps.app_id = app_versions.app_id) < 63113904 + AND app_versions.created_at < ( + SELECT NOW() - make_interval(secs => apps.retention) + FROM public.apps + WHERE apps.app_id = app_versions.app_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM public.channels + WHERE channels.app_id = app_versions.app_id + AND channels.version = app_versions.id + ); +END; +$$; + + +ALTER FUNCTION "public"."update_app_versions_retention"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_apps_build_timeout_updated_at"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + NEW."build_timeout_updated_at" := COALESCE(NEW."build_timeout_updated_at", now()); + ELSIF NEW."build_timeout_seconds" IS DISTINCT FROM OLD."build_timeout_seconds" THEN + NEW."build_timeout_updated_at" := now(); + ELSE + NEW."build_timeout_updated_at" := OLD."build_timeout_updated_at"; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."update_apps_build_timeout_updated_at"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + role_id uuid; + legacy_right public.user_min_right; + invite_right public.user_min_right; + api_key_text text; +BEGIN + IF NOT public.rbac_is_enabled_for_org(p_org_id) THEN + RAISE EXCEPTION 'RBAC_NOT_ENABLED'; + END IF; + + SELECT id INTO role_id + FROM public.roles r + WHERE r.name = p_new_role_name + AND r.scope_type = public.rbac_scope_org() + AND r.is_assignable = true + LIMIT 1; + + IF role_id IS NULL THEN + RAISE EXCEPTION 'ROLE_NOT_FOUND'; + END IF; + + SELECT public.get_apikey_header() INTO api_key_text; + + IF p_new_role_name = public.rbac_role_org_super_admin() THEN + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + ELSE + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_invite_user(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + END IF; + + legacy_right := public.rbac_legacy_right_for_org_role(p_new_role_name); + invite_right := public.transform_role_to_invite(legacy_right); + + UPDATE public.org_users + SET user_right = invite_right, + rbac_role_name = p_new_role_name, + updated_at = now() + WHERE org_id = p_org_id + AND user_id = p_user_id + AND user_right::text LIKE 'invite_%'; + + IF NOT FOUND THEN + RAISE EXCEPTION 'NO_INVITATION'; + END IF; + + RETURN 'OK'; +END; +$$; + + +ALTER FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_new_role_id uuid; + v_existing_binding_id uuid; + v_org_created_by uuid; + v_role_family text; +BEGIN + -- Check if user has permission to update roles + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), p_org_id, NULL, NULL) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + + -- Get org owner to prevent removing the last super admin + SELECT created_by INTO v_org_created_by + FROM public.orgs + WHERE id = p_org_id; + + -- Prevent changing the org owner's role + IF p_user_id = v_org_created_by THEN + RAISE EXCEPTION 'CANNOT_CHANGE_OWNER_ROLE'; + END IF; + + -- Validate the new role exists and is an org-level role + SELECT r.id, r.scope_type INTO v_new_role_id, v_role_family + FROM public.roles r + WHERE r.name = p_new_role_name + LIMIT 1; + + IF v_new_role_id IS NULL THEN + RAISE EXCEPTION 'ROLE_NOT_FOUND'; + END IF; + + IF v_role_family != public.rbac_scope_org() THEN + RAISE EXCEPTION 'ROLE_MUST_BE_ORG_LEVEL'; + END IF; + + -- Check if changing from super_admin and if this is the last super_admin + IF EXISTS ( + SELECT 1 + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.principal_id = p_user_id + AND rb.principal_type = public.rbac_principal_user() + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id = p_org_id + AND r.name = public.rbac_role_org_super_admin() + ) THEN + -- Count super admins in this org + IF ( + SELECT COUNT(*) + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.scope_type = public.rbac_scope_org() + AND rb.org_id = p_org_id + AND rb.principal_type = public.rbac_principal_user() + AND r.name = public.rbac_role_org_super_admin() + ) <= 1 AND p_new_role_name != public.rbac_role_org_super_admin() THEN + RAISE EXCEPTION 'CANNOT_REMOVE_LAST_SUPER_ADMIN'; + END IF; + END IF; + + -- Find existing role binding for this user at org level + SELECT rb.id INTO v_existing_binding_id + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.principal_id = p_user_id + AND rb.principal_type = public.rbac_principal_user() + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id = p_org_id + AND r.scope_type = public.rbac_scope_org() + LIMIT 1; + + -- Delete existing org-level role binding if it exists + IF v_existing_binding_id IS NOT NULL THEN + DELETE FROM public.role_bindings + WHERE id = v_existing_binding_id; + END IF; + + -- Create new role binding + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + channel_id, + granted_by, + granted_at, + reason, + is_direct + ) VALUES ( + public.rbac_principal_user(), + p_user_id, + v_new_role_id, + public.rbac_scope_org(), + p_org_id, + NULL, + NULL, + auth.uid(), + NOW(), + 'Role updated via update_org_member_role', + true + ); + + RETURN 'OK'; +END; +$$; + + +ALTER FUNCTION "public"."update_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."update_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") IS 'Updates an organization member''s role. Requires org.update_user_roles permission. Returns OK on success.'; + + + +CREATE OR REPLACE FUNCTION "public"."update_sso_providers_updated_at"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."update_sso_providers_updated_at"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uuid", "p_email" "text", "p_new_role_name" "text") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + role_id uuid; + legacy_right public.user_min_right; + api_key_text text; +BEGIN + IF NOT public.rbac_is_enabled_for_org(p_org_id) THEN + RAISE EXCEPTION 'RBAC_NOT_ENABLED'; + END IF; + + SELECT id INTO role_id + FROM public.roles r + WHERE r.name = p_new_role_name + AND r.scope_type = public.rbac_scope_org() + AND r.is_assignable = true + LIMIT 1; + + IF role_id IS NULL THEN + RAISE EXCEPTION 'ROLE_NOT_FOUND'; + END IF; + + SELECT public.get_apikey_header() INTO api_key_text; + + IF p_new_role_name = public.rbac_role_org_super_admin() THEN + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + ELSE + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_invite_user(), auth.uid(), p_org_id, NULL, NULL, api_key_text) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + END IF; + + legacy_right := public.rbac_legacy_right_for_org_role(p_new_role_name); + + UPDATE public.tmp_users + SET role = legacy_right, + rbac_role_name = p_new_role_name, + updated_at = now() + WHERE org_id = p_org_id + AND email = p_email + AND cancelled_at IS NULL; + + IF NOT FOUND THEN + RAISE EXCEPTION 'NO_INVITATION'; + END IF; + + RETURN 'OK'; +END; +$$; + + +ALTER FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uuid", "p_email" "text", "p_new_role_name" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_webhook_updated_at"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."update_webhook_updated_at"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."upsert_version_meta"("p_app_id" character varying, "p_version_id" bigint, "p_size" bigint) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_owner_org uuid; + v_caller_id uuid; + v_existing_count integer; + v_version_exists boolean; +BEGIN + IF p_size = 0 THEN + RETURN FALSE; + END IF; + + SELECT owner_org + INTO v_owner_org + FROM public.apps + WHERE app_id = p_app_id + LIMIT 1; + + IF v_owner_org IS NULL THEN + RETURN FALSE; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.app_versions av + WHERE av.app_id = p_app_id + AND av.id = p_version_id + ) + INTO v_version_exists; + + IF NOT v_version_exists THEN + RETURN FALSE; + END IF; + + IF COALESCE(current_setting('role', true), '') NOT IN ('service_role', 'postgres') + AND COALESCE(session_user, current_user) NOT IN ('service_role', 'postgres') THEN + SELECT public.get_identity_org_appid('{write,all}'::public.key_mode[], v_owner_org, p_app_id) + INTO v_caller_id; + + IF v_caller_id IS NULL THEN + RETURN FALSE; + END IF; + + IF NOT public.check_min_rights( + 'write'::public.user_min_right, + v_caller_id, + v_owner_org, + p_app_id, + NULL::bigint + ) THEN + RETURN FALSE; + END IF; + END IF; + + -- Check if a row already exists for this app_id/version_id with same sign. + IF p_size > 0 THEN + SELECT COUNT(*) INTO v_existing_count + FROM public.version_meta + WHERE public.version_meta.app_id = p_app_id + AND public.version_meta.version_id = p_version_id + AND public.version_meta.size > 0; + ELSIF p_size < 0 THEN + SELECT COUNT(*) INTO v_existing_count + FROM public.version_meta + WHERE public.version_meta.app_id = p_app_id + AND public.version_meta.version_id = p_version_id + AND public.version_meta.size < 0; + END IF; + + -- If row already exists, do nothing and return false. + IF v_existing_count > 0 THEN + RETURN FALSE; + END IF; + + INSERT INTO public.version_meta (app_id, version_id, size) + VALUES ( + p_app_id, + p_version_id, + p_size + ); + + RETURN TRUE; + +EXCEPTION + WHEN unique_violation THEN + RETURN FALSE; +END; +$$; + + +ALTER FUNCTION "public"."upsert_version_meta"("p_app_id" character varying, "p_version_id" bigint, "p_size" bigint) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."usage_credit_readable_org_ids"() RETURNS "uuid"[] + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_api_key_text text; + v_api_key public.apikeys%ROWTYPE; + v_permission text := public.rbac_permission_for_legacy(public.rbac_right_admin(), public.rbac_scope_org()); + v_allowed uuid[] := '{}'::uuid[]; +BEGIN + SELECT auth.uid() INTO v_user_id; + SELECT public.get_apikey_header() INTO v_api_key_text; + + IF v_user_id IS NULL AND v_api_key_text IS NULL THEN + RETURN v_allowed; + END IF; + + IF v_api_key_text IS NOT NULL THEN + SELECT * INTO v_api_key FROM public.find_apikey_by_value(v_api_key_text) LIMIT 1; + IF v_api_key.id IS NULL OR public.is_apikey_expired(v_api_key.expires_at) THEN + RETURN v_allowed; + END IF; + v_user_id := v_api_key.user_id; + END IF; + + SELECT COALESCE(array_agg(DISTINCT orgs.id), '{}'::uuid[]) + INTO v_allowed + FROM public.orgs + WHERE CASE + WHEN v_api_key.id IS NOT NULL THEN public.rbac_check_permission_direct(v_permission, v_user_id, orgs.id, NULL, NULL, v_api_key_text) + ELSE public.check_min_rights('admin'::public.user_min_right, v_user_id, orgs.id, NULL::character varying, NULL::bigint) + END; + + RETURN v_allowed; +END; +$$; + + +ALTER FUNCTION "public"."usage_credit_readable_org_ids"() OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."usage_credit_readable_org_ids"() IS 'Returns org IDs whose usage-credit rows are readable by the current authenticated user or Capgo API key. It evaluates candidate orgs from legacy/RBAC bindings once per statement, then verifies each candidate with check_min_rights() to avoid per-row RLS work while preserving authorization semantics.'; + + + +CREATE OR REPLACE FUNCTION "public"."user_has_app_update_user_roles"("p_user_id" "uuid", "p_app_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_app_id_varchar text; + v_org_id uuid; + v_caller_id uuid; +BEGIN + -- Use SELECT to evaluate auth.uid() once + SELECT auth.uid() INTO v_caller_id; + + IF v_caller_id IS NULL THEN + RETURN false; + END IF; + + -- Fetch app_id varchar and org_id from apps table + SELECT app_id, owner_org INTO v_app_id_varchar, v_org_id + FROM public.apps + WHERE id = p_app_id + LIMIT 1; + + IF v_app_id_varchar IS NULL OR v_org_id IS NULL THEN + RETURN false; + END IF; + + IF v_caller_id <> p_user_id THEN + IF NOT EXISTS ( + SELECT 1 + FROM public.role_bindings rb + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = v_caller_id + AND (rb.org_id = v_org_id OR rb.app_id = p_app_id) + ) THEN + RETURN false; + END IF; + END IF; + + -- Use rbac_has_permission to check the permission + RETURN public.rbac_has_permission( + public.rbac_principal_user(), + p_user_id, + public.rbac_perm_app_update_user_roles(), + v_org_id, + v_app_id_varchar, + NULL + ); +END; +$$; + + +ALTER FUNCTION "public"."user_has_app_update_user_roles"("p_user_id" "uuid", "p_app_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."user_has_app_update_user_roles"("p_user_id" "uuid", "p_app_id" "uuid") IS 'Checks whether a user has app.update_user_roles permission (bypasses RLS to avoid recursion). Optimized with SELECT auth.uid() pattern.'; + + + +CREATE OR REPLACE FUNCTION "public"."user_has_role_in_app"("p_user_id" "uuid", "p_app_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_caller_id uuid; + v_org_id uuid; +BEGIN + -- Use SELECT to evaluate auth.uid() once + SELECT auth.uid() INTO v_caller_id; + + IF v_caller_id IS NULL THEN + RETURN false; + END IF; + + IF v_caller_id <> p_user_id THEN + SELECT owner_org INTO v_org_id + FROM public.apps + WHERE id = p_app_id + LIMIT 1; + + IF v_org_id IS NULL THEN + RETURN false; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.role_bindings rb + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = v_caller_id + AND (rb.org_id = v_org_id OR rb.app_id = p_app_id) + ) THEN + RETURN false; + END IF; + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM public.role_bindings rb + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = p_user_id + AND rb.app_id = p_app_id + AND rb.scope_type = public.rbac_scope_app() + ); +END; +$$; + + +ALTER FUNCTION "public"."user_has_role_in_app"("p_user_id" "uuid", "p_app_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."user_has_role_in_app"("p_user_id" "uuid", "p_app_id" "uuid") IS 'Checks whether a user has a role in an app (bypasses RLS to avoid recursion). Optimized with SELECT auth.uid() pattern.'; + + + +CREATE OR REPLACE FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_policy_config jsonb; + org_policy_hash text; + compliance_record_hash text; +BEGIN + -- Get org's password policy config + SELECT password_policy_config + INTO org_policy_config + FROM public.orgs + WHERE public.orgs.id = user_meets_password_policy.org_id; + + -- If no policy or policy is disabled, user passes + IF org_policy_config IS NULL OR COALESCE((org_policy_config->>'enabled')::boolean, false) = false THEN + RETURN true; + END IF; + + -- Compute the hash of the current policy + org_policy_hash := public.get_password_policy_hash(org_policy_config); + + -- Check if user has a valid compliance record with matching policy hash + SELECT policy_hash INTO compliance_record_hash + FROM public.user_password_compliance + WHERE public.user_password_compliance.user_id = user_meets_password_policy.user_id + AND public.user_password_compliance.org_id = user_meets_password_policy.org_id; + + -- User passes if they have a compliance record AND the policy hash matches + -- (If policy changed, they need to re-validate) + RETURN compliance_record_hash IS NOT NULL AND compliance_record_hash = org_policy_hash; +END; +$$; + + +ALTER FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."verify_api_key_hash"("plain_key" "text", "stored_hash" "text") RETURNS boolean + LANGUAGE "plpgsql" + SET "search_path" TO '' + AS $$ +BEGIN + RETURN encode(extensions.digest(plain_key, 'sha256'), 'hex') = stored_hash; +END; +$$; + + +ALTER FUNCTION "public"."verify_api_key_hash"("plain_key" "text", "stored_hash" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."verify_mfa"() RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN ( + array[(SELECT coalesce(auth.jwt()->>'aal', 'aal1'))] <@ ( + SELECT + CASE + WHEN count(id) > 0 THEN array['aal2'] + ELSE array['aal1', 'aal2'] + END AS aal + FROM auth.mfa_factors + WHERE (SELECT auth.uid()) = user_id AND status = 'verified' + ) + ) OR ( + EXISTS( + SELECT 1 FROM jsonb_array_elements((SELECT auth.jwt())->'amr') AS amr_elem + WHERE amr_elem->>'method' = 'otp' + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."verify_mfa"() OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."apikey_global_permissions" ( + "id" bigint NOT NULL, + "apikey_rbac_id" "uuid" NOT NULL, + "permission_key" "text" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "granted_by" "uuid", + "reason" "text", + CONSTRAINT "apikey_global_permissions_permission_key_not_empty" CHECK (("permission_key" <> ''::"text")) +); + + +ALTER TABLE "public"."apikey_global_permissions" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."apikey_global_permissions" IS 'Global permissions for API keys where no org/app/channel target exists yet. Currently used to grandfather org creation for existing write-capable keys without granting it to future keys by default.'; + + + +ALTER TABLE "public"."apikey_global_permissions" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."apikey_global_permissions_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +ALTER TABLE "public"."apikeys" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."apikeys_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +ALTER TABLE "public"."app_metrics_cache" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."app_metrics_cache_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +ALTER TABLE "public"."app_versions" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."app_versions_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."app_versions_meta" ( + "created_at" timestamp with time zone DEFAULT "now"(), + "app_id" character varying NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"(), + "checksum" character varying NOT NULL, + "size" bigint NOT NULL, + "id" bigint NOT NULL, + "owner_org" "uuid" NOT NULL +); + + +ALTER TABLE "public"."app_versions_meta" OWNER TO "postgres"; + + +ALTER TABLE "public"."app_versions_meta" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."app_versions_meta_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."audit_logs" ( + "id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "table_name" "text" NOT NULL, + "record_id" "text" NOT NULL, + "operation" "text" NOT NULL, + "user_id" "uuid", + "org_id" "uuid" NOT NULL, + "old_record" "jsonb", + "new_record" "jsonb", + "changed_fields" "text"[] +); + + +ALTER TABLE "public"."audit_logs" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."audit_logs" IS 'Audit log for tracking changes to orgs, apps, channels, app_versions, and org_users tables'; + + + +COMMENT ON COLUMN "public"."audit_logs"."table_name" IS 'Name of the table that was modified (orgs, apps, channels, app_versions, org_users)'; + + + +COMMENT ON COLUMN "public"."audit_logs"."record_id" IS 'Primary key of the affected record'; + + + +COMMENT ON COLUMN "public"."audit_logs"."operation" IS 'Type of operation: INSERT, UPDATE, or DELETE'; + + + +COMMENT ON COLUMN "public"."audit_logs"."user_id" IS 'User who made the change (from auth.uid() or API key)'; + + + +COMMENT ON COLUMN "public"."audit_logs"."org_id" IS 'Organization context for filtering'; + + + +COMMENT ON COLUMN "public"."audit_logs"."old_record" IS 'Previous state of the record (null for INSERT)'; + + + +COMMENT ON COLUMN "public"."audit_logs"."new_record" IS 'New state of the record (null for DELETE)'; + + + +COMMENT ON COLUMN "public"."audit_logs"."changed_fields" IS 'Array of field names that changed (for UPDATE operations)'; + + + +CREATE SEQUENCE IF NOT EXISTS "public"."audit_logs_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."audit_logs_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."audit_logs_id_seq" OWNED BY "public"."audit_logs"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."bandwidth_usage" ( + "id" integer NOT NULL, + "device_id" character varying(255) NOT NULL, + "app_id" character varying(255) NOT NULL, + "file_size" bigint NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +ALTER TABLE "public"."bandwidth_usage" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."bandwidth_usage_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."bandwidth_usage_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."bandwidth_usage_id_seq" OWNED BY "public"."bandwidth_usage"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."build_logs" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "org_id" "uuid" NOT NULL, + "user_id" "uuid", + "build_id" character varying NOT NULL, + "platform" character varying NOT NULL, + "billable_seconds" bigint NOT NULL, + "build_time_unit" bigint NOT NULL, + "app_id" character varying, + CONSTRAINT "build_logs_billable_seconds_check" CHECK (("billable_seconds" >= 0)), + CONSTRAINT "build_logs_build_time_unit_check" CHECK (("build_time_unit" >= 0)), + CONSTRAINT "build_logs_platform_check" CHECK ((("platform")::"text" = ANY (ARRAY[('ios'::character varying)::"text", ('android'::character varying)::"text"]))) +); + + +ALTER TABLE "public"."build_logs" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."build_requests" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "app_id" character varying NOT NULL, + "owner_org" "uuid" NOT NULL, + "requested_by" "uuid" NOT NULL, + "platform" character varying NOT NULL, + "build_mode" character varying DEFAULT 'release'::character varying NOT NULL, + "build_config" "jsonb" DEFAULT '{}'::"jsonb", + "status" character varying DEFAULT 'pending'::character varying NOT NULL, + "builder_job_id" character varying, + "upload_session_key" character varying NOT NULL, + "upload_path" character varying NOT NULL, + "upload_url" character varying NOT NULL, + "upload_expires_at" timestamp with time zone NOT NULL, + "last_error" "text", + "runner_wait_seconds" bigint DEFAULT 0 NOT NULL, + "ai_analyzed" boolean DEFAULT false NOT NULL, + CONSTRAINT "build_requests_platform_check" CHECK ((("platform")::"text" = ANY ((ARRAY['ios'::character varying, 'android'::character varying])::"text"[]))) +); + + +ALTER TABLE "public"."build_requests" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."build_requests"."runner_wait_seconds" IS 'Self-hosted runner wait time reported by builder, in seconds. Informational only; not used for billing.'; + + + +COMMENT ON COLUMN "public"."build_requests"."ai_analyzed" IS 'Set true after a successful AI analysis of this failed build. Enforces one-analysis-per-job for cost control.'; + + + +CREATE TABLE IF NOT EXISTS "public"."capgo_credits_steps" ( + "id" bigint NOT NULL, + "step_min" bigint NOT NULL, + "step_max" bigint NOT NULL, + "price_per_unit" double precision NOT NULL, + "type" "text" NOT NULL, + "unit_factor" bigint DEFAULT 1 NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "org_id" "uuid", + CONSTRAINT "step_range_check" CHECK (("step_min" < "step_max")) +); + + +ALTER TABLE "public"."capgo_credits_steps" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."capgo_credits_steps" IS 'Table to store token pricing tiers'; + + + +COMMENT ON COLUMN "public"."capgo_credits_steps"."id" IS 'The unique identifier for the pricing tier'; + + + +COMMENT ON COLUMN "public"."capgo_credits_steps"."step_min" IS 'The minimum number of credits for this tier'; + + + +COMMENT ON COLUMN "public"."capgo_credits_steps"."step_max" IS 'The maximum number of credits for this tier'; + + + +COMMENT ON COLUMN "public"."capgo_credits_steps"."price_per_unit" IS 'The price per token in this tier'; + + + +COMMENT ON COLUMN "public"."capgo_credits_steps"."unit_factor" IS 'The unit conversion factor (e.g., bytes to GB = 1073741824)'; + + + +COMMENT ON COLUMN "public"."capgo_credits_steps"."created_at" IS 'Timestamp when the tier was created'; + + + +COMMENT ON COLUMN "public"."capgo_credits_steps"."updated_at" IS 'Timestamp when the tier was last updated'; + + + +COMMENT ON COLUMN "public"."capgo_credits_steps"."org_id" IS 'Optional organization owner for this pricing tier'; + + + +CREATE SEQUENCE IF NOT EXISTS "public"."capgo_credits_steps_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."capgo_credits_steps_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."capgo_credits_steps_id_seq" OWNED BY "public"."capgo_credits_steps"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."channel_devices" ( + "created_at" timestamp with time zone DEFAULT "now"(), + "channel_id" bigint NOT NULL, + "app_id" character varying NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "device_id" "text" NOT NULL, + "id" bigint NOT NULL, + "owner_org" "uuid" NOT NULL +); + + +ALTER TABLE "public"."channel_devices" OWNER TO "postgres"; + + +ALTER TABLE "public"."channel_devices" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."channel_devices_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."channels" ( + "id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "name" character varying NOT NULL, + "app_id" character varying NOT NULL, + "version" bigint, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "public" boolean DEFAULT false NOT NULL, + "disable_auto_update_under_native" boolean DEFAULT true NOT NULL, + "ios" boolean DEFAULT true NOT NULL, + "android" boolean DEFAULT true NOT NULL, + "allow_device_self_set" boolean DEFAULT false NOT NULL, + "allow_emulator" boolean DEFAULT true NOT NULL, + "allow_device" boolean DEFAULT true NOT NULL, + "allow_dev" boolean DEFAULT true NOT NULL, + "allow_prod" boolean DEFAULT true NOT NULL, + "disable_auto_update" "public"."disable_update" DEFAULT 'major'::"public"."disable_update" NOT NULL, + "owner_org" "uuid" NOT NULL, + "created_by" "uuid" NOT NULL, + "rbac_id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "electron" boolean DEFAULT true NOT NULL +); + + +ALTER TABLE "public"."channels" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."channels"."rbac_id" IS 'Stable UUID to bind RBAC roles to channel scope.'; + + + +ALTER TABLE "public"."channels" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."channel_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."channel_permission_overrides" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "principal_type" "text" NOT NULL, + "principal_id" "uuid" NOT NULL, + "channel_id" bigint NOT NULL, + "permission_key" "text" NOT NULL, + "is_allowed" boolean NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + CONSTRAINT "channel_permission_overrides_principal_type_check" CHECK (("principal_type" = ANY (ARRAY["public"."rbac_principal_user"(), "public"."rbac_principal_group"(), "public"."rbac_principal_apikey"()]))) +); + + +ALTER TABLE "public"."channel_permission_overrides" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."channel_permission_overrides" IS 'Delta-only overrides for channel-scoped permissions (user > group, deny > allow).'; + + + +COMMENT ON COLUMN "public"."channel_permission_overrides"."principal_type" IS 'user | group | apikey.'; + + + +COMMENT ON COLUMN "public"."channel_permission_overrides"."principal_id" IS 'users.id, groups.id, or apikeys.rbac_id depending on principal_type.'; + + + +COMMENT ON COLUMN "public"."channel_permission_overrides"."channel_id" IS 'public.channels.id target for the override.'; + + + +COMMENT ON COLUMN "public"."channel_permission_overrides"."permission_key" IS 'RBAC permission key (channel.*).'; + + + +CREATE TABLE IF NOT EXISTS "public"."compatibility_events" ( + "id" bigint NOT NULL, + "org_id" "uuid" NOT NULL, + "app_id" "text" NOT NULL, + "source" "text" NOT NULL, + "platform" "text" NOT NULL, + "channel_id" bigint, + "channel_name" "text" NOT NULL, + "current_version_id" bigint, + "current_version_name" "text" NOT NULL, + "previous_version_id" bigint, + "previous_version_name" "text" NOT NULL, + "offenders" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "resolved_at" timestamp with time zone, + "resolved_by" "uuid", + "resolution_kind" "text", + "resolution_note" "text", + "change_occurred_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."compatibility_events" OWNER TO "postgres"; + + +ALTER TABLE "public"."compatibility_events" ALTER COLUMN "id" ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME "public"."compatibility_events_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."cron_tasks" ( + "id" integer NOT NULL, + "name" "text" NOT NULL, + "description" "text", + "task_type" "public"."cron_task_type" DEFAULT 'function'::"public"."cron_task_type" NOT NULL, + "target" "text" NOT NULL, + "batch_size" integer, + "payload" "jsonb", + "second_interval" integer, + "minute_interval" integer, + "hour_interval" integer, + "run_at_hour" integer, + "run_at_minute" integer, + "run_at_second" integer DEFAULT 0, + "run_on_dow" integer, + "run_on_day" integer, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "healthcheck_url" "text" +); + + +ALTER TABLE "public"."cron_tasks" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."cron_tasks_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."cron_tasks_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."cron_tasks_id_seq" OWNED BY "public"."cron_tasks"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."daily_bandwidth" ( + "id" integer NOT NULL, + "app_id" character varying(255) NOT NULL, + "date" "date" NOT NULL, + "bandwidth" bigint NOT NULL +) +WITH ("autovacuum_vacuum_scale_factor"='0.05', "autovacuum_analyze_scale_factor"='0.02'); + + +ALTER TABLE "public"."daily_bandwidth" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."daily_bandwidth_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."daily_bandwidth_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."daily_bandwidth_id_seq" OWNED BY "public"."daily_bandwidth"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."daily_build_time" ( + "app_id" character varying NOT NULL, + "date" "date" NOT NULL, + "build_time_unit" bigint DEFAULT 0 NOT NULL, + "build_count" bigint DEFAULT 0 NOT NULL, + CONSTRAINT "daily_build_time_build_count_check" CHECK (("build_count" >= 0)), + CONSTRAINT "daily_build_time_build_time_unit_check" CHECK (("build_time_unit" >= 0)) +); + + +ALTER TABLE "public"."daily_build_time" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."daily_mau" ( + "id" integer NOT NULL, + "app_id" character varying(255) NOT NULL, + "date" "date" NOT NULL, + "mau" bigint NOT NULL +) +WITH ("autovacuum_vacuum_scale_factor"='0.05', "autovacuum_analyze_scale_factor"='0.02'); + + +ALTER TABLE "public"."daily_mau" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."daily_mau_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."daily_mau_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."daily_mau_id_seq" OWNED BY "public"."daily_mau"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."daily_revenue_metrics" ( + "date_id" character varying NOT NULL, + "customer_id" character varying NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "opening_mrr" double precision DEFAULT 0 NOT NULL, + "new_business_mrr" double precision DEFAULT 0 NOT NULL, + "expansion_mrr" double precision DEFAULT 0 NOT NULL, + "contraction_mrr" double precision DEFAULT 0 NOT NULL, + "churn_mrr" double precision DEFAULT 0 NOT NULL, + "churn_mrr_solo" double precision DEFAULT 0 NOT NULL, + "churn_mrr_maker" double precision DEFAULT 0 NOT NULL, + "churn_mrr_team" double precision DEFAULT 0 NOT NULL, + "churn_mrr_enterprise" double precision DEFAULT 0 NOT NULL, + "contraction_mrr_solo" double precision DEFAULT 0 NOT NULL, + "contraction_mrr_maker" double precision DEFAULT 0 NOT NULL, + "contraction_mrr_team" double precision DEFAULT 0 NOT NULL, + "contraction_mrr_enterprise" double precision DEFAULT 0 NOT NULL +); + + +ALTER TABLE "public"."daily_revenue_metrics" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."daily_revenue_metrics" IS 'Daily MRR movement rollup per customer, fed by Stripe webhook events for admin retention analytics.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."opening_mrr" IS 'Customer monthly recurring revenue at the start of the UTC day, before any tracked movement.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."new_business_mrr" IS 'New monthly recurring revenue created on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."expansion_mrr" IS 'Expansion monthly recurring revenue added on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."contraction_mrr" IS 'Monthly recurring revenue lost to downgrades on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."churn_mrr" IS 'Monthly recurring revenue fully lost to churn on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."churn_mrr_solo" IS 'Solo plan MRR fully lost to churn on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."churn_mrr_maker" IS 'Maker plan MRR fully lost to churn on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."churn_mrr_team" IS 'Team plan MRR fully lost to churn on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."churn_mrr_enterprise" IS 'Enterprise plan MRR fully lost to churn on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."contraction_mrr_solo" IS 'Solo plan MRR lost to downgrades on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."contraction_mrr_maker" IS 'Maker plan MRR lost to downgrades on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."contraction_mrr_team" IS 'Team plan MRR lost to downgrades on the day.'; + + + +COMMENT ON COLUMN "public"."daily_revenue_metrics"."contraction_mrr_enterprise" IS 'Enterprise plan MRR lost to downgrades on the day.'; + + + +CREATE TABLE IF NOT EXISTS "public"."daily_storage" ( + "id" integer NOT NULL, + "app_id" character varying(255) NOT NULL, + "date" "date" NOT NULL, + "storage" bigint NOT NULL +) +WITH ("autovacuum_vacuum_scale_factor"='0.05', "autovacuum_analyze_scale_factor"='0.02'); + + +ALTER TABLE "public"."daily_storage" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."daily_storage_hourly" ( + "app_id" character varying(255) NOT NULL, + "owner_org" "uuid" NOT NULL, + "date" "date" NOT NULL, + "storage_byte_hours" double precision DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."daily_storage_hourly" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."daily_storage_hourly" IS 'Shadow daily storage-hour usage, recorded as byte-hours. This is intentionally not used for billing until storage-hour billing is explicitly enabled.'; + + + +COMMENT ON COLUMN "public"."daily_storage_hourly"."storage_byte_hours" IS 'Byte-hour contribution for this UTC day.'; + + + +CREATE SEQUENCE IF NOT EXISTS "public"."daily_storage_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."daily_storage_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."daily_storage_id_seq" OWNED BY "public"."daily_storage"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."daily_version" ( + "date" "date" NOT NULL, + "app_id" character varying(255) NOT NULL, + "version_id" bigint, + "get" bigint, + "fail" bigint, + "install" bigint, + "uninstall" bigint, + "version_name" character varying(255) NOT NULL +) +WITH ("autovacuum_vacuum_scale_factor"='0.05', "autovacuum_analyze_scale_factor"='0.02'); + + +ALTER TABLE "public"."daily_version" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."deleted_account" ( + "created_at" timestamp with time zone DEFAULT "now"(), + "email" character varying DEFAULT ''::character varying NOT NULL, + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL +); + + +ALTER TABLE "public"."deleted_account" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."deleted_apps" ( + "id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"(), + "app_id" character varying NOT NULL, + "owner_org" "uuid" NOT NULL, + "deleted_at" timestamp with time zone DEFAULT "now"() +); + + +ALTER TABLE "public"."deleted_apps" OWNER TO "postgres"; + + +ALTER TABLE "public"."deleted_apps" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."deleted_apps_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."deploy_history" ( + "id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "channel_id" bigint NOT NULL, + "app_id" character varying NOT NULL, + "version_id" bigint NOT NULL, + "deployed_at" timestamp with time zone DEFAULT "now"(), + "created_by" "uuid" NOT NULL, + "owner_org" "uuid" NOT NULL, + "install_stats_email_sent_at" timestamp with time zone +); + + +ALTER TABLE "public"."deploy_history" OWNER TO "postgres"; + + +ALTER TABLE "public"."deploy_history" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."deploy_history_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."device_usage" ( + "id" integer NOT NULL, + "device_id" character varying(255) NOT NULL, + "app_id" character varying(255) NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "org_id" character varying(255) NOT NULL, + "version_build" character varying(70), + "platform" character varying(32) +); + + +ALTER TABLE "public"."device_usage" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."device_usage_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."device_usage_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."device_usage_id_seq" OWNED BY "public"."device_usage"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."devices" ( + "updated_at" timestamp with time zone NOT NULL, + "device_id" "text" NOT NULL, + "version" bigint, + "app_id" character varying(50) NOT NULL, + "platform" "public"."platform_os" NOT NULL, + "plugin_version" character varying(20) DEFAULT '2.3.3'::"text" NOT NULL, + "os_version" character varying(20), + "version_build" character varying(70) DEFAULT 'builtin'::"text", + "custom_id" character varying(36) DEFAULT ''::"text" NOT NULL, + "is_prod" boolean DEFAULT true, + "is_emulator" boolean DEFAULT false, + "id" bigint NOT NULL, + "version_name" "text" DEFAULT 'unknown'::"text" NOT NULL, + "default_channel" character varying(255), + "key_id" character varying(20) +); + + +ALTER TABLE "public"."devices" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."devices"."default_channel" IS 'The default channel name that the device is configured to request updates from'; + + + +COMMENT ON COLUMN "public"."devices"."key_id" IS 'First 20 characters of the base64-encoded public key (identifies which key is in use)'; + + + +ALTER TABLE "public"."devices" ALTER COLUMN "id" ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME "public"."devices_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."global_stats" ( + "created_at" timestamp with time zone DEFAULT "now"(), + "date_id" character varying NOT NULL, + "apps" bigint NOT NULL, + "updates" bigint NOT NULL, + "stars" bigint NOT NULL, + "users" bigint DEFAULT '0'::bigint, + "paying" bigint DEFAULT '0'::bigint, + "trial" bigint DEFAULT '0'::bigint, + "need_upgrade" bigint DEFAULT '0'::bigint, + "not_paying" bigint DEFAULT '0'::bigint, + "onboarded" bigint DEFAULT '0'::bigint, + "apps_active" integer DEFAULT 0, + "users_active" integer DEFAULT 0, + "paying_monthly" integer DEFAULT 0, + "paying_yearly" integer DEFAULT 0, + "updates_last_month" bigint DEFAULT '0'::bigint, + "updates_external" bigint DEFAULT '0'::bigint, + "success_rate" double precision, + "plan_solo" bigint DEFAULT 0, + "plan_maker" bigint DEFAULT 0, + "plan_team" bigint DEFAULT 0, + "devices_last_month" bigint DEFAULT 0, + "registers_today" bigint DEFAULT 0 NOT NULL, + "bundle_storage_gb" double precision DEFAULT 0 NOT NULL, + "mrr" double precision DEFAULT 0 NOT NULL, + "total_revenue" double precision DEFAULT 0 NOT NULL, + "revenue_solo" double precision DEFAULT 0 NOT NULL, + "revenue_maker" double precision DEFAULT 0 NOT NULL, + "revenue_team" double precision DEFAULT 0 NOT NULL, + "revenue_enterprise" double precision DEFAULT 0 NOT NULL, + "plan_solo_monthly" integer DEFAULT 0 NOT NULL, + "plan_solo_yearly" integer DEFAULT 0 NOT NULL, + "plan_maker_monthly" integer DEFAULT 0 NOT NULL, + "plan_maker_yearly" integer DEFAULT 0 NOT NULL, + "plan_team_monthly" integer DEFAULT 0 NOT NULL, + "plan_team_yearly" integer DEFAULT 0 NOT NULL, + "plan_enterprise" integer DEFAULT 0 NOT NULL, + "plan_enterprise_monthly" integer DEFAULT 0 NOT NULL, + "plan_enterprise_yearly" integer DEFAULT 0 NOT NULL, + "new_paying_orgs" integer DEFAULT 0 NOT NULL, + "canceled_orgs" integer DEFAULT 0 NOT NULL, + "credits_bought" bigint DEFAULT 0 NOT NULL, + "credits_consumed" bigint DEFAULT 0 NOT NULL, + "devices_last_month_ios" bigint DEFAULT 0, + "devices_last_month_android" bigint DEFAULT 0, + "plugin_version_breakdown" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, + "plugin_major_breakdown" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, + "builds_total" bigint DEFAULT 0, + "builds_ios" bigint DEFAULT 0, + "builds_android" bigint DEFAULT 0, + "builds_last_month" bigint DEFAULT 0, + "builds_last_month_ios" bigint DEFAULT 0, + "builds_last_month_android" bigint DEFAULT 0, + "upgraded_orgs" integer DEFAULT 0 NOT NULL, + "builds_success_total" bigint DEFAULT 0, + "builds_success_ios" bigint DEFAULT 0, + "builds_success_android" bigint DEFAULT 0, + "demo_apps_created" integer DEFAULT 0 NOT NULL, + "org_conversion_rate" double precision DEFAULT 0 NOT NULL, + "build_total_seconds_day_ios" bigint DEFAULT 0 NOT NULL, + "build_total_seconds_day_android" bigint DEFAULT 0 NOT NULL, + "build_count_day_ios" integer DEFAULT 0 NOT NULL, + "build_count_day_android" integer DEFAULT 0 NOT NULL, + "build_avg_seconds_day_ios" double precision DEFAULT 0 NOT NULL, + "build_avg_seconds_day_android" double precision DEFAULT 0 NOT NULL, + "nrr" double precision DEFAULT 100 NOT NULL, + "churn_revenue" double precision DEFAULT 0 NOT NULL, + "churn_revenue_solo" double precision DEFAULT 0 NOT NULL, + "churn_revenue_maker" double precision DEFAULT 0 NOT NULL, + "churn_revenue_team" double precision DEFAULT 0 NOT NULL, + "churn_revenue_enterprise" double precision DEFAULT 0 NOT NULL, + "plugin_version_ladder" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, + "builder_active_paying_clients_60d" integer DEFAULT 0 NOT NULL, + "live_updates_active_paying_clients_60d" integer DEFAULT 0 NOT NULL, + "plan_solo_conversion_rate" double precision DEFAULT 0 NOT NULL, + "plan_maker_conversion_rate" double precision DEFAULT 0 NOT NULL, + "plan_team_conversion_rate" double precision DEFAULT 0 NOT NULL, + "plan_enterprise_conversion_rate" double precision DEFAULT 0 NOT NULL, + "plan_total_conversion_rate" double precision DEFAULT 0 NOT NULL, + "average_ltv" double precision DEFAULT 0 NOT NULL, + "shortest_ltv" double precision DEFAULT 0 NOT NULL, + "longest_ltv" double precision DEFAULT 0 NOT NULL +); + + +ALTER TABLE "public"."global_stats" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."global_stats"."mrr" IS 'Total Monthly Recurring Revenue in dollars'; + + + +COMMENT ON COLUMN "public"."global_stats"."total_revenue" IS 'Total Annual Recurring Revenue (ARR) in dollars'; + + + +COMMENT ON COLUMN "public"."global_stats"."revenue_solo" IS 'Solo plan ARR in dollars'; + + + +COMMENT ON COLUMN "public"."global_stats"."revenue_maker" IS 'Maker plan ARR in dollars'; + + + +COMMENT ON COLUMN "public"."global_stats"."revenue_team" IS 'Team plan ARR in dollars'; + + + +COMMENT ON COLUMN "public"."global_stats"."revenue_enterprise" IS 'Enterprise plan ARR in dollars'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_solo_monthly" IS 'Number of Solo plan monthly subscriptions'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_solo_yearly" IS 'Number of Solo plan yearly subscriptions'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_maker_monthly" IS 'Number of Maker plan monthly subscriptions'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_maker_yearly" IS 'Number of Maker plan yearly subscriptions'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_team_monthly" IS 'Number of Team plan monthly subscriptions'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_team_yearly" IS 'Number of Team plan yearly subscriptions'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_enterprise_monthly" IS 'Number of Enterprise plan monthly subscriptions'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_enterprise_yearly" IS 'Number of Enterprise plan yearly subscriptions'; + + + +COMMENT ON COLUMN "public"."global_stats"."new_paying_orgs" IS 'Number of new paying organizations today'; + + + +COMMENT ON COLUMN "public"."global_stats"."canceled_orgs" IS 'Number of canceled subscriptions today'; + + + +COMMENT ON COLUMN "public"."global_stats"."credits_bought" IS 'Total credits purchased today'; + + + +COMMENT ON COLUMN "public"."global_stats"."credits_consumed" IS 'Total credits consumed today'; + + + +COMMENT ON COLUMN "public"."global_stats"."plugin_version_breakdown" IS 'JSON breakdown of plugin version percentages. Format: {"version": percentage, ...}'; + + + +COMMENT ON COLUMN "public"."global_stats"."plugin_major_breakdown" IS 'JSON breakdown of plugin major version percentages. Format: {"major_version": percentage, ...}'; + + + +COMMENT ON COLUMN "public"."global_stats"."builds_total" IS 'Total number of native builds recorded (all time)'; + + + +COMMENT ON COLUMN "public"."global_stats"."builds_ios" IS 'Total number of iOS native builds recorded (all time)'; + + + +COMMENT ON COLUMN "public"."global_stats"."builds_android" IS 'Total number of Android native builds recorded (all time)'; + + + +COMMENT ON COLUMN "public"."global_stats"."builds_last_month" IS 'Number of native builds in the last 30 days'; + + + +COMMENT ON COLUMN "public"."global_stats"."builds_last_month_ios" IS 'Number of iOS native builds in the last 30 days'; + + + +COMMENT ON COLUMN "public"."global_stats"."builds_last_month_android" IS 'Number of Android native builds in the last 30 days'; + + + +COMMENT ON COLUMN "public"."global_stats"."upgraded_orgs" IS 'Number of organizations that upgraded plans in the last 24 hours'; + + + +COMMENT ON COLUMN "public"."global_stats"."builds_success_total" IS 'Total number of successful native builds recorded (all time)'; + + + +COMMENT ON COLUMN "public"."global_stats"."builds_success_ios" IS 'Total number of successful iOS native builds recorded (all time)'; + + + +COMMENT ON COLUMN "public"."global_stats"."builds_success_android" IS 'Total number of successful Android native builds recorded (all time)'; + + + +COMMENT ON COLUMN "public"."global_stats"."demo_apps_created" IS 'Number of demo apps created in the last 24 hours'; + + + +COMMENT ON COLUMN "public"."global_stats"."org_conversion_rate" IS 'Percentage of organizations that are paying (paying / orgs * 100)'; + + + +COMMENT ON COLUMN "public"."global_stats"."build_total_seconds_day_ios" IS 'Total iOS build seconds recorded for the UTC day'; + + + +COMMENT ON COLUMN "public"."global_stats"."build_total_seconds_day_android" IS 'Total Android build seconds recorded for the UTC day'; + + + +COMMENT ON COLUMN "public"."global_stats"."build_count_day_ios" IS 'Total iOS builds recorded for the UTC day'; + + + +COMMENT ON COLUMN "public"."global_stats"."build_count_day_android" IS 'Total Android builds recorded for the UTC day'; + + + +COMMENT ON COLUMN "public"."global_stats"."build_avg_seconds_day_ios" IS 'Average iOS build duration in seconds for the UTC day'; + + + +COMMENT ON COLUMN "public"."global_stats"."build_avg_seconds_day_android" IS 'Average Android build duration in seconds for the UTC day'; + + + +COMMENT ON COLUMN "public"."global_stats"."nrr" IS 'Net Revenue Retention percentage for the day based on prior-day MRR, excluding new business.'; + + + +COMMENT ON COLUMN "public"."global_stats"."churn_revenue" IS 'Total monthly recurring revenue lost to churn and downgrades on the day in dollars.'; + + + +COMMENT ON COLUMN "public"."global_stats"."churn_revenue_solo" IS 'Solo plan MRR lost to churn and downgrades on the day.'; + + + +COMMENT ON COLUMN "public"."global_stats"."churn_revenue_maker" IS 'Maker plan MRR lost to churn and downgrades on the day.'; + + + +COMMENT ON COLUMN "public"."global_stats"."churn_revenue_team" IS 'Team plan MRR lost to churn and downgrades on the day.'; + + + +COMMENT ON COLUMN "public"."global_stats"."churn_revenue_enterprise" IS 'Enterprise plan MRR lost to churn and downgrades on the day.'; + + + +COMMENT ON COLUMN "public"."global_stats"."builder_active_paying_clients_60d" IS 'Number of paying clients with Capgo Builder activity in the trailing 60 days for the UTC day.'; + + + +COMMENT ON COLUMN "public"."global_stats"."live_updates_active_paying_clients_60d" IS 'Number of paying clients with Live Updates activity in the trailing 60 days for the UTC day.'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_solo_conversion_rate" IS 'Percentage of organizations converted to the Solo plan (plan_solo / orgs * 100)'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_maker_conversion_rate" IS 'Percentage of organizations converted to the Maker plan (plan_maker / orgs * 100)'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_team_conversion_rate" IS 'Percentage of organizations converted to the Team plan (plan_team / orgs * 100)'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_enterprise_conversion_rate" IS 'Percentage of organizations converted to the Enterprise plan (plan_enterprise / orgs * 100)'; + + + +COMMENT ON COLUMN "public"."global_stats"."plan_total_conversion_rate" IS 'Percentage of organizations converted to any paid plan ((plan_solo + plan_maker + plan_team + plan_enterprise) / orgs * 100)'; + + + +COMMENT ON COLUMN "public"."global_stats"."average_ltv" IS 'Average estimated customer LTV in dollars for the daily snapshot.'; + + + +COMMENT ON COLUMN "public"."global_stats"."shortest_ltv" IS 'Lowest estimated customer LTV in dollars for the daily snapshot.'; + + + +COMMENT ON COLUMN "public"."global_stats"."longest_ltv" IS 'Highest estimated customer LTV in dollars for the daily snapshot.'; + + + +CREATE TABLE IF NOT EXISTS "public"."group_members" ( + "group_id" "uuid" NOT NULL, + "user_id" "uuid" NOT NULL, + "added_by" "uuid", + "added_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."group_members" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."group_members" IS 'Membership join table linking users to groups.'; + + + +CREATE TABLE IF NOT EXISTS "public"."groups" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "org_id" "uuid" NOT NULL, + "name" "text" NOT NULL, + "description" "text", + "is_system" boolean DEFAULT false NOT NULL, + "created_by" "uuid", + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."groups" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."groups" IS 'Org-scoped groups/teams. Groups are a principal for role bindings.'; + + + +CREATE TABLE IF NOT EXISTS "public"."manifest" ( + "id" integer NOT NULL, + "app_version_id" bigint NOT NULL, + "file_name" character varying NOT NULL, + "s3_path" character varying NOT NULL, + "file_hash" character varying NOT NULL, + "file_size" bigint DEFAULT 0 +) +WITH ("autovacuum_vacuum_scale_factor"='0.05', "autovacuum_analyze_scale_factor"='0.02'); + + +ALTER TABLE "public"."manifest" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."manifest_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."manifest_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."manifest_id_seq" OWNED BY "public"."manifest"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."notifications" ( + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "last_send_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "total_send" bigint DEFAULT '1'::bigint NOT NULL, + "owner_org" "uuid" NOT NULL, + "event" character varying(255) NOT NULL, + "uniq_id" character varying(255) NOT NULL +); + + +ALTER TABLE "public"."notifications" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."onboarding_demo_data" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "app_id" character varying NOT NULL, + "owner_org" "uuid" NOT NULL, + "relation_name" "text" NOT NULL, + "row_key" "text" NOT NULL, + "seed_id" "uuid" NOT NULL, + CONSTRAINT "onboarding_demo_data_relation_name_check" CHECK (("relation_name" = ANY (ARRAY['app_versions'::"text", 'app_versions_meta'::"text", 'manifest'::"text", 'channels'::"text", 'channel_devices'::"text", 'deploy_history'::"text", 'devices'::"text", 'build_requests'::"text"]))) +); + + +ALTER TABLE "public"."onboarding_demo_data" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."onboarding_demo_data" IS 'Tracks rows created by onboarding demo seeding so demo resets can delete only demo-owned data.'; + + + +COMMENT ON COLUMN "public"."onboarding_demo_data"."row_key" IS 'Primary-row identifier as text. Only exact rows created or confidently fingerprinted by onboarding demo seeding are tracked.'; + + + +CREATE TABLE IF NOT EXISTS "public"."org_users" ( + "id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "user_id" "uuid" NOT NULL, + "org_id" "uuid" NOT NULL, + "app_id" character varying, + "channel_id" bigint, + "user_right" "public"."user_min_right", + "rbac_role_name" "text" +); + + +ALTER TABLE "public"."org_users" OWNER TO "postgres"; + + +ALTER TABLE "public"."org_users" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."org_users_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."orgs" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "created_by" "uuid" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "logo" "text", + "name" "text" NOT NULL, + "management_email" "text" NOT NULL, + "customer_id" character varying, + "stats_updated_at" timestamp without time zone, + "last_stats_updated_at" timestamp without time zone, + "use_new_rbac" boolean DEFAULT true NOT NULL, + "enforcing_2fa" boolean DEFAULT false NOT NULL, + "email_preferences" "jsonb" DEFAULT '{"onboarding": true, "usage_limit": true, "credit_usage": true, "device_error": true, "weekly_stats": true, "monthly_stats": true, "bundle_created": true, "bundle_deployed": true, "deploy_stats_24h": true, "billing_period_stats": true, "channel_self_rejected": true}'::"jsonb" NOT NULL, + "enforce_hashed_api_keys" boolean DEFAULT false NOT NULL, + "require_apikey_expiration" boolean DEFAULT false NOT NULL, + "max_apikey_expiration_days" integer, + "password_policy_config" "jsonb", + "enforce_encrypted_bundles" boolean DEFAULT false NOT NULL, + "required_encryption_key" character varying(21) DEFAULT NULL::character varying, + "has_usage_credits" boolean DEFAULT false NOT NULL, + "website" "text", + "stats_refresh_requested_at" timestamp without time zone, + "onboarding" "jsonb" DEFAULT '{"intent": "unknown"}'::"jsonb" NOT NULL, + CONSTRAINT "orgs_max_apikey_expiration_days_valid" CHECK ((("max_apikey_expiration_days" IS NULL) OR (("max_apikey_expiration_days" >= 1) AND ("max_apikey_expiration_days" <= 365)))), + CONSTRAINT "orgs_onboarding_valid" CHECK ((("jsonb_typeof"("onboarding") = 'object'::"text") AND ((NOT ("onboarding" ? 'intent'::"text")) OR (("onboarding" ->> 'intent'::"text") = ANY (ARRAY['unknown'::"text", 'ota'::"text", 'builder'::"text", 'both'::"text", 'exploring'::"text"]))))), + CONSTRAINT "orgs_password_policy_config_min_length_check" CHECK ((("password_policy_config" IS NULL) OR (("jsonb_typeof"("password_policy_config") = 'object'::"text") AND ((NOT ("password_policy_config" ? 'min_length'::"text")) OR (("jsonb_typeof"(("password_policy_config" -> 'min_length'::"text")) = 'number'::"text") AND ((("password_policy_config" ->> 'min_length'::"text"))::numeric = "trunc"((("password_policy_config" ->> 'min_length'::"text"))::numeric)) AND (((("password_policy_config" ->> 'min_length'::"text"))::numeric >= (6)::numeric) AND ((("password_policy_config" ->> 'min_length'::"text"))::numeric <= (72)::numeric))))))), + CONSTRAINT "orgs_required_encryption_key_valid" CHECK ((("required_encryption_key" IS NULL) OR ("length"(("required_encryption_key")::"text") = ANY (ARRAY[20, 21])))) +); + + +ALTER TABLE "public"."orgs" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."orgs"."use_new_rbac" IS 'Feature flag: when true, org uses RBAC instead of legacy org_users rights.'; + + + +COMMENT ON COLUMN "public"."orgs"."enforcing_2fa" IS 'When true, all members of this organization must have 2FA enabled to access the organization'; + + + +COMMENT ON COLUMN "public"."orgs"."email_preferences" IS 'JSONB object containing email notification preferences for the organization. When enabled, emails are also sent to the management_email if it differs from admin user emails. Keys: usage_limit, credit_usage, onboarding, weekly_stats, monthly_stats, billing_period_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected. All default to true.'; + + + +COMMENT ON COLUMN "public"."orgs"."enforce_hashed_api_keys" IS 'When true, only hashed API keys can access this organization. Plain-text keys will be rejected.'; + + + +COMMENT ON COLUMN "public"."orgs"."require_apikey_expiration" IS 'When true, API keys used with this organization must have an expiration date set.'; + + + +COMMENT ON COLUMN "public"."orgs"."max_apikey_expiration_days" IS 'Maximum number of days an API key can be valid when creating/updating keys limited to this org. NULL means no maximum.'; + + + +COMMENT ON COLUMN "public"."orgs"."password_policy_config" IS 'JSON configuration for password policy: {enabled: boolean, min_length: number, require_uppercase: boolean, require_number: boolean, require_special: boolean}'; + + + +COMMENT ON COLUMN "public"."orgs"."enforce_encrypted_bundles" IS 'When true, all bundles uploaded to this organization must be encrypted (have session_key set). Unencrypted bundles will be rejected.'; + + + +COMMENT ON COLUMN "public"."orgs"."required_encryption_key" IS 'Optional: First 21 characters of the base64-encoded public key. When set, only bundles encrypted with this specific key (matching key_id) will be accepted.'; + + + +COMMENT ON COLUMN "public"."orgs"."has_usage_credits" IS 'True only with positive, unexpired usage credits.'; + + + +COMMENT ON COLUMN "public"."orgs"."onboarding" IS 'Onboarding answers (extensible JSONB). Currently: {"intent": unknown|ota|builder|both|exploring}. Used for segmentation and to tailor the org experience.'; + + + +CREATE TABLE IF NOT EXISTS "public"."permissions" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "key" "text" NOT NULL, + "scope_type" "text" NOT NULL, + "bundle_id" bigint, + "description" "text", + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + CONSTRAINT "permissions_scope_type_check" CHECK (("scope_type" = ANY (ARRAY["public"."rbac_scope_platform"(), "public"."rbac_scope_org"(), "public"."rbac_scope_app"(), "public"."rbac_scope_bundle"(), "public"."rbac_scope_channel"()]))), + CONSTRAINT "permissions_scope_type_no_platform" CHECK (("scope_type" <> "public"."rbac_scope_platform"())) +); + + +ALTER TABLE "public"."permissions" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."permissions" IS 'Atomic permission keys; used by role_permissions. Only priority permissions are seeded in Phase 1.'; + + + +CREATE TABLE IF NOT EXISTS "public"."plans" ( + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "name" character varying DEFAULT ''::character varying NOT NULL, + "description" character varying DEFAULT ''::character varying NOT NULL, + "price_m" bigint DEFAULT '0'::bigint NOT NULL, + "price_y" bigint DEFAULT '0'::bigint NOT NULL, + "stripe_id" character varying DEFAULT ''::character varying NOT NULL, + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "price_m_id" character varying NOT NULL, + "price_y_id" character varying NOT NULL, + "storage" bigint NOT NULL, + "bandwidth" bigint NOT NULL, + "mau" bigint DEFAULT '0'::bigint NOT NULL, + "market_desc" character varying DEFAULT ''::character varying, + "build_time_unit" bigint DEFAULT 0 NOT NULL, + "credit_id" "text" NOT NULL, + "native_build_concurrency" integer DEFAULT 2 NOT NULL, + CONSTRAINT "plans_native_build_concurrency_positive" CHECK (("native_build_concurrency" > 0)) +); + + +ALTER TABLE "public"."plans" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."plans"."build_time_unit" IS 'Maximum build time in seconds per billing cycle'; + + + +COMMENT ON COLUMN "public"."plans"."credit_id" IS 'Stripe product identifier used for purchasing additional credits.'; + + + +COMMENT ON COLUMN "public"."plans"."native_build_concurrency" IS 'Maximum number of active native builds allowed concurrently for this plan.'; + + + +CREATE TABLE IF NOT EXISTS "public"."processed_stripe_events" ( + "event_id" "text" NOT NULL, + "customer_id" character varying NOT NULL, + "date_id" character varying NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."processed_stripe_events" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."processed_stripe_events" IS 'Idempotency ledger for Stripe webhook events that have already updated retention revenue metrics.'; + + + +CREATE TABLE IF NOT EXISTS "public"."role_bindings" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "principal_type" "text" NOT NULL, + "principal_id" "uuid" NOT NULL, + "role_id" "uuid" NOT NULL, + "scope_type" "text" NOT NULL, + "org_id" "uuid", + "app_id" "uuid", + "bundle_id" bigint, + "channel_id" "uuid", + "granted_by" "uuid" NOT NULL, + "granted_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "expires_at" timestamp with time zone, + "reason" "text", + "is_direct" boolean DEFAULT true NOT NULL, + CONSTRAINT "role_bindings_check" CHECK (((("scope_type" = "public"."rbac_scope_platform"()) AND ("org_id" IS NULL) AND ("app_id" IS NULL) AND ("bundle_id" IS NULL) AND ("channel_id" IS NULL)) OR (("scope_type" = "public"."rbac_scope_org"()) AND ("org_id" IS NOT NULL) AND ("app_id" IS NULL) AND ("bundle_id" IS NULL) AND ("channel_id" IS NULL)) OR (("scope_type" = "public"."rbac_scope_app"()) AND ("org_id" IS NOT NULL) AND ("app_id" IS NOT NULL) AND ("bundle_id" IS NULL) AND ("channel_id" IS NULL)) OR (("scope_type" = "public"."rbac_scope_bundle"()) AND ("org_id" IS NOT NULL) AND ("app_id" IS NOT NULL) AND ("bundle_id" IS NOT NULL) AND ("channel_id" IS NULL)) OR (("scope_type" = "public"."rbac_scope_channel"()) AND ("org_id" IS NOT NULL) AND ("app_id" IS NOT NULL) AND ("bundle_id" IS NULL) AND ("channel_id" IS NOT NULL)))), + CONSTRAINT "role_bindings_principal_type_check" CHECK (("principal_type" = ANY (ARRAY["public"."rbac_principal_user"(), "public"."rbac_principal_group"(), "public"."rbac_principal_apikey"()]))), + CONSTRAINT "role_bindings_scope_type_check" CHECK (("scope_type" = ANY (ARRAY["public"."rbac_scope_platform"(), "public"."rbac_scope_org"(), "public"."rbac_scope_app"(), "public"."rbac_scope_bundle"(), "public"."rbac_scope_channel"()]))), + CONSTRAINT "role_bindings_scope_type_no_platform" CHECK (("scope_type" <> "public"."rbac_scope_platform"())) +); + + +ALTER TABLE "public"."role_bindings" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."role_bindings" IS 'Assign roles to principals at a scope. SSD: only one role per scope_type per scope/principal.'; + + + +CREATE TABLE IF NOT EXISTS "public"."role_hierarchy" ( + "parent_role_id" "uuid" NOT NULL, + "child_role_id" "uuid" NOT NULL, + CONSTRAINT "role_hierarchy_check" CHECK (("parent_role_id" IS DISTINCT FROM "child_role_id")) +); + + +ALTER TABLE "public"."role_hierarchy" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."role_hierarchy" IS 'Explicit role inheritance. Parent inherits all permissions of its children (acyclic by convention).'; + + + +CREATE TABLE IF NOT EXISTS "public"."role_permissions" ( + "role_id" "uuid" NOT NULL, + "permission_id" "uuid" NOT NULL +); + + +ALTER TABLE "public"."role_permissions" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."role_permissions" IS 'Join table assigning permission keys to roles.'; + + + +CREATE TABLE IF NOT EXISTS "public"."roles" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "name" "text" NOT NULL, + "scope_type" "text" NOT NULL, + "description" "text", + "priority_rank" integer DEFAULT 0 NOT NULL, + "is_assignable" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "created_by" "uuid", + CONSTRAINT "roles_scope_type_check" CHECK (("scope_type" = ANY (ARRAY["public"."rbac_scope_platform"(), "public"."rbac_scope_org"(), "public"."rbac_scope_app"(), "public"."rbac_scope_bundle"(), "public"."rbac_scope_channel"()]))), + CONSTRAINT "roles_scope_type_no_platform" CHECK (("scope_type" <> "public"."rbac_scope_platform"())) +); + + +ALTER TABLE "public"."roles" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."roles" IS 'Canonical RBAC roles. Scope_type indicates the native scope the role is defined for.'; + + + +CREATE TABLE IF NOT EXISTS "public"."sso_providers" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "org_id" "uuid" NOT NULL, + "domain" "text" NOT NULL, + "provider_id" "text", + "status" "text" DEFAULT 'pending_verification'::"text" NOT NULL, + "enforce_sso" boolean DEFAULT false NOT NULL, + "dns_verification_token" "text" NOT NULL, + "dns_verified_at" timestamp with time zone, + "metadata_url" "text", + "attribute_mapping" "jsonb" DEFAULT '{}'::"jsonb", + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + CONSTRAINT "sso_providers_domain_lowercase_check" CHECK (("domain" = "lower"("btrim"("domain")))), + CONSTRAINT "sso_providers_status_check" CHECK (("status" = ANY (ARRAY['pending_verification'::"text", 'verified'::"text", 'active'::"text", 'disabled'::"text"]))) +); + + +ALTER TABLE "public"."sso_providers" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."stats" ( + "created_at" timestamp with time zone NOT NULL, + "action" "public"."stats_action" NOT NULL, + "device_id" character varying(36) NOT NULL, + "app_id" character varying(50) NOT NULL, + "id" bigint NOT NULL, + "version_name" "text" DEFAULT 'unknown'::"text" NOT NULL, + "metadata" "jsonb" +); + + +ALTER TABLE "public"."stats" OWNER TO "postgres"; + + +ALTER TABLE "public"."stats" ALTER COLUMN "id" ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME "public"."stats_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."storage_usage" ( + "id" integer NOT NULL, + "device_id" character varying(255) NOT NULL, + "app_id" character varying(255) NOT NULL, + "file_size" bigint NOT NULL, + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +ALTER TABLE "public"."storage_usage" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."storage_usage_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."storage_usage_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."storage_usage_id_seq" OWNED BY "public"."storage_usage"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."stripe_info" ( + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "subscription_id" character varying, + "customer_id" character varying NOT NULL, + "status" "public"."stripe_status", + "product_id" character varying NOT NULL, + "trial_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "price_id" character varying, + "is_good_plan" boolean DEFAULT true, + "plan_usage" bigint DEFAULT '0'::bigint, + "subscription_anchor_start" timestamp with time zone DEFAULT "now"() NOT NULL, + "subscription_anchor_end" timestamp with time zone DEFAULT "public"."one_month_ahead"() NOT NULL, + "canceled_at" timestamp with time zone, + "mau_exceeded" boolean DEFAULT false, + "storage_exceeded" boolean DEFAULT false, + "bandwidth_exceeded" boolean DEFAULT false, + "id" integer NOT NULL, + "plan_calculated_at" timestamp with time zone, + "build_time_exceeded" boolean DEFAULT false, + "upgraded_at" timestamp with time zone, + "paid_at" timestamp with time zone, + "customer_country" character varying(2), + "last_stripe_event_at" timestamp with time zone +); + + +ALTER TABLE "public"."stripe_info" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."stripe_info"."build_time_exceeded" IS 'Organization exceeded build time limit'; + + + +COMMENT ON COLUMN "public"."stripe_info"."upgraded_at" IS 'Timestamp of last paid plan upgrade for the org'; + + + +COMMENT ON COLUMN "public"."stripe_info"."paid_at" IS 'Timestamp when the org first became a paying customer'; + + + +COMMENT ON COLUMN "public"."stripe_info"."customer_country" IS 'Latest ISO 3166-1 alpha-2 billing country code synced from the Stripe customer profile.'; + + + +COMMENT ON COLUMN "public"."stripe_info"."last_stripe_event_at" IS 'Timestamp of the most recent Stripe event applied to this row, used for webhook ordering checks.'; + + + +CREATE SEQUENCE IF NOT EXISTS "public"."stripe_info_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."stripe_info_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."stripe_info_id_seq" OWNED BY "public"."stripe_info"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."tmp_users" ( + "id" integer NOT NULL, + "email" "text" NOT NULL, + "org_id" "uuid" NOT NULL, + "role" "public"."user_min_right" NOT NULL, + "invite_magic_string" "text" DEFAULT "encode"("extensions"."gen_random_bytes"(128), 'hex'::"text") NOT NULL, + "future_uuid" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "first_name" "text" NOT NULL, + "last_name" "text" NOT NULL, + "cancelled_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "rbac_role_name" "text" +); + + +ALTER TABLE "public"."tmp_users" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."tmp_users_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."tmp_users_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."tmp_users_id_seq" OWNED BY "public"."tmp_users"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."to_delete_accounts" ( + "id" integer NOT NULL, + "account_id" "uuid" NOT NULL, + "removed_data" "jsonb", + "removal_date" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."to_delete_accounts" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."to_delete_accounts_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."to_delete_accounts_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."to_delete_accounts_id_seq" OWNED BY "public"."to_delete_accounts"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."usage_credit_grants" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "org_id" "uuid" NOT NULL, + "credits_total" numeric(18,6) NOT NULL, + "credits_consumed" numeric(18,6) DEFAULT 0 NOT NULL, + "granted_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "expires_at" timestamp with time zone DEFAULT ("now"() + '1 year'::interval) NOT NULL, + "source" "text" DEFAULT 'manual'::"text" NOT NULL, + "source_ref" "jsonb", + "notes" "text", + CONSTRAINT "usage_credit_grants_check" CHECK (("credits_consumed" <= "credits_total")), + CONSTRAINT "usage_credit_grants_credits_consumed_check" CHECK (("credits_consumed" >= (0)::numeric)), + CONSTRAINT "usage_credit_grants_credits_total_check" CHECK (("credits_total" >= (0)::numeric)), + CONSTRAINT "usage_credit_grants_source_check" CHECK (("source" = ANY ('{manual,stripe_top_up}'::"text"[]))) +); + + +ALTER TABLE "public"."usage_credit_grants" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."usage_credit_grants" IS 'Records every block of credits granted to an org, tracking totals, consumption and expiry.'; + + + +CREATE OR REPLACE VIEW "public"."usage_credit_balances" WITH ("security_invoker"='true') AS + SELECT "org_id", + "sum"(GREATEST("credits_total", (0)::numeric)) AS "total_credits", + "sum"(GREATEST( + CASE + WHEN ("expires_at" >= "now"()) THEN ("credits_total" - "credits_consumed") + ELSE (0)::numeric + END, (0)::numeric)) AS "available_credits", + "min"( + CASE + WHEN (("credits_total" - "credits_consumed") > (0)::numeric) THEN "expires_at" + ELSE NULL::timestamp with time zone + END) AS "next_expiration" + FROM "public"."usage_credit_grants" + GROUP BY "org_id"; + + +ALTER VIEW "public"."usage_credit_balances" OWNER TO "postgres"; + + +COMMENT ON VIEW "public"."usage_credit_balances" IS 'Aggregated balance view per org: total credits granted, remaining unexpired credits, and the closest upcoming expiry. Respects RLS policies.'; + + + +CREATE TABLE IF NOT EXISTS "public"."usage_credit_consumptions" ( + "id" bigint NOT NULL, + "grant_id" "uuid" NOT NULL, + "org_id" "uuid" NOT NULL, + "overage_event_id" "uuid", + "metric" "public"."credit_metric_type" NOT NULL, + "credits_used" numeric(18,6) NOT NULL, + "applied_at" timestamp with time zone DEFAULT "now"() NOT NULL, + CONSTRAINT "usage_credit_consumptions_credits_used_check" CHECK (("credits_used" > (0)::numeric)) +); + + +ALTER TABLE "public"."usage_credit_consumptions" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."usage_credit_consumptions" IS 'Detailed allocation records showing which grants covered each overage event and how many credits were used.'; + + + +CREATE SEQUENCE IF NOT EXISTS "public"."usage_credit_consumptions_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."usage_credit_consumptions_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."usage_credit_consumptions_id_seq" OWNED BY "public"."usage_credit_consumptions"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."usage_credit_transactions" ( + "id" bigint NOT NULL, + "org_id" "uuid" NOT NULL, + "grant_id" "uuid", + "transaction_type" "public"."credit_transaction_type" NOT NULL, + "amount" numeric(18,6) NOT NULL, + "balance_after" numeric(18,6), + "occurred_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "description" "text", + "source_ref" "jsonb" +); + + +ALTER TABLE "public"."usage_credit_transactions" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."usage_credit_transactions" IS 'General ledger of credit movements (grants, purchases, deductions, expiries, refunds) with running balances.'; + + + +CREATE TABLE IF NOT EXISTS "public"."usage_overage_events" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "org_id" "uuid" NOT NULL, + "metric" "public"."credit_metric_type" NOT NULL, + "overage_amount" numeric(20,6) NOT NULL, + "credits_estimated" numeric(18,6) NOT NULL, + "credits_debited" numeric(18,6) DEFAULT 0 NOT NULL, + "credit_step_id" bigint, + "billing_cycle_start" "date", + "billing_cycle_end" "date", + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "details" "jsonb", + CONSTRAINT "usage_overage_events_credits_debited_check" CHECK (("credits_debited" >= (0)::numeric)), + CONSTRAINT "usage_overage_events_credits_estimated_check" CHECK (("credits_estimated" >= (0)::numeric)), + CONSTRAINT "usage_overage_events_overage_amount_check" CHECK (("overage_amount" >= (0)::numeric)) +); + + +ALTER TABLE "public"."usage_overage_events" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."usage_overage_events" IS 'Snapshots of detected plan overages, capturing usage, credits applied, and linkage back to pricing tiers.'; + + + +CREATE OR REPLACE VIEW "public"."usage_credit_ledger" WITH ("security_invoker"='true', "security_barrier"='true') AS + WITH "overage_allocations" AS ( + SELECT "e"."id" AS "overage_event_id", + "e"."org_id", + "e"."metric", + "e"."overage_amount", + "e"."credits_estimated", + "e"."credits_debited", + "e"."billing_cycle_start", + "e"."billing_cycle_end", + "e"."created_at", + "e"."details", + COALESCE("sum"("c"."credits_used"), (0)::numeric) AS "credits_applied", + "jsonb_agg"("jsonb_build_object"('grant_id', "c"."grant_id", 'credits_used', "c"."credits_used", 'grant_source', "g"."source", 'grant_expires_at', "g"."expires_at", 'grant_notes', "g"."notes") ORDER BY "g"."expires_at", "g"."granted_at") FILTER (WHERE ("c"."grant_id" IS NOT NULL)) AS "grant_allocations" + FROM (("public"."usage_overage_events" "e" + LEFT JOIN "public"."usage_credit_consumptions" "c" ON (("e"."id" = "c"."overage_event_id"))) + LEFT JOIN "public"."usage_credit_grants" "g" ON (("c"."grant_id" = "g"."id"))) + GROUP BY "e"."id", "e"."org_id", "e"."metric", "e"."overage_amount", "e"."credits_estimated", "e"."credits_debited", "e"."billing_cycle_start", "e"."billing_cycle_end", "e"."created_at", "e"."details" + ), "aggregated_deductions" AS ( + SELECT "a"."org_id", + 'deduction'::"public"."credit_transaction_type" AS "transaction_type", + "a"."overage_event_id", + "a"."metric", + "a"."overage_amount", + "a"."billing_cycle_start", + "a"."billing_cycle_end", + "a"."grant_allocations", + "a"."details", + "min"("t"."id") AS "id", + "sum"("t"."amount") AS "amount", + "min"("t"."balance_after") AS "balance_after", + "max"("t"."occurred_at") AS "occurred_at", + "min"("t"."description") AS "description_raw", + COALESCE(NULLIF(("a"."details" ->> 'note'::"text"), ''::"text"), NULLIF(("a"."details" ->> 'description'::"text"), ''::"text"), "min"("t"."description"), "format"('Overage %s'::"text", ("a"."metric")::"text")) AS "description", + "jsonb_build_object"('overage_event_id', "a"."overage_event_id", 'metric', ("a"."metric")::"text", 'overage_amount', "a"."overage_amount", 'grant_allocations', "a"."grant_allocations") AS "source_ref" + FROM ("public"."usage_credit_transactions" "t" + JOIN "overage_allocations" "a" ON (((("t"."source_ref" ->> 'overage_event_id'::"text"))::"uuid" = "a"."overage_event_id"))) + WHERE (("t"."transaction_type" = 'deduction'::"public"."credit_transaction_type") AND ("t"."source_ref" ? 'overage_event_id'::"text")) + GROUP BY "a"."overage_event_id", "a"."metric", "a"."overage_amount", "a"."billing_cycle_start", "a"."billing_cycle_end", "a"."grant_allocations", "a"."details", "a"."org_id" + ), "other_transactions" AS ( + SELECT "t"."id", + "t"."org_id", + "t"."transaction_type", + "t"."amount", + "t"."balance_after", + "t"."occurred_at", + "t"."description", + "t"."source_ref", + NULL::"uuid" AS "overage_event_id", + NULL::"public"."credit_metric_type" AS "metric", + NULL::numeric AS "overage_amount", + NULL::"date" AS "billing_cycle_start", + NULL::"date" AS "billing_cycle_end", + NULL::"jsonb" AS "grant_allocations" + FROM "public"."usage_credit_transactions" "t" + WHERE (("t"."transaction_type" <> 'deduction'::"public"."credit_transaction_type") OR ("t"."source_ref" IS NULL) OR (NOT ("t"."source_ref" ? 'overage_event_id'::"text"))) + ) + SELECT "aggregated_deductions"."id", + "aggregated_deductions"."org_id", + "aggregated_deductions"."transaction_type", + "aggregated_deductions"."amount", + "aggregated_deductions"."balance_after", + "aggregated_deductions"."occurred_at", + "aggregated_deductions"."description", + "aggregated_deductions"."source_ref", + "aggregated_deductions"."overage_event_id", + "aggregated_deductions"."metric", + "aggregated_deductions"."overage_amount", + "aggregated_deductions"."billing_cycle_start", + "aggregated_deductions"."billing_cycle_end", + "aggregated_deductions"."grant_allocations", + NULL::"jsonb" AS "details" + FROM "aggregated_deductions" +UNION ALL + SELECT "other_transactions"."id", + "other_transactions"."org_id", + "other_transactions"."transaction_type", + "other_transactions"."amount", + "other_transactions"."balance_after", + "other_transactions"."occurred_at", + "other_transactions"."description", + "other_transactions"."source_ref", + "other_transactions"."overage_event_id", + "other_transactions"."metric", + "other_transactions"."overage_amount", + "other_transactions"."billing_cycle_start", + "other_transactions"."billing_cycle_end", + "other_transactions"."grant_allocations", + NULL::"jsonb" AS "details" + FROM "other_transactions"; + + +ALTER VIEW "public"."usage_credit_ledger" OWNER TO "postgres"; + + +CREATE SEQUENCE IF NOT EXISTS "public"."usage_credit_transactions_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE "public"."usage_credit_transactions_id_seq" OWNER TO "postgres"; + + +ALTER SEQUENCE "public"."usage_credit_transactions_id_seq" OWNED BY "public"."usage_credit_transactions"."id"; + + + +CREATE TABLE IF NOT EXISTS "public"."user_password_compliance" ( + "id" bigint NOT NULL, + "user_id" "uuid" NOT NULL, + "org_id" "uuid" NOT NULL, + "validated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "policy_hash" "text" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."user_password_compliance" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."user_password_compliance" IS 'Tracks which users have verified their passwords meet their org password policy requirements'; + + + +COMMENT ON COLUMN "public"."user_password_compliance"."policy_hash" IS 'MD5 hash of the password_policy_config when the user validated. If policy changes, user must re-validate.'; + + + +ALTER TABLE "public"."user_password_compliance" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."user_password_compliance_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."user_security" ( + "user_id" "uuid" NOT NULL, + "email_otp_verified_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."user_security" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."user_security" IS 'Tracks email OTP verification state used to gate MFA enrollment'; + + + +COMMENT ON COLUMN "public"."user_security"."email_otp_verified_at" IS 'Last successful email OTP verification used for MFA enrollment'; + + + +CREATE TABLE IF NOT EXISTS "public"."users" ( + "created_at" timestamp with time zone DEFAULT "now"(), + "image_url" character varying, + "first_name" character varying, + "last_name" character varying, + "country" character varying, + "email" character varying NOT NULL, + "id" "uuid" NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"(), + "enable_notifications" boolean DEFAULT true NOT NULL, + "opt_for_newsletters" boolean DEFAULT true NOT NULL, + "ban_time" timestamp with time zone, + "email_preferences" "jsonb" DEFAULT '{"onboarding": true, "usage_limit": true, "credit_usage": true, "device_error": true, "weekly_stats": true, "monthly_stats": true, "bundle_created": true, "bundle_deployed": true, "deploy_stats_24h": true, "cli_realtime_feed": true, "builder_onboarding": true, "bundle_incompatible": true, "billing_period_stats": true, "channel_self_rejected": true}'::"jsonb" NOT NULL, + "created_via_invite" boolean DEFAULT false NOT NULL +); + + +ALTER TABLE "public"."users" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."users"."email_preferences" IS 'Per-user email notification preferences. Keys: usage_limit, credit_usage, onboarding, builder_onboarding, weekly_stats, monthly_stats, billing_period_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected, cli_realtime_feed, bundle_incompatible. Values are booleans.'; + + + +COMMENT ON COLUMN "public"."users"."created_via_invite" IS 'True when the account was created through /private/accept_invitation (invited members), false for normal self-signups.'; + + + +CREATE TABLE IF NOT EXISTS "public"."version_meta" ( + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "app_id" character varying(255) NOT NULL, + "version_id" bigint NOT NULL, + "size" bigint NOT NULL +); + + +ALTER TABLE "public"."version_meta" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."version_usage" ( + "timestamp" timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "app_id" character varying(50) NOT NULL, + "version_id" bigint, + "action" "public"."version_action" NOT NULL, + "version_name" character varying(255) +); + + +ALTER TABLE "public"."version_usage" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."webhook_deliveries" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "webhook_id" "uuid" NOT NULL, + "org_id" "uuid" NOT NULL, + "audit_log_id" bigint, + "event_type" "text" NOT NULL, + "status" "text" DEFAULT 'pending'::"text" NOT NULL, + "request_payload" "jsonb" NOT NULL, + "response_status" integer, + "response_body" "text", + "response_headers" "jsonb", + "attempt_count" integer DEFAULT 0 NOT NULL, + "max_attempts" integer DEFAULT 10 NOT NULL, + "next_retry_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "completed_at" timestamp with time zone, + "duration_ms" integer, + "delivery_version" "text" DEFAULT 'legacy'::"text" NOT NULL, + CONSTRAINT "webhook_deliveries_delivery_version_check" CHECK (("delivery_version" ~ '^(legacy|standard)$'::"text")) +); + + +ALTER TABLE "public"."webhook_deliveries" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."webhook_deliveries"."delivery_version" IS 'Delivery format version used for this webhook attempt.'; + + + +CREATE TABLE IF NOT EXISTS "public"."webhooks" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "org_id" "uuid" NOT NULL, + "name" "text" NOT NULL, + "url" "text" NOT NULL, + "secret" "text" DEFAULT ('whsec_'::"text" || "encode"("extensions"."gen_random_bytes"(32), 'base64'::"text")) NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "events" "text"[] NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "created_by" "uuid" NOT NULL, + "delivery_version" "text" DEFAULT 'legacy'::"text" NOT NULL, + CONSTRAINT "webhooks_delivery_version_check" CHECK (("delivery_version" ~ '^(legacy|standard)$'::"text")) +); + + +ALTER TABLE "public"."webhooks" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."webhooks"."secret" IS 'Standard Webhooks HMAC-SHA256 secret in whsec_ base64 format.'; + + + +COMMENT ON COLUMN "public"."webhooks"."delivery_version" IS 'Webhook delivery format version. legacy preserves existing Capgo payloads; standard uses Standard Webhooks payload and headers.'; + + + +ALTER TABLE ONLY "public"."audit_logs" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."audit_logs_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."bandwidth_usage" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."bandwidth_usage_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."capgo_credits_steps" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."capgo_credits_steps_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."cron_tasks" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."cron_tasks_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."daily_bandwidth" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."daily_bandwidth_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."daily_mau" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."daily_mau_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."daily_storage" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."daily_storage_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."device_usage" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."device_usage_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."manifest" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."manifest_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."storage_usage" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."storage_usage_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."stripe_info" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."stripe_info_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."tmp_users" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."tmp_users_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."to_delete_accounts" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."to_delete_accounts_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."usage_credit_consumptions" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."usage_credit_consumptions_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."usage_credit_transactions" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."usage_credit_transactions_id_seq"'::"regclass"); + + + +ALTER TABLE ONLY "public"."apikey_global_permissions" + ADD CONSTRAINT "apikey_global_permissions_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."apikey_global_permissions" + ADD CONSTRAINT "apikey_global_permissions_rbac_permission_unique" UNIQUE ("apikey_rbac_id", "permission_key"); + + + +ALTER TABLE ONLY "public"."apikeys" + ADD CONSTRAINT "apikeys_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."apikeys" + ADD CONSTRAINT "apikeys_rbac_id_key" UNIQUE ("rbac_id"); + + + +ALTER TABLE ONLY "public"."app_metrics_cache" + ADD CONSTRAINT "app_metrics_cache_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."app_versions_meta" + ADD CONSTRAINT "app_versions_meta_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."app_versions" + ADD CONSTRAINT "app_versions_name_app_id_key" UNIQUE ("name", "app_id"); + + + +ALTER TABLE ONLY "public"."app_versions" + ADD CONSTRAINT "app_versions_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."apps" + ADD CONSTRAINT "apps_id_unique" UNIQUE ("id"); + + + +ALTER TABLE ONLY "public"."apps" + ADD CONSTRAINT "apps_pkey" PRIMARY KEY ("app_id"); + + + +ALTER TABLE ONLY "public"."audit_logs" + ADD CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."bandwidth_usage" + ADD CONSTRAINT "bandwidth_usage_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."build_logs" + ADD CONSTRAINT "build_logs_build_id_org_id_unique" UNIQUE ("build_id", "org_id"); + + + +ALTER TABLE ONLY "public"."build_requests" + ADD CONSTRAINT "build_requests_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."capgo_credits_steps" + ADD CONSTRAINT "capgo_credits_steps_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."channel_devices" + ADD CONSTRAINT "channel_devices_app_id_device_id_key" UNIQUE ("app_id", "device_id"); + + + +ALTER TABLE ONLY "public"."channel_permission_overrides" + ADD CONSTRAINT "channel_permission_overrides_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."channels" + ADD CONSTRAINT "channel_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."channels" + ADD CONSTRAINT "channels_rbac_id_key" UNIQUE ("rbac_id"); + + + +ALTER TABLE ONLY "public"."compatibility_events" + ADD CONSTRAINT "compatibility_events_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."cron_tasks" + ADD CONSTRAINT "cron_tasks_name_key" UNIQUE ("name"); + + + +ALTER TABLE ONLY "public"."cron_tasks" + ADD CONSTRAINT "cron_tasks_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."daily_bandwidth" + ADD CONSTRAINT "daily_bandwidth_pkey" PRIMARY KEY ("app_id", "date"); + + + +ALTER TABLE ONLY "public"."daily_build_time" + ADD CONSTRAINT "daily_build_time_pkey" PRIMARY KEY ("app_id", "date"); + + + +ALTER TABLE ONLY "public"."daily_mau" + ADD CONSTRAINT "daily_mau_pkey" PRIMARY KEY ("app_id", "date"); + + + +ALTER TABLE ONLY "public"."daily_revenue_metrics" + ADD CONSTRAINT "daily_revenue_metrics_pkey" PRIMARY KEY ("date_id", "customer_id"); + + + +ALTER TABLE ONLY "public"."daily_storage_hourly" + ADD CONSTRAINT "daily_storage_hourly_pkey" PRIMARY KEY ("app_id", "date"); + + + +ALTER TABLE ONLY "public"."daily_storage" + ADD CONSTRAINT "daily_storage_pkey" PRIMARY KEY ("app_id", "date"); + + + +ALTER TABLE ONLY "public"."daily_version" + ADD CONSTRAINT "daily_version_app_date_version_name_key" UNIQUE ("app_id", "date", "version_name"); + + + +ALTER TABLE ONLY "public"."deleted_account" + ADD CONSTRAINT "deleted_account_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."deleted_apps" + ADD CONSTRAINT "deleted_apps_app_id_owner_org_key" UNIQUE ("app_id", "owner_org"); + + + +ALTER TABLE ONLY "public"."deleted_apps" + ADD CONSTRAINT "deleted_apps_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."deploy_history" + ADD CONSTRAINT "deploy_history_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."device_usage" + ADD CONSTRAINT "device_usage_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."devices" + ADD CONSTRAINT "devices_pkey" PRIMARY KEY ("app_id", "device_id"); + + + +ALTER TABLE ONLY "public"."global_stats" + ADD CONSTRAINT "global_stats_pkey" PRIMARY KEY ("date_id"); + + + +ALTER TABLE ONLY "public"."group_members" + ADD CONSTRAINT "group_members_pkey" PRIMARY KEY ("group_id", "user_id"); + + + +ALTER TABLE ONLY "public"."groups" + ADD CONSTRAINT "groups_org_name_unique" UNIQUE ("org_id", "name"); + + + +ALTER TABLE ONLY "public"."groups" + ADD CONSTRAINT "groups_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."manifest" + ADD CONSTRAINT "manifest_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."notifications" + ADD CONSTRAINT "notifications_pkey" PRIMARY KEY ("owner_org", "event", "uniq_id"); + + + +ALTER TABLE ONLY "public"."onboarding_demo_data" + ADD CONSTRAINT "onboarding_demo_data_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."org_metrics_cache" + ADD CONSTRAINT "org_metrics_cache_pkey" PRIMARY KEY ("org_id"); + + + +ALTER TABLE ONLY "public"."org_users" + ADD CONSTRAINT "org_users_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."orgs" + ADD CONSTRAINT "orgs_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."permissions" + ADD CONSTRAINT "permissions_key_key" UNIQUE ("key"); + + + +ALTER TABLE ONLY "public"."permissions" + ADD CONSTRAINT "permissions_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."plans" + ADD CONSTRAINT "plans_pkey" PRIMARY KEY ("name", "stripe_id", "id"); + + + +ALTER TABLE ONLY "public"."plans" + ADD CONSTRAINT "plans_stripe_id_key" UNIQUE ("stripe_id"); + + + +ALTER TABLE ONLY "public"."processed_stripe_events" + ADD CONSTRAINT "processed_stripe_events_pkey" PRIMARY KEY ("event_id"); + + + +ALTER TABLE ONLY "public"."role_bindings" + ADD CONSTRAINT "role_bindings_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."role_hierarchy" + ADD CONSTRAINT "role_hierarchy_pkey" PRIMARY KEY ("parent_role_id", "child_role_id"); + + + +ALTER TABLE ONLY "public"."role_permissions" + ADD CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id", "permission_id"); + + + +ALTER TABLE ONLY "public"."roles" + ADD CONSTRAINT "roles_name_key" UNIQUE ("name"); + + + +ALTER TABLE ONLY "public"."roles" + ADD CONSTRAINT "roles_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."sso_providers" + ADD CONSTRAINT "sso_providers_domain_key" UNIQUE ("domain"); + + + +ALTER TABLE ONLY "public"."sso_providers" + ADD CONSTRAINT "sso_providers_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."stats" + ADD CONSTRAINT "stats_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."storage_usage" + ADD CONSTRAINT "storage_usage_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."stripe_info" + ADD CONSTRAINT "stripe_info_pkey" PRIMARY KEY ("customer_id"); + + + +ALTER TABLE ONLY "public"."tmp_users" + ADD CONSTRAINT "tmp_users_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."to_delete_accounts" + ADD CONSTRAINT "to_delete_accounts_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."orgs" + ADD CONSTRAINT "unique customer_id on orgs" UNIQUE ("customer_id"); + + + +ALTER TABLE ONLY "public"."channels" + ADD CONSTRAINT "unique_name_app_id" UNIQUE ("name", "app_id"); + + + +ALTER TABLE ONLY "public"."orgs" + ADD CONSTRAINT "unique_name_created_by" UNIQUE ("name", "created_by"); + + + +ALTER TABLE ONLY "public"."usage_credit_consumptions" + ADD CONSTRAINT "usage_credit_consumptions_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."usage_credit_grants" + ADD CONSTRAINT "usage_credit_grants_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."usage_credit_transactions" + ADD CONSTRAINT "usage_credit_transactions_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."usage_overage_events" + ADD CONSTRAINT "usage_overage_events_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_password_compliance" + ADD CONSTRAINT "user_password_compliance_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_password_compliance" + ADD CONSTRAINT "user_password_compliance_user_id_org_id_key" UNIQUE ("user_id", "org_id"); + + + +ALTER TABLE ONLY "public"."user_security" + ADD CONSTRAINT "user_security_pkey" PRIMARY KEY ("user_id"); + + + +ALTER TABLE ONLY "public"."users" + ADD CONSTRAINT "users_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."version_meta" + ADD CONSTRAINT "version_meta_pkey" PRIMARY KEY ("timestamp", "app_id", "version_id", "size"); + + + +ALTER TABLE ONLY "public"."webhook_deliveries" + ADD CONSTRAINT "webhook_deliveries_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."webhooks" + ADD CONSTRAINT "webhooks_pkey" PRIMARY KEY ("id"); + + + +CREATE INDEX "apikeys_key_idx" ON "public"."apikeys" USING "btree" ("key"); + + + +CREATE UNIQUE INDEX "app_metrics_cache_org_id_key" ON "public"."app_metrics_cache" USING "btree" ("org_id"); + + + +CREATE INDEX "app_versions_cli_version_idx" ON "public"."app_versions" USING "btree" ("cli_version"); + + + +CREATE INDEX "app_versions_meta_app_id_idx" ON "public"."app_versions_meta" USING "btree" ("app_id"); + + + +CREATE INDEX "app_versions_r2_path_idx" ON "public"."app_versions" USING "btree" ("r2_path"); + + + +CREATE INDEX "capgo_credits_steps_range_idx" ON "public"."capgo_credits_steps" USING "btree" ("step_min", "step_max"); + + + +CREATE INDEX "channel_devices_device_id_idx" ON "public"."channel_devices" USING "btree" ("device_id"); + + + +CREATE INDEX "channel_permission_overrides_channel_idx" ON "public"."channel_permission_overrides" USING "btree" ("channel_id"); + + + +CREATE INDEX "channel_permission_overrides_permission_idx" ON "public"."channel_permission_overrides" USING "btree" ("permission_key"); + + + +CREATE INDEX "channel_permission_overrides_principal_idx" ON "public"."channel_permission_overrides" USING "btree" ("principal_type", "principal_id"); + + + +CREATE UNIQUE INDEX "channel_permission_overrides_unique" ON "public"."channel_permission_overrides" USING "btree" ("principal_type", "principal_id", "channel_id", "permission_key"); + + + +CREATE UNIQUE INDEX "channels_one_public_android_per_app_key" ON "public"."channels" USING "btree" ("app_id") WHERE (("public" = true) AND ("android" = true)); + + + +CREATE UNIQUE INDEX "channels_one_public_electron_per_app_key" ON "public"."channels" USING "btree" ("app_id") WHERE (("public" = true) AND ("electron" = true)); + + + +CREATE UNIQUE INDEX "channels_one_public_ios_per_app_key" ON "public"."channels" USING "btree" ("app_id") WHERE (("public" = true) AND ("ios" = true)); + + + +CREATE INDEX "daily_revenue_metrics_date_id_idx" ON "public"."daily_revenue_metrics" USING "btree" ("date_id"); + + + +CREATE INDEX "deploy_history_app_id_idx" ON "public"."deploy_history" USING "btree" ("app_id"); + + + +CREATE INDEX "deploy_history_app_version_idx" ON "public"."deploy_history" USING "btree" ("app_id", "version_id"); + + + +CREATE INDEX "deploy_history_channel_app_idx" ON "public"."deploy_history" USING "btree" ("channel_id", "app_id"); + + + +CREATE INDEX "deploy_history_channel_deployed_idx" ON "public"."deploy_history" USING "btree" ("channel_id", "deployed_at"); + + + +CREATE INDEX "deploy_history_channel_id_idx" ON "public"."deploy_history" USING "btree" ("channel_id"); + + + +CREATE INDEX "deploy_history_deployed_at_idx" ON "public"."deploy_history" USING "btree" ("deployed_at"); + + + +CREATE INDEX "deploy_history_version_id_idx" ON "public"."deploy_history" USING "btree" ("version_id"); + + + +CREATE INDEX "devices_app_id_device_id_updated_at_idx" ON "public"."devices" USING "btree" ("app_id", "device_id", "updated_at"); + + + +CREATE INDEX "devices_app_id_updated_at_idx" ON "public"."devices" USING "btree" ("app_id", "updated_at"); + + + +CREATE INDEX "finx_apikeys_user_id" ON "public"."apikeys" USING "btree" ("user_id"); + + + +CREATE INDEX "finx_app_versions_meta_owner_org" ON "public"."app_versions_meta" USING "btree" ("owner_org"); + + + +CREATE INDEX "finx_app_versions_owner_org" ON "public"."app_versions" USING "btree" ("owner_org"); + + + +CREATE INDEX "finx_apps_owner_org" ON "public"."apps" USING "btree" ("owner_org"); + + + +CREATE INDEX "finx_apps_user_id" ON "public"."apps" USING "btree" ("user_id"); + + + +CREATE INDEX "finx_channel_devices_app_id" ON "public"."channel_devices" USING "btree" ("app_id"); + + + +CREATE INDEX "finx_channel_devices_channel_id" ON "public"."channel_devices" USING "btree" ("channel_id"); + + + +CREATE INDEX "finx_channels_app_id" ON "public"."channels" USING "btree" ("app_id"); + + + +CREATE INDEX "finx_channels_owner_org" ON "public"."channels" USING "btree" ("owner_org"); + + + +CREATE INDEX "finx_channels_version" ON "public"."channels" USING "btree" ("version"); + + + +CREATE INDEX "finx_org_users_channel_id" ON "public"."org_users" USING "btree" ("channel_id"); + + + +CREATE INDEX "finx_org_users_org_id" ON "public"."org_users" USING "btree" ("org_id"); + + + +CREATE INDEX "finx_org_users_user_id" ON "public"."org_users" USING "btree" ("user_id"); + + + +CREATE INDEX "finx_orgs_created_by" ON "public"."orgs" USING "btree" ("created_by"); + + + +CREATE INDEX "finx_orgs_stripe_info" ON "public"."stripe_info" USING "btree" ("product_id"); + + + +CREATE INDEX "idx_apikeys_expires_at" ON "public"."apikeys" USING "btree" ("expires_at") WHERE ("expires_at" IS NOT NULL); + + + +CREATE INDEX "idx_apikeys_key_hash" ON "public"."apikeys" USING "btree" ("key_hash") WHERE ("key_hash" IS NOT NULL); + + + +CREATE INDEX "idx_app_id_app_versions" ON "public"."app_versions" USING "btree" ("app_id"); + + + +CREATE UNIQUE INDEX "idx_app_id_device_id_channel_id_channel_devices" ON "public"."channel_devices" USING "btree" ("app_id", "device_id", "channel_id"); + + + +CREATE INDEX "idx_app_id_name_app_versions" ON "public"."app_versions" USING "btree" ("app_id", "name"); + + + +CREATE INDEX "idx_app_id_public_channel" ON "public"."channels" USING "btree" ("app_id", "public"); + + + +CREATE INDEX "idx_app_id_version_name_devices" ON "public"."devices" USING "btree" ("app_id", "version_name"); + + + +CREATE INDEX "idx_app_versions_created_at" ON "public"."app_versions" USING "btree" ("created_at"); + + + +CREATE INDEX "idx_app_versions_created_at_app_id" ON "public"."app_versions" USING "btree" ("created_at", "app_id"); + + + +CREATE INDEX "idx_app_versions_deleted" ON "public"."app_versions" USING "btree" ("deleted"); + + + +CREATE INDEX "idx_app_versions_deleted_at" ON "public"."app_versions" USING "btree" ("deleted_at") WHERE ("deleted_at" IS NOT NULL); + + + +CREATE INDEX "idx_app_versions_id" ON "public"."app_versions" USING "btree" ("id"); + + + +CREATE INDEX "idx_app_versions_key_id" ON "public"."app_versions" USING "btree" ("key_id") WHERE ("key_id" IS NOT NULL); + + + +CREATE INDEX "idx_app_versions_meta_id" ON "public"."app_versions_meta" USING "btree" ("id"); + + + +CREATE INDEX "idx_app_versions_name" ON "public"."app_versions" USING "btree" ("name"); + + + +CREATE INDEX "idx_app_versions_owner_org_not_deleted" ON "public"."app_versions" USING "btree" ("owner_org") WHERE ("deleted" = false); + + + +CREATE INDEX "idx_app_versions_retention_cleanup" ON "public"."app_versions" USING "btree" ("deleted", "created_at", "app_id") WHERE ("deleted" = false); + + + +CREATE INDEX "idx_apps_default_upload_channel" ON "public"."apps" USING "btree" ("default_upload_channel"); + + + +CREATE INDEX "idx_audit_logs_created_at" ON "public"."audit_logs" USING "btree" ("created_at" DESC); + + + +CREATE INDEX "idx_audit_logs_operation" ON "public"."audit_logs" USING "btree" ("operation"); + + + +CREATE INDEX "idx_audit_logs_org_created" ON "public"."audit_logs" USING "btree" ("org_id", "created_at" DESC); + + + +CREATE INDEX "idx_audit_logs_org_id" ON "public"."audit_logs" USING "btree" ("org_id"); + + + +CREATE INDEX "idx_audit_logs_record_id" ON "public"."audit_logs" USING "btree" ("record_id"); + + + +CREATE INDEX "idx_audit_logs_table_name" ON "public"."audit_logs" USING "btree" ("table_name"); + + + +CREATE INDEX "idx_audit_logs_user_id" ON "public"."audit_logs" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_build_logs_app_id_created_at" ON "public"."build_logs" USING "btree" ("app_id", "created_at"); + + + +CREATE INDEX "idx_build_logs_created_at_platform" ON "public"."build_logs" USING "btree" ("created_at", "platform"); + + + +CREATE INDEX "idx_build_logs_org_created" ON "public"."build_logs" USING "btree" ("org_id", "created_at" DESC); + + + +CREATE INDEX "idx_build_logs_user_id" ON "public"."build_logs" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_build_requests_app" ON "public"."build_requests" USING "btree" ("app_id"); + + + +CREATE INDEX "idx_build_requests_job" ON "public"."build_requests" USING "btree" ("builder_job_id"); + + + +CREATE INDEX "idx_build_requests_org" ON "public"."build_requests" USING "btree" ("owner_org"); + + + +CREATE INDEX "idx_build_requests_requested_by" ON "public"."build_requests" USING "btree" ("requested_by"); + + + +CREATE INDEX "idx_capgo_credits_steps_org_id" ON "public"."capgo_credits_steps" USING "btree" ("org_id"); + + + +CREATE INDEX "idx_channels_app_id_name" ON "public"."channels" USING "btree" ("app_id", "name"); + + + +CREATE INDEX "idx_channels_app_id_version" ON "public"."channels" USING "btree" ("app_id", "version"); + + + +CREATE INDEX "idx_channels_public_app_id_android" ON "public"."channels" USING "btree" ("public", "app_id", "android"); + + + +CREATE INDEX "idx_channels_public_app_id_ios" ON "public"."channels" USING "btree" ("public", "app_id", "ios"); + + + +CREATE INDEX "idx_compatibility_events_app_created" ON "public"."compatibility_events" USING "btree" ("app_id", "created_at" DESC); + + + +CREATE INDEX "idx_compatibility_events_unresolved" ON "public"."compatibility_events" USING "btree" ("app_id") WHERE ("resolved_at" IS NULL); + + + +CREATE INDEX "idx_cron_tasks_enabled" ON "public"."cron_tasks" USING "btree" ("enabled") WHERE ("enabled" = true); + + + +CREATE INDEX "idx_daily_bandwidth_app_id_date" ON "public"."daily_bandwidth" USING "btree" ("app_id", "date"); + + + +CREATE INDEX "idx_daily_build_time_app_date" ON "public"."daily_build_time" USING "btree" ("app_id", "date"); + + + +CREATE INDEX "idx_daily_mau_app_id_date" ON "public"."daily_mau" USING "btree" ("app_id", "date"); + + + +CREATE INDEX "idx_daily_storage_app_id_date" ON "public"."daily_storage" USING "btree" ("app_id", "date"); + + + +CREATE INDEX "idx_daily_storage_hourly_date" ON "public"."daily_storage_hourly" USING "btree" ("date"); + + + +CREATE INDEX "idx_daily_storage_hourly_owner_org_date" ON "public"."daily_storage_hourly" USING "btree" ("owner_org", "date"); + + + +CREATE INDEX "idx_daily_version_app_id" ON "public"."daily_version" USING "btree" ("app_id"); + + + +CREATE INDEX "idx_daily_version_app_id_date" ON "public"."daily_version" USING "btree" ("app_id", "date"); + + + +CREATE INDEX "idx_daily_version_version_name" ON "public"."daily_version" USING "btree" ("version_name"); + + + +CREATE INDEX "idx_deleted_apps_app_id" ON "public"."deleted_apps" USING "btree" ("app_id"); + + + +CREATE INDEX "idx_deleted_apps_deleted_at" ON "public"."deleted_apps" USING "btree" ("deleted_at"); + + + +CREATE INDEX "idx_deleted_apps_owner_org" ON "public"."deleted_apps" USING "btree" ("owner_org"); + + + +CREATE INDEX "idx_deploy_history_created_by" ON "public"."deploy_history" USING "btree" ("created_by"); + + + +CREATE INDEX "idx_device_usage_app_timestamp_platform_version_build" ON "public"."device_usage" USING "btree" ("app_id", "timestamp", "platform", "version_build"); + + + +CREATE INDEX "idx_device_usage_app_timestamp_version_build" ON "public"."device_usage" USING "btree" ("app_id", "timestamp", "version_build"); + + + +CREATE INDEX "idx_devices_default_channel" ON "public"."devices" USING "btree" ("default_channel"); + + + +CREATE INDEX "idx_devices_key_id" ON "public"."devices" USING "btree" ("key_id") WHERE ("key_id" IS NOT NULL); + + + +CREATE INDEX "idx_group_members_user_id_group_id" ON "public"."group_members" USING "btree" ("user_id", "group_id"); + + + +CREATE INDEX "idx_manifest_app_version_id" ON "public"."manifest" USING "btree" ("app_version_id"); + + + +CREATE INDEX "idx_manifest_file_hash" ON "public"."manifest" USING "btree" ("file_hash"); + + + +CREATE INDEX "idx_manifest_file_name" ON "public"."manifest" USING "btree" ("file_name"); + + + +CREATE INDEX "idx_orgs_customer_id" ON "public"."orgs" USING "btree" ("customer_id"); + + + +CREATE INDEX "idx_orgs_email_preferences" ON "public"."orgs" USING "gin" ("email_preferences"); + + + +CREATE INDEX "idx_sso_providers_org_id" ON "public"."sso_providers" USING "btree" ("org_id"); + + + +CREATE INDEX "idx_stats_app_id_action" ON "public"."stats" USING "btree" ("app_id", "action"); + + + +CREATE INDEX "idx_stats_app_id_created_at" ON "public"."stats" USING "btree" ("app_id", "created_at"); + + + +CREATE INDEX "idx_stats_app_id_device_id" ON "public"."stats" USING "btree" ("app_id", "device_id"); + + + +CREATE INDEX "idx_stats_app_id_version_name" ON "public"."stats" USING "btree" ("app_id", "version_name"); + + + +CREATE INDEX "idx_stripe_info_customer_covering" ON "public"."stripe_info" USING "btree" ("customer_id") INCLUDE ("product_id", "subscription_anchor_start", "subscription_anchor_end"); + + + +CREATE INDEX "idx_stripe_info_customer_id" ON "public"."stripe_info" USING "btree" ("customer_id"); + + + +CREATE INDEX "idx_stripe_info_status_plan" ON "public"."stripe_info" USING "btree" ("status", "is_good_plan") WHERE (("status" = 'succeeded'::"public"."stripe_status") AND ("is_good_plan" = true)); + + + +CREATE INDEX "idx_stripe_info_trial" ON "public"."stripe_info" USING "btree" ("trial_at") WHERE ("trial_at" IS NOT NULL); + + + +CREATE INDEX "idx_usage_credit_consumptions_grant" ON "public"."usage_credit_consumptions" USING "btree" ("grant_id", "applied_at" DESC); + + + +CREATE INDEX "idx_usage_credit_consumptions_org_time" ON "public"."usage_credit_consumptions" USING "btree" ("org_id", "applied_at" DESC); + + + +CREATE INDEX "idx_usage_credit_consumptions_overage_event_id" ON "public"."usage_credit_consumptions" USING "btree" ("overage_event_id"); + + + +CREATE INDEX "idx_usage_credit_grants_org_expires" ON "public"."usage_credit_grants" USING "btree" ("org_id", "expires_at"); + + + +CREATE INDEX "idx_usage_credit_grants_org_remaining" ON "public"."usage_credit_grants" USING "btree" ("org_id", (("credits_total" - "credits_consumed"))); + + + +CREATE INDEX "idx_usage_credit_transactions_grant" ON "public"."usage_credit_transactions" USING "btree" ("grant_id", "occurred_at" DESC); + + + +CREATE INDEX "idx_usage_credit_transactions_org_id" ON "public"."usage_credit_transactions" USING "btree" ("org_id"); + + + +CREATE INDEX "idx_usage_credit_transactions_org_time" ON "public"."usage_credit_transactions" USING "btree" ("org_id", "occurred_at" DESC); + + + +CREATE INDEX "idx_usage_overage_events_credit_step_id" ON "public"."usage_overage_events" USING "btree" ("credit_step_id"); + + + +CREATE INDEX "idx_usage_overage_events_metric" ON "public"."usage_overage_events" USING "btree" ("metric"); + + + +CREATE INDEX "idx_usage_overage_events_org_id" ON "public"."usage_overage_events" USING "btree" ("org_id"); + + + +CREATE INDEX "idx_usage_overage_events_org_time" ON "public"."usage_overage_events" USING "btree" ("org_id", "created_at" DESC); + + + +CREATE INDEX "idx_user_password_compliance_user_org" ON "public"."user_password_compliance" USING "btree" ("user_id", "org_id"); + + + +CREATE INDEX "idx_users_email_preferences" ON "public"."users" USING "gin" ("email_preferences"); + + + +CREATE INDEX "idx_version_meta_app_id_timestamp" ON "public"."version_meta" USING "btree" ("app_id", "timestamp"); + + + +CREATE INDEX "idx_version_usage_version_name" ON "public"."version_usage" USING "btree" ("version_name"); + + + +CREATE INDEX "notifications_uniq_id_idx" ON "public"."notifications" USING "btree" ("uniq_id"); + + + +CREATE UNIQUE INDEX "onboarding_demo_data_app_relation_row_key_idx" ON "public"."onboarding_demo_data" USING "btree" ("app_id", "relation_name", "row_key"); + + + +CREATE INDEX "onboarding_demo_data_seed_id_idx" ON "public"."onboarding_demo_data" USING "btree" ("seed_id"); + + + +CREATE INDEX "org_users_app_id_idx" ON "public"."org_users" USING "btree" ("app_id"); + + + +CREATE INDEX "orgs_enforce_hashed_api_keys_true_idx" ON "public"."orgs" USING "btree" ("id") WHERE ("enforce_hashed_api_keys" = true); + + + +CREATE INDEX "orgs_updated_at_id_idx" ON "public"."orgs" USING "btree" ("updated_at" DESC) INCLUDE ("id") WHERE ("customer_id" IS NOT NULL); + + + +CREATE INDEX "processed_stripe_events_customer_id_date_id_idx" ON "public"."processed_stripe_events" USING "btree" ("customer_id", "date_id"); + + + +CREATE UNIQUE INDEX "role_bindings_app_scope_uniq" ON "public"."role_bindings" USING "btree" ("principal_type", "principal_id", "app_id", "scope_type") WHERE ("scope_type" = "public"."rbac_scope_app"()); + + + +CREATE UNIQUE INDEX "role_bindings_bundle_scope_uniq" ON "public"."role_bindings" USING "btree" ("principal_type", "principal_id", "bundle_id", "scope_type") WHERE ("scope_type" = "public"."rbac_scope_bundle"()); + + + +CREATE UNIQUE INDEX "role_bindings_channel_scope_uniq" ON "public"."role_bindings" USING "btree" ("principal_type", "principal_id", "channel_id", "scope_type") WHERE ("scope_type" = "public"."rbac_scope_channel"()); + + + +CREATE UNIQUE INDEX "role_bindings_org_scope_uniq" ON "public"."role_bindings" USING "btree" ("principal_type", "principal_id", "org_id", "scope_type") WHERE ("scope_type" = "public"."rbac_scope_org"()); + + + +CREATE INDEX "role_bindings_principal_scope_idx" ON "public"."role_bindings" USING "btree" ("principal_type", "principal_id", "scope_type", "org_id", "app_id", "channel_id"); + + + +CREATE INDEX "role_bindings_scope_idx" ON "public"."role_bindings" USING "btree" ("scope_type", "org_id", "app_id", "channel_id"); + + + +CREATE INDEX "si_customer_status_trial_idx" ON "public"."stripe_info" USING "btree" ("customer_id", "status", "trial_at") INCLUDE ("mau_exceeded", "storage_exceeded", "bandwidth_exceeded"); + + + +CREATE INDEX "stripe_info_paid_at_idx" ON "public"."stripe_info" USING "btree" ("paid_at") WHERE ("paid_at" IS NOT NULL); + + + +CREATE INDEX "tmp_users_invite_magic_string_idx" ON "public"."tmp_users" USING "btree" ("invite_magic_string"); + + + +CREATE UNIQUE INDEX "tmp_users_org_id_email_idx" ON "public"."tmp_users" USING "btree" ("org_id", "email"); + + + +CREATE UNIQUE INDEX "to_delete_accounts_account_id_key" ON "public"."to_delete_accounts" USING "btree" ("account_id"); + + + +CREATE INDEX "to_delete_accounts_removal_date_idx" ON "public"."to_delete_accounts" USING "btree" ("removal_date"); + + + +CREATE UNIQUE INDEX "unique_app_version_negative" ON "public"."version_meta" USING "btree" ("app_id", "version_id") WHERE ("size" < 0); + + + +CREATE UNIQUE INDEX "unique_app_version_positive" ON "public"."version_meta" USING "btree" ("app_id", "version_id") WHERE ("size" > 0); + + + +CREATE UNIQUE INDEX "uq_compatibility_events_dedup" ON "public"."compatibility_events" USING "btree" ("app_id", "channel_id", "platform", "current_version_id", "previous_version_id", "change_occurred_at") NULLS NOT DISTINCT; + + + +CREATE UNIQUE INDEX "usage_credit_transactions_purchase_payment_intent_id_idx" ON "public"."usage_credit_transactions" USING "btree" ((("source_ref" ->> 'paymentIntentId'::"text"))) WHERE (("transaction_type" = 'purchase'::"public"."credit_transaction_type") AND (("source_ref" ->> 'paymentIntentId'::"text") IS NOT NULL)); + + + +CREATE UNIQUE INDEX "usage_credit_transactions_purchase_session_id_idx" ON "public"."usage_credit_transactions" USING "btree" ((("source_ref" ->> 'sessionId'::"text"))) WHERE (("transaction_type" = 'purchase'::"public"."credit_transaction_type") AND (("source_ref" ->> 'sessionId'::"text") IS NOT NULL)); + + + +CREATE INDEX "webhook_deliveries_org_id_created_idx" ON "public"."webhook_deliveries" USING "btree" ("org_id", "created_at" DESC); + + + +CREATE INDEX "webhook_deliveries_pending_retry_idx" ON "public"."webhook_deliveries" USING "btree" ("status", "next_retry_at") WHERE ("status" = 'pending'::"text"); + + + +CREATE INDEX "webhook_deliveries_webhook_id_idx" ON "public"."webhook_deliveries" USING "btree" ("webhook_id"); + + + +CREATE INDEX "webhooks_enabled_idx" ON "public"."webhooks" USING "btree" ("org_id", "enabled") WHERE ("enabled" = true); + + + +CREATE INDEX "webhooks_org_id_idx" ON "public"."webhooks" USING "btree" ("org_id"); + + + +CREATE OR REPLACE TRIGGER "aggregate_build_log_to_daily_trigger" AFTER INSERT OR DELETE OR UPDATE ON "public"."build_logs" FOR EACH ROW EXECUTE FUNCTION "public"."aggregate_build_log_to_daily"(); + + + +CREATE OR REPLACE TRIGGER "apikeys_enforce_expiration_policy" BEFORE INSERT OR UPDATE ON "public"."apikeys" FOR EACH ROW EXECUTE FUNCTION "public"."enforce_apikey_expiration_policy"(); + + + +CREATE OR REPLACE TRIGGER "apikeys_force_server_key" BEFORE INSERT OR UPDATE ON "public"."apikeys" FOR EACH ROW EXECUTE FUNCTION "public"."apikeys_force_server_key"(); + + + +CREATE CONSTRAINT TRIGGER "apikeys_strip_plain_key_for_hashed" AFTER INSERT OR UPDATE ON "public"."apikeys" DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION "public"."apikeys_strip_plain_key_for_hashed"(); + + + +CREATE OR REPLACE TRIGGER "audit_app_versions_trigger" AFTER INSERT OR DELETE OR UPDATE ON "public"."app_versions" FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); + + + +CREATE OR REPLACE TRIGGER "audit_apps_trigger" AFTER INSERT OR DELETE OR UPDATE ON "public"."apps" FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); + + + +CREATE OR REPLACE TRIGGER "audit_channels_trigger" AFTER INSERT OR DELETE OR UPDATE ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); + + + +CREATE OR REPLACE TRIGGER "audit_org_users_trigger" AFTER INSERT OR DELETE OR UPDATE ON "public"."org_users" FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); + + + +CREATE OR REPLACE TRIGGER "audit_orgs_trigger" AFTER INSERT OR DELETE OR UPDATE ON "public"."orgs" FOR EACH ROW EXECUTE FUNCTION "public"."audit_log_trigger"(); + + + +CREATE OR REPLACE TRIGGER "bind_creating_apikey_to_org_on_create" AFTER INSERT ON "public"."orgs" FOR EACH ROW EXECUTE FUNCTION "public"."bind_creating_apikey_to_org_on_create"(); + + + +CREATE OR REPLACE TRIGGER "channel_device_count_enqueue" AFTER INSERT OR DELETE ON "public"."channel_devices" FOR EACH ROW EXECUTE FUNCTION "public"."enqueue_channel_device_counts"(); + + + +CREATE OR REPLACE TRIGGER "check_if_org_can_exist_org_users" AFTER DELETE ON "public"."org_users" FOR EACH ROW EXECUTE FUNCTION "public"."check_if_org_can_exist"(); + + + +CREATE OR REPLACE TRIGGER "check_privileges" BEFORE INSERT OR UPDATE ON "public"."org_users" FOR EACH ROW EXECUTE FUNCTION "public"."check_org_user_privileges"(); + + + +CREATE OR REPLACE TRIGGER "cleanup_apikey_role_bindings_on_delete" BEFORE DELETE ON "public"."apikeys" FOR EACH ROW EXECUTE FUNCTION "public"."cleanup_apikey_role_bindings"(); + + + +CREATE OR REPLACE TRIGGER "cleanup_onboarding_app_data_on_complete" AFTER UPDATE OF "need_onboarding" ON "public"."apps" FOR EACH ROW WHEN ((("old"."need_onboarding" IS TRUE) AND ("new"."need_onboarding" IS FALSE))) EXECUTE FUNCTION "public"."cleanup_onboarding_app_data_on_complete"(); + + + +CREATE OR REPLACE TRIGGER "credit_usage_alert_on_transactions" AFTER INSERT ON "public"."usage_credit_transactions" FOR EACH ROW EXECUTE FUNCTION "public"."enqueue_credit_usage_alert"(); + + + +CREATE OR REPLACE TRIGGER "enforce_channel_version_promotion_permission" BEFORE UPDATE OF "version" ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "public"."enforce_channel_version_promotion_permission"(); + + + +CREATE OR REPLACE TRIGGER "enforce_encrypted_bundle_trigger" BEFORE INSERT OR UPDATE OF "name", "app_id", "session_key", "key_id", "storage_provider", "r2_path", "external_url", "checksum", "manifest", "native_packages" ON "public"."app_versions" FOR EACH ROW EXECUTE FUNCTION "public"."check_encrypted_bundle_on_insert"(); + + + +CREATE OR REPLACE TRIGGER "enforce_role_binding_role_scope" BEFORE INSERT OR UPDATE OF "role_id", "scope_type" ON "public"."role_bindings" FOR EACH ROW EXECUTE FUNCTION "public"."enforce_role_binding_role_scope"(); + + + +COMMENT ON TRIGGER "enforce_role_binding_role_scope" ON "public"."role_bindings" IS 'Prevents mixed-scope RBAC bindings such as org roles attached to app scope rows.'; + + + +CREATE OR REPLACE TRIGGER "force_valid_apikey_name" BEFORE INSERT OR UPDATE ON "public"."apikeys" FOR EACH ROW EXECUTE FUNCTION "public"."auto_apikey_name_by_id"(); + + + +CREATE OR REPLACE TRIGGER "force_valid_owner_org_app_versions" BEFORE INSERT OR UPDATE ON "public"."app_versions" FOR EACH ROW EXECUTE FUNCTION "public"."auto_owner_org_by_app_id"(); + + + +CREATE OR REPLACE TRIGGER "force_valid_owner_org_app_versions_meta" BEFORE INSERT OR UPDATE ON "public"."app_versions_meta" FOR EACH ROW EXECUTE FUNCTION "public"."auto_owner_org_by_app_id"(); + + + +CREATE OR REPLACE TRIGGER "force_valid_owner_org_channel_devices" BEFORE INSERT OR UPDATE ON "public"."channel_devices" FOR EACH ROW EXECUTE FUNCTION "public"."auto_owner_org_by_app_id"(); + + + +CREATE OR REPLACE TRIGGER "force_valid_owner_org_channels" BEFORE INSERT OR UPDATE ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "public"."auto_owner_org_by_app_id"(); + + + +CREATE OR REPLACE TRIGGER "generate_org_user_stripe_info_on_org_create" AFTER INSERT ON "public"."orgs" FOR EACH ROW EXECUTE FUNCTION "public"."generate_org_user_stripe_info_on_org_create"(); + + + +CREATE OR REPLACE TRIGGER "guard_owner_org_reassignment_app_versions" BEFORE UPDATE OF "owner_org" ON "public"."app_versions" FOR EACH ROW EXECUTE FUNCTION "public"."guard_owner_org_reassignment"(); + + + +CREATE OR REPLACE TRIGGER "guard_owner_org_reassignment_app_versions_meta" BEFORE UPDATE OF "owner_org" ON "public"."app_versions_meta" FOR EACH ROW EXECUTE FUNCTION "public"."guard_owner_org_reassignment"(); + + + +CREATE OR REPLACE TRIGGER "guard_owner_org_reassignment_apps" BEFORE UPDATE OF "owner_org" ON "public"."apps" FOR EACH ROW EXECUTE FUNCTION "public"."guard_owner_org_reassignment"(); + + + +CREATE OR REPLACE TRIGGER "guard_owner_org_reassignment_channel_devices" BEFORE UPDATE OF "owner_org" ON "public"."channel_devices" FOR EACH ROW EXECUTE FUNCTION "public"."guard_owner_org_reassignment"(); + + + +CREATE OR REPLACE TRIGGER "guard_owner_org_reassignment_channels" BEFORE UPDATE OF "owner_org" ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "public"."guard_owner_org_reassignment"(); + + + +CREATE OR REPLACE TRIGGER "handle_build_requests_updated_at" BEFORE UPDATE ON "public"."build_requests" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_sso_providers_updated_at" BEFORE UPDATE ON "public"."sso_providers" FOR EACH ROW EXECUTE FUNCTION "public"."update_sso_providers_updated_at"(); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE UPDATE ON "public"."apikeys" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE UPDATE ON "public"."app_versions" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE UPDATE ON "public"."app_versions_meta" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE INSERT OR UPDATE ON "public"."apps" FOR EACH ROW EXECUTE FUNCTION "public"."sanitize_apps_text_fields"(); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE UPDATE ON "public"."capgo_credits_steps" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE UPDATE ON "public"."channel_devices" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE UPDATE ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE UPDATE ON "public"."org_users" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE UPDATE ON "public"."plans" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE UPDATE ON "public"."stripe_info" FOR EACH ROW EXECUTE FUNCTION "extensions"."moddatetime"('updated_at'); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE INSERT OR UPDATE ON "public"."tmp_users" FOR EACH ROW EXECUTE FUNCTION "public"."sanitize_tmp_users_text_fields"(); + + + +CREATE OR REPLACE TRIGGER "handle_updated_at" BEFORE INSERT OR UPDATE ON "public"."users" FOR EACH ROW EXECUTE FUNCTION "public"."sanitize_users_text_fields"(); + + + +CREATE OR REPLACE TRIGGER "normalize_public_channel_overlap_before_upsert" BEFORE INSERT OR UPDATE OF "public", "ios", "android", "electron", "app_id" ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "public"."normalize_public_channel_overlap"(); + + + +CREATE OR REPLACE TRIGGER "normalize_sso_provider_domain_before_upsert" BEFORE INSERT OR UPDATE OF "domain" ON "public"."sso_providers" FOR EACH ROW EXECUTE FUNCTION "public"."normalize_sso_provider_domain"(); + + + +CREATE OR REPLACE TRIGGER "noupdate" BEFORE UPDATE ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "public"."noupdate"(); + + + +CREATE OR REPLACE TRIGGER "on_app_create" AFTER INSERT ON "public"."apps" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_app_create'); + + + +CREATE OR REPLACE TRIGGER "on_app_delete" AFTER DELETE ON "public"."apps" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_app_delete'); + + + +CREATE OR REPLACE TRIGGER "on_app_update" AFTER UPDATE OF "icon_url" ON "public"."apps" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_app_update'); + + + +CREATE OR REPLACE TRIGGER "on_audit_log_webhook" AFTER INSERT ON "public"."audit_logs" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_webhook_on_audit_log"(); + + + +CREATE OR REPLACE TRIGGER "on_channel_update" AFTER UPDATE ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_channel_update'); + + + +CREATE OR REPLACE TRIGGER "on_manifest_create" AFTER INSERT ON "public"."manifest" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_manifest_create'); + + + +CREATE OR REPLACE TRIGGER "on_org_create" AFTER INSERT ON "public"."orgs" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_organization_create'); + + + +CREATE OR REPLACE TRIGGER "on_org_update" AFTER UPDATE OF "logo" ON "public"."orgs" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_org_update'); + + + +CREATE OR REPLACE TRIGGER "on_organization_delete" AFTER DELETE ON "public"."orgs" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_organization_delete'); + + + +CREATE OR REPLACE TRIGGER "on_user_create" AFTER INSERT ON "public"."users" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_user_create'); + + + +CREATE OR REPLACE TRIGGER "on_user_delete" AFTER DELETE ON "public"."users" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_user_delete'); + + + +CREATE OR REPLACE TRIGGER "on_user_update" AFTER UPDATE ON "public"."users" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_user_update'); + + + +CREATE OR REPLACE TRIGGER "on_version_create" AFTER INSERT ON "public"."app_versions" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_version_create'); + + + +CREATE OR REPLACE TRIGGER "on_version_delete" AFTER DELETE ON "public"."app_versions" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_version_delete'); + + + +CREATE OR REPLACE TRIGGER "on_version_update" AFTER UPDATE ON "public"."app_versions" FOR EACH ROW EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function"('on_version_update'); + + + +CREATE OR REPLACE TRIGGER "prevent_last_super_admin_delete" BEFORE DELETE ON "public"."role_bindings" FOR EACH ROW EXECUTE FUNCTION "public"."prevent_last_super_admin_binding_delete"(); + + + +CREATE OR REPLACE TRIGGER "prevent_last_super_admin_update" BEFORE UPDATE OF "role_id" ON "public"."role_bindings" FOR EACH ROW EXECUTE FUNCTION "public"."prevent_last_super_admin_binding_update"(); + + + +CREATE OR REPLACE TRIGGER "reassign_webhook_created_by_before_user_delete" BEFORE DELETE ON "public"."users" FOR EACH ROW EXECUTE FUNCTION "public"."reassign_webhook_created_by_before_user_delete"(); + + + +CREATE OR REPLACE TRIGGER "record_deployment_history_trigger" AFTER UPDATE OF "version" ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "public"."record_deployment_history"(); + + + +CREATE OR REPLACE TRIGGER "role_bindings_enforce_apikey_expiration_policy" BEFORE INSERT OR UPDATE OF "principal_type", "principal_id", "org_id", "expires_at" ON "public"."role_bindings" FOR EACH ROW EXECUTE FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"(); + + + +CREATE OR REPLACE TRIGGER "sanitize_orgs_text_fields" BEFORE INSERT OR UPDATE ON "public"."orgs" FOR EACH ROW EXECUTE FUNCTION "public"."sanitize_orgs_text_fields"(); + + + +CREATE OR REPLACE TRIGGER "set_deleted_at_trigger" BEFORE UPDATE ON "public"."app_versions" FOR EACH ROW EXECUTE FUNCTION "public"."set_deleted_at_on_soft_delete"(); + + + +CREATE OR REPLACE TRIGGER "set_webhook_created_by" BEFORE INSERT ON "public"."webhooks" FOR EACH ROW EXECUTE FUNCTION "public"."set_webhook_created_by"(); + + + +CREATE OR REPLACE TRIGGER "sync_org_user_role_binding_on_delete" AFTER DELETE ON "public"."org_users" FOR EACH ROW EXECUTE FUNCTION "public"."sync_org_user_role_binding_on_delete"(); + + + +CREATE OR REPLACE TRIGGER "sync_org_user_role_binding_on_update" AFTER UPDATE OF "user_right" ON "public"."org_users" FOR EACH ROW EXECUTE FUNCTION "public"."sync_org_user_role_binding_on_update"(); + + + +COMMENT ON TRIGGER "sync_org_user_role_binding_on_update" ON "public"."org_users" IS 'Ensures role_bindings are updated automatically when org_users permissions are changed.'; + + + +CREATE OR REPLACE TRIGGER "sync_org_user_to_role_binding_on_insert" AFTER INSERT ON "public"."org_users" FOR EACH ROW EXECUTE FUNCTION "public"."sync_org_user_to_role_binding"(); + + + +COMMENT ON TRIGGER "sync_org_user_to_role_binding_on_insert" ON "public"."org_users" IS 'Ensures role_bindings are created automatically when org_users entries are added.'; + + + +CREATE OR REPLACE TRIGGER "trg_sync_org_has_usage_credits" AFTER INSERT OR DELETE OR UPDATE ON "public"."usage_credit_grants" FOR EACH ROW EXECUTE FUNCTION "public"."sync_org_has_usage_credits_from_grants"(); + + + +CREATE OR REPLACE TRIGGER "update_apps_build_timeout_updated_at" BEFORE INSERT OR UPDATE ON "public"."apps" FOR EACH ROW EXECUTE FUNCTION "public"."update_apps_build_timeout_updated_at"(); + + + +CREATE OR REPLACE TRIGGER "update_webhooks_updated_at" BEFORE UPDATE ON "public"."webhooks" FOR EACH ROW EXECUTE FUNCTION "public"."update_webhook_updated_at"(); + + + +ALTER TABLE ONLY "public"."apikey_global_permissions" + ADD CONSTRAINT "apikey_global_permissions_apikey_rbac_id_fkey" FOREIGN KEY ("apikey_rbac_id") REFERENCES "public"."apikeys"("rbac_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."apikey_global_permissions" + ADD CONSTRAINT "apikey_global_permissions_granted_by_fkey" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."apikeys" + ADD CONSTRAINT "apikeys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."app_metrics_cache" + ADD CONSTRAINT "app_metrics_cache_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."app_versions" + ADD CONSTRAINT "app_versions_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."app_versions_meta" + ADD CONSTRAINT "app_versions_meta_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."app_versions_meta" + ADD CONSTRAINT "app_versions_meta_id_fkey" FOREIGN KEY ("id") REFERENCES "public"."app_versions"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."apps" + ADD CONSTRAINT "apps_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."audit_logs" + ADD CONSTRAINT "audit_logs_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."audit_logs" + ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."build_logs" + ADD CONSTRAINT "build_logs_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."build_requests" + ADD CONSTRAINT "build_requests_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."build_requests" + ADD CONSTRAINT "build_requests_owner_org_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."build_requests" + ADD CONSTRAINT "build_requests_requested_by_fkey" FOREIGN KEY ("requested_by") REFERENCES "auth"."users"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."capgo_credits_steps" + ADD CONSTRAINT "capgo_credits_steps_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."channel_devices" + ADD CONSTRAINT "channel_devices_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."channel_devices" + ADD CONSTRAINT "channel_devices_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id"); + + + +ALTER TABLE ONLY "public"."channel_permission_overrides" + ADD CONSTRAINT "channel_permission_overrides_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."channel_permission_overrides" + ADD CONSTRAINT "channel_permission_overrides_permission_key_fkey" FOREIGN KEY ("permission_key") REFERENCES "public"."permissions"("key") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."channels" + ADD CONSTRAINT "channels_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."channels" + ADD CONSTRAINT "channels_version_fkey" FOREIGN KEY ("version") REFERENCES "public"."app_versions"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."compatibility_events" + ADD CONSTRAINT "compatibility_events_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."compatibility_events" + ADD CONSTRAINT "compatibility_events_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."daily_build_time" + ADD CONSTRAINT "daily_build_time_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."daily_storage_hourly" + ADD CONSTRAINT "daily_storage_hourly_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."daily_storage_hourly" + ADD CONSTRAINT "daily_storage_hourly_owner_org_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."deploy_history" + ADD CONSTRAINT "deploy_history_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."deploy_history" + ADD CONSTRAINT "deploy_history_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."deploy_history" + ADD CONSTRAINT "deploy_history_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."deploy_history" + ADD CONSTRAINT "deploy_history_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "public"."app_versions"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."group_members" + ADD CONSTRAINT "group_members_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."group_members" + ADD CONSTRAINT "group_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."groups" + ADD CONSTRAINT "groups_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."manifest" + ADD CONSTRAINT "manifest_app_version_id_fkey" FOREIGN KEY ("app_version_id") REFERENCES "public"."app_versions"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."onboarding_demo_data" + ADD CONSTRAINT "onboarding_demo_data_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."onboarding_demo_data" + ADD CONSTRAINT "onboarding_demo_data_owner_org_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."org_metrics_cache" + ADD CONSTRAINT "org_metrics_cache_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."org_users" + ADD CONSTRAINT "org_users_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("app_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."org_users" + ADD CONSTRAINT "org_users_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."org_users" + ADD CONSTRAINT "org_users_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."org_users" + ADD CONSTRAINT "org_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."orgs" + ADD CONSTRAINT "orgs_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."orgs" + ADD CONSTRAINT "orgs_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."stripe_info"("customer_id"); + + + +ALTER TABLE ONLY "public"."apps" + ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."app_versions" + ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."app_versions_meta" + ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."channel_devices" + ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."channels" + ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."notifications" + ADD CONSTRAINT "owner_org_id_fkey" FOREIGN KEY ("owner_org") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."permissions" + ADD CONSTRAINT "permissions_bundle_id_fkey" FOREIGN KEY ("bundle_id") REFERENCES "public"."app_versions"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."role_bindings" + ADD CONSTRAINT "role_bindings_app_id_fkey" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."role_bindings" + ADD CONSTRAINT "role_bindings_bundle_id_fkey" FOREIGN KEY ("bundle_id") REFERENCES "public"."app_versions"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."role_bindings" + ADD CONSTRAINT "role_bindings_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("rbac_id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."role_bindings" + ADD CONSTRAINT "role_bindings_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."role_bindings" + ADD CONSTRAINT "role_bindings_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."role_hierarchy" + ADD CONSTRAINT "role_hierarchy_child_role_id_fkey" FOREIGN KEY ("child_role_id") REFERENCES "public"."roles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."role_hierarchy" + ADD CONSTRAINT "role_hierarchy_parent_role_id_fkey" FOREIGN KEY ("parent_role_id") REFERENCES "public"."roles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."role_permissions" + ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "public"."permissions"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."role_permissions" + ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."sso_providers" + ADD CONSTRAINT "sso_providers_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."stripe_info" + ADD CONSTRAINT "stripe_info_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "public"."plans"("stripe_id"); + + + +ALTER TABLE ONLY "public"."tmp_users" + ADD CONSTRAINT "tmp_users_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."to_delete_accounts" + ADD CONSTRAINT "to_delete_accounts_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."usage_credit_consumptions" + ADD CONSTRAINT "usage_credit_consumptions_grant_id_fkey" FOREIGN KEY ("grant_id") REFERENCES "public"."usage_credit_grants"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."usage_credit_consumptions" + ADD CONSTRAINT "usage_credit_consumptions_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."usage_credit_consumptions" + ADD CONSTRAINT "usage_credit_consumptions_overage_event_id_fkey" FOREIGN KEY ("overage_event_id") REFERENCES "public"."usage_overage_events"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."usage_credit_grants" + ADD CONSTRAINT "usage_credit_grants_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."usage_credit_transactions" + ADD CONSTRAINT "usage_credit_transactions_grant_id_fkey" FOREIGN KEY ("grant_id") REFERENCES "public"."usage_credit_grants"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."usage_credit_transactions" + ADD CONSTRAINT "usage_credit_transactions_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."usage_overage_events" + ADD CONSTRAINT "usage_overage_events_credit_step_id_fkey" FOREIGN KEY ("credit_step_id") REFERENCES "public"."capgo_credits_steps"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."usage_overage_events" + ADD CONSTRAINT "usage_overage_events_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_password_compliance" + ADD CONSTRAINT "user_password_compliance_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_password_compliance" + ADD CONSTRAINT "user_password_compliance_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_security" + ADD CONSTRAINT "user_security_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."users" + ADD CONSTRAINT "users_id_fkey" FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."webhook_deliveries" + ADD CONSTRAINT "webhook_deliveries_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."webhook_deliveries" + ADD CONSTRAINT "webhook_deliveries_webhook_id_fkey" FOREIGN KEY ("webhook_id") REFERENCES "public"."webhooks"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."webhooks" + ADD CONSTRAINT "webhooks_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."webhooks" + ADD CONSTRAINT "webhooks_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE; + + + +CREATE POLICY "Allow admin to delete webhooks" ON "public"."webhooks" FOR DELETE TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow admin to insert webhook_deliveries" ON "public"."webhook_deliveries" FOR INSERT TO "authenticated", "anon" WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow admin to insert webhooks" ON "public"."webhooks" FOR INSERT TO "authenticated", "anon" WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow admin to select webhooks" ON "public"."webhooks" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow admin to update webhook_deliveries" ON "public"."webhook_deliveries" FOR UPDATE TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "org_id", NULL::character varying, NULL::bigint)) WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow admin to update webhooks" ON "public"."webhooks" FOR UPDATE TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "org_id", NULL::character varying, NULL::bigint)) WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow all for auth (super_admin+)" ON "public"."app_versions" FOR DELETE TO "authenticated", "anon" USING ("public"."check_min_rights"('super_admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow all for auth (super_admin+)" ON "public"."apps" FOR DELETE TO "authenticated", "anon" USING ("public"."check_min_rights"('super_admin'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow delete for auth (admin+) (all apikey)" ON "public"."channels" FOR DELETE TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow delete for auth, api keys (write+)" ON "public"."channel_devices" FOR DELETE TO "authenticated", "anon" USING ("public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow for auth, api keys (read+)" ON "public"."app_versions" FOR SELECT TO "authenticated", "anon" USING ((((( SELECT "auth"."uid"() AS "uid") IS NOT NULL) OR (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL)) AND (EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "uid", + "public"."get_apikey_header"() AS "apikey") "identity" + WHERE ((("identity"."uid" IS NOT NULL) AND "public"."app_versions_has_app_permission"('read'::"public"."user_min_right", "app_versions"."owner_org", "app_versions"."app_id", "identity"."uid", NULL::"text")) OR (("identity"."uid" IS NULL) AND ("identity"."apikey" IS NOT NULL) AND "public"."app_versions_has_app_permission"('read'::"public"."user_min_right", "app_versions"."owner_org", "app_versions"."app_id", NULL::"uuid", "identity"."apikey"))))))); + + + +CREATE POLICY "Allow for auth, api keys (read+)" ON "public"."apps" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow insert for api keys (write,all,upload) (upload+)" ON "public"."app_versions" FOR INSERT TO "anon" WITH CHECK ((EXISTS ( SELECT 1 + FROM ( SELECT "public"."get_apikey_header"() AS "apikey") "identity" + WHERE (("identity"."apikey" IS NOT NULL) AND "public"."app_versions_has_app_permission"('upload'::"public"."user_min_right", "app_versions"."owner_org", "app_versions"."app_id", NULL::"uuid", "identity"."apikey"))))); + + + +CREATE POLICY "Allow insert for apikey (write,all) (admin+)" ON "public"."apps" FOR INSERT TO "authenticated", "anon" WITH CHECK ("public"."rbac_check_permission_request"("public"."rbac_perm_org_create_app"(), "owner_org", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow insert for auth (write+)" ON "public"."channel_devices" FOR INSERT TO "authenticated" WITH CHECK ("public"."check_min_rights"('write'::"public"."user_min_right", ( SELECT "auth"."uid"() AS "uid"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow insert for auth, api keys (write, all) (admin+)" ON "public"."channels" FOR INSERT TO "authenticated", "anon" WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow insert org for user" ON "public"."orgs" FOR INSERT TO "authenticated" WITH CHECK (("created_by" = ( SELECT "public"."get_identity_for_apikey_creation"() AS "get_identity_for_apikey_creation"))); + + + +CREATE POLICY "Allow member and owner to select" ON "public"."org_users" FOR SELECT TO "authenticated", "anon" USING ("public"."is_member_of_org"(( SELECT "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], "org_users"."org_id") AS "get_identity_org_allowed"), "org_id")); + + + +CREATE POLICY "Allow org admin to insert" ON "public"."org_users" FOR INSERT TO "authenticated", "anon" WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", ( SELECT "public"."get_identity_org_allowed"('{all}'::"public"."key_mode"[], "org_users"."org_id") AS "get_identity_org_allowed"), "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow org admin to update" ON "public"."org_users" FOR UPDATE TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", ( SELECT "public"."get_identity_org_allowed"('{all}'::"public"."key_mode"[], "org_users"."org_id") AS "get_identity_org_allowed"), "org_id", NULL::character varying, NULL::bigint)) WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", ( SELECT "public"."get_identity_org_allowed"('{all}'::"public"."key_mode"[], "org_users"."org_id") AS "get_identity_org_allowed"), "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow org delete for super_admin" ON "public"."orgs" FOR DELETE TO "authenticated", "anon" USING (( SELECT "public"."check_min_rights"('super_admin'::"public"."user_min_right", ( SELECT "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], "orgs"."id") AS "get_identity_org_allowed"), "orgs"."id", NULL::character varying, NULL::bigint) AS "check_min_rights")); + + + +CREATE POLICY "Allow org member to insert devices" ON "public"."devices" FOR INSERT TO "authenticated", "anon" WITH CHECK (( SELECT "public"."check_min_rights"('write'::"public"."user_min_right", ( SELECT "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], ( SELECT "public"."get_user_main_org_id_by_app_id"(("devices"."app_id")::"text") AS "get_user_main_org_id_by_app_id"), "devices"."app_id") AS "get_identity_org_appid"), ( SELECT "public"."get_user_main_org_id_by_app_id"(("devices"."app_id")::"text") AS "get_user_main_org_id_by_app_id"), "devices"."app_id", NULL::bigint) AS "check_min_rights")); + + + +CREATE POLICY "Allow org member to select devices" ON "public"."devices" FOR SELECT TO "authenticated", "anon" USING (( SELECT "public"."check_min_rights"('read'::"public"."user_min_right", ( SELECT "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], ( SELECT "public"."get_user_main_org_id_by_app_id"(("devices"."app_id")::"text") AS "get_user_main_org_id_by_app_id"), "devices"."app_id") AS "get_identity_org_appid"), ( SELECT "public"."get_user_main_org_id_by_app_id"(("devices"."app_id")::"text") AS "get_user_main_org_id_by_app_id"), "devices"."app_id", NULL::bigint) AS "check_min_rights")); + + + +CREATE POLICY "Allow org member to select stripe_info" ON "public"."stripe_info" FOR SELECT TO "authenticated", "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."orgs" "o" + WHERE ((("o"."customer_id")::"text" = ("stripe_info"."customer_id")::"text") AND ( SELECT "public"."check_min_rights"('read'::"public"."user_min_right", ( SELECT "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], "o"."id") AS "get_identity_org_allowed"), "o"."id", NULL::character varying, NULL::bigint) AS "check_min_rights"))))); + + + +CREATE POLICY "Allow org member to update devices" ON "public"."devices" FOR UPDATE TO "authenticated", "anon" USING (( SELECT "public"."check_min_rights"('write'::"public"."user_min_right", ( SELECT "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], "public"."get_user_main_org_id_by_app_id"(("devices"."app_id")::"text"), "devices"."app_id") AS "get_identity_org_appid"), ( SELECT "public"."get_user_main_org_id_by_app_id"(("devices"."app_id")::"text") AS "get_user_main_org_id_by_app_id"), "devices"."app_id", NULL::bigint) AS "check_min_rights")) WITH CHECK (( SELECT "public"."check_min_rights"('write'::"public"."user_min_right", ( SELECT "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], "public"."get_user_main_org_id_by_app_id"(("devices"."app_id")::"text"), "devices"."app_id") AS "get_identity_org_appid"), ( SELECT "public"."get_user_main_org_id_by_app_id"(("devices"."app_id")::"text") AS "get_user_main_org_id_by_app_id"), "devices"."app_id", NULL::bigint) AS "check_min_rights")); + + + +CREATE POLICY "Allow org members to select build_logs" ON "public"."build_logs" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], "org_id"), "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow org members to select build_requests" ON "public"."build_requests" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow org members to select daily_build_time" ON "public"."daily_build_time" FOR SELECT TO "authenticated", "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE ((("apps"."app_id")::"text" = ("daily_build_time"."app_id")::"text") AND "public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."owner_org", "apps"."app_id", NULL::bigint))))); + + + +CREATE POLICY "Allow org members to select usage_credit_consumptions" ON "public"."usage_credit_consumptions" FOR SELECT TO "authenticated", "anon" USING (("org_id" = ANY (COALESCE(( SELECT "public"."usage_credit_readable_org_ids"() AS "usage_credit_readable_org_ids"), '{}'::"uuid"[])))); + + + +CREATE POLICY "Allow org members to select usage_credit_grants" ON "public"."usage_credit_grants" FOR SELECT TO "authenticated", "anon" USING (("org_id" = ANY (COALESCE(( SELECT "public"."usage_credit_readable_org_ids"() AS "usage_credit_readable_org_ids"), '{}'::"uuid"[])))); + + + +CREATE POLICY "Allow org members to select usage_credit_transactions" ON "public"."usage_credit_transactions" FOR SELECT TO "authenticated", "anon" USING (("org_id" = ANY (COALESCE(( SELECT "public"."usage_credit_readable_org_ids"() AS "usage_credit_readable_org_ids"), '{}'::"uuid"[])))); + + + +CREATE POLICY "Allow org members to select usage_overage_events" ON "public"."usage_overage_events" FOR SELECT TO "authenticated", "anon" USING (("org_id" = ANY (COALESCE(( SELECT "public"."usage_credit_readable_org_ids"() AS "usage_credit_readable_org_ids"), '{}'::"uuid"[])))); + + + +CREATE POLICY "Allow org members to select webhook_deliveries" ON "public"."webhook_deliveries" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('read'::"public"."user_min_right", +CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") +END, "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow owner to delete own apikeys" ON "public"."apikeys" FOR DELETE TO "authenticated" USING (("user_id" = ( SELECT "auth"."uid"() AS "uid"))); + + + +CREATE POLICY "Allow owner to insert own users" ON "public"."users" FOR INSERT TO "authenticated" WITH CHECK ((("id" = ( SELECT "auth"."uid"() AS "uid")) AND ( SELECT "public"."is_not_deleted"("users"."email") AS "is_not_deleted"))); + + + +CREATE POLICY "Allow owner to select own apikeys" ON "public"."apikeys" FOR SELECT TO "authenticated" USING (("user_id" = ( SELECT "auth"."uid"() AS "uid"))); + + + +CREATE POLICY "Allow owner to select own user" ON "public"."users" FOR SELECT TO "authenticated" USING ((("id" = ( SELECT "auth"."uid"() AS "uid")) AND ( SELECT "public"."is_not_deleted"("users"."email") AS "is_not_deleted"))); + + + +CREATE POLICY "Allow owner to update own apikeys" ON "public"."apikeys" FOR UPDATE TO "authenticated", "anon" USING (("user_id" = ( SELECT "public"."get_identity_for_apikey_creation"() AS "get_identity_for_apikey_creation"))) WITH CHECK (("user_id" = ( SELECT "public"."get_identity_for_apikey_creation"() AS "get_identity_for_apikey_creation"))); + + + +CREATE POLICY "Allow owner to update own users" ON "public"."users" FOR UPDATE TO "authenticated" USING ((("id" = ( SELECT "auth"."uid"() AS "uid")) AND ( SELECT "public"."is_not_deleted"("users"."email") AS "is_not_deleted"))) WITH CHECK ((("id" = ( SELECT "auth"."uid"() AS "uid")) AND ( SELECT "public"."is_not_deleted"("users"."email") AS "is_not_deleted"))); + + + +CREATE POLICY "Allow read for auth (read+)" ON "public"."app_versions_meta" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_bandwidth" FOR SELECT TO "authenticated", "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE ((("apps"."app_id")::"text" = ("daily_bandwidth"."app_id")::"text") AND "public"."check_min_rights"('read'::"public"."user_min_right", + CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") + END, "apps"."owner_org", "daily_bandwidth"."app_id", NULL::bigint))))); + + + +CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_mau" FOR SELECT TO "authenticated", "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE ((("apps"."app_id")::"text" = ("daily_mau"."app_id")::"text") AND "public"."check_min_rights"('read'::"public"."user_min_right", + CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") + END, "apps"."owner_org", "daily_mau"."app_id", NULL::bigint))))); + + + +CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_storage" FOR SELECT TO "authenticated", "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE ((("apps"."app_id")::"text" = ("daily_storage"."app_id")::"text") AND "public"."check_min_rights"('read'::"public"."user_min_right", + CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") + END, "apps"."owner_org", "daily_storage"."app_id", NULL::bigint))))); + + + +CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_storage_hourly" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow read for auth (read+)" ON "public"."daily_version" FOR SELECT TO "authenticated", "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE ((("apps"."app_id")::"text" = ("daily_version"."app_id")::"text") AND "public"."check_min_rights"('read'::"public"."user_min_right", + CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") + END, "apps"."owner_org", "daily_version"."app_id", NULL::bigint))))); + + + +CREATE POLICY "Allow read for auth (read+)" ON "public"."stats" FOR SELECT TO "authenticated", "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE ((("apps"."app_id")::"text" = ("stats"."app_id")::"text") AND "public"."check_min_rights"('read'::"public"."user_min_right", + CASE + WHEN (( SELECT "public"."get_apikey_header"() AS "get_apikey_header") IS NOT NULL) THEN NULL::"uuid" + ELSE ( SELECT "auth"."uid"() AS "uid") + END, "apps"."owner_org", "stats"."app_id", NULL::bigint))))); + + + +CREATE POLICY "Allow read for auth, api keys (read+)" ON "public"."channel_devices" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow select for auth, api keys (read+)" ON "public"."channels" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow select for auth, api keys (read+)" ON "public"."manifest" FOR SELECT TO "authenticated", "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."app_versions" "av" + WHERE (("av"."id" = "manifest"."app_version_id") AND "public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], "av"."owner_org", "av"."app_id"), "av"."owner_org", "av"."app_id", NULL::bigint))))); + + + +CREATE POLICY "Allow select for auth, api keys (read+)" ON "public"."orgs" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], "id"), "id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "Allow select for auth, api keys (super_admin+)" ON "public"."audit_logs" FOR SELECT TO "authenticated", "anon" USING (("org_id" = ANY (COALESCE(( SELECT "public"."audit_logs_allowed_orgs"() AS "audit_logs_allowed_orgs"), '{}'::"uuid"[])))); + + + +CREATE POLICY "Allow service_role full access" ON "public"."usage_credit_consumptions" TO "service_role" USING (true) WITH CHECK (true); + + + +CREATE POLICY "Allow service_role full access" ON "public"."usage_credit_grants" TO "service_role" USING (true) WITH CHECK (true); + + + +CREATE POLICY "Allow service_role full access" ON "public"."usage_credit_transactions" TO "service_role" USING (true) WITH CHECK (true); + + + +CREATE POLICY "Allow service_role full access" ON "public"."usage_overage_events" TO "service_role" USING (true) WITH CHECK (true); + + + +CREATE POLICY "Allow service_role full access to webhook_deliveries" ON "public"."webhook_deliveries" TO "service_role" USING (true) WITH CHECK (true); + + + +CREATE POLICY "Allow service_role full access to webhooks" ON "public"."webhooks" TO "service_role" USING (true) WITH CHECK (true); + + + +CREATE POLICY "Allow to self delete" ON "public"."org_users" FOR DELETE TO "authenticated", "anon" USING (("public"."check_min_rights"('admin'::"public"."user_min_right", ( SELECT "public"."get_identity_org_allowed"('{all}'::"public"."key_mode"[], "org_users"."org_id") AS "get_identity_org_allowed"), "org_id", NULL::character varying, NULL::bigint) OR ("user_id" = ( SELECT "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], "org_users"."org_id") AS "get_identity_org_allowed")))); + + + +CREATE POLICY "Allow update for auth (admin+)" ON "public"."orgs" FOR UPDATE TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_allowed"('{all,write}'::"public"."key_mode"[], "id"), "id", NULL::character varying, NULL::bigint)) WITH CHECK (("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_allowed"('{all,write}'::"public"."key_mode"[], "id"), "id", NULL::character varying, NULL::bigint) AND (("enforcing_2fa" IS NOT TRUE) OR "public"."has_2fa_enabled"()) AND (("password_policy_config" IS NULL) OR (("jsonb_typeof"("password_policy_config") = 'object'::"text") AND ((NOT ("password_policy_config" ? 'min_length'::"text")) OR (("jsonb_typeof"(("password_policy_config" -> 'min_length'::"text")) = 'number'::"text") AND ((("password_policy_config" ->> 'min_length'::"text"))::numeric = "trunc"((("password_policy_config" ->> 'min_length'::"text"))::numeric)) AND (((("password_policy_config" ->> 'min_length'::"text"))::numeric >= (6)::numeric) AND ((("password_policy_config" ->> 'min_length'::"text"))::numeric <= (72)::numeric)))))))); + + + +CREATE POLICY "Allow update for auth and api keys" ON "public"."app_versions" FOR UPDATE TO "authenticated", "anon" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "uid", + "public"."get_apikey_header"() AS "apikey") "identity" + WHERE ((("identity"."uid" IS NOT NULL) AND "public"."app_versions_has_app_permission"('write'::"public"."user_min_right", "app_versions"."owner_org", "app_versions"."app_id", "identity"."uid", NULL::"text")) OR (("identity"."uid" IS NULL) AND ("identity"."apikey" IS NOT NULL) AND "public"."app_versions_has_app_permission"('upload'::"public"."user_min_right", "app_versions"."owner_org", "app_versions"."app_id", NULL::"uuid", "identity"."apikey")))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "uid", + "public"."get_apikey_header"() AS "apikey") "identity" + WHERE ((("identity"."uid" IS NOT NULL) AND "public"."app_versions_has_app_permission"('write'::"public"."user_min_right", "app_versions"."owner_org", "app_versions"."app_id", "identity"."uid", NULL::"text")) OR (("identity"."uid" IS NULL) AND ("identity"."apikey" IS NOT NULL) AND "public"."app_versions_has_app_permission"('upload'::"public"."user_min_right", "app_versions"."owner_org", "app_versions"."app_id", NULL::"uuid", "identity"."apikey")))))); + + + +CREATE POLICY "Allow update for auth, api keys (write+)" ON "public"."channel_devices" FOR UPDATE TO "authenticated", "anon" USING ("public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)) WITH CHECK ("public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow update for auth, api keys (write, all) (admin+)" ON "public"."apps" FOR UPDATE TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)) WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow update for auth, api keys (write, all) (write+)" ON "public"."channels" FOR UPDATE TO "authenticated", "anon" USING ("public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)) WITH CHECK ("public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "owner_org", "app_id"), "owner_org", "app_id", NULL::bigint)); + + + +CREATE POLICY "Allow users to view deploy history for their org" ON "public"."deploy_history" FOR SELECT TO "authenticated" USING (( SELECT (( SELECT "auth"."uid"() AS "uid") IN ( SELECT "org_users"."user_id" + FROM "public"."org_users" + WHERE ("org_users"."org_id" = "deploy_history"."owner_org"))))); + + + +CREATE POLICY "Allow users with write permissions to insert deploy history" ON "public"."deploy_history" FOR INSERT WITH CHECK (false); + + + +CREATE POLICY "Anyone can read capgo_credits_steps" ON "public"."capgo_credits_steps" FOR SELECT USING (true); + + + +CREATE POLICY "Deny all" ON "public"."app_metrics_cache" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny all" ON "public"."org_metrics_cache" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny all access" ON "public"."cron_tasks" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny all access" ON "public"."daily_revenue_metrics" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny all access" ON "public"."processed_stripe_events" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny all access" ON "public"."to_delete_accounts" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny client insert on apikeys" ON "public"."apikeys" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "Deny delete for org members" ON "public"."usage_credit_consumptions" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "Deny delete for org members" ON "public"."usage_credit_grants" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "Deny delete for org members" ON "public"."usage_credit_transactions" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "Deny delete for org members" ON "public"."usage_overage_events" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "Deny delete on apikey_global_permissions" ON "public"."apikey_global_permissions" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "Deny delete on daily_storage_hourly" ON "public"."daily_storage_hourly" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "Deny delete on deploy history" ON "public"."deploy_history" FOR DELETE USING (false); + + + +CREATE POLICY "Deny insert for org members" ON "public"."usage_credit_consumptions" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "Deny insert for org members" ON "public"."usage_credit_grants" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "Deny insert for org members" ON "public"."usage_credit_transactions" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "Deny insert for org members" ON "public"."usage_overage_events" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "Deny insert on apikey_global_permissions" ON "public"."apikey_global_permissions" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "Deny insert on daily_storage_hourly" ON "public"."daily_storage_hourly" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "Deny select on apikey_global_permissions" ON "public"."apikey_global_permissions" AS RESTRICTIVE FOR SELECT TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "Deny update for org members" ON "public"."usage_credit_consumptions" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny update for org members" ON "public"."usage_credit_grants" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny update for org members" ON "public"."usage_credit_transactions" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny update for org members" ON "public"."usage_overage_events" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny update on apikey_global_permissions" ON "public"."apikey_global_permissions" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny update on daily_storage_hourly" ON "public"."daily_storage_hourly" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Deny user access to onboarding demo data" ON "public"."onboarding_demo_data" AS RESTRICTIVE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Disable for all" ON "public"."bandwidth_usage" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Disable for all" ON "public"."device_usage" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Disable for all" ON "public"."notifications" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Disable for all" ON "public"."storage_usage" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Disable for all" ON "public"."tmp_users" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Disable for all" ON "public"."version_meta" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Disable for all" ON "public"."version_usage" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Disallow owner to delete own users" ON "public"."users" FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "Enable select for anyone" ON "public"."plans" FOR SELECT TO "authenticated", "anon" USING (true); + + + +CREATE POLICY "Enable update for users based on email" ON "public"."deleted_account" TO "authenticated" WITH CHECK (("encode"("extensions"."digest"(( SELECT "auth"."email"() AS "email"), 'sha256'::"text"), 'hex'::"text") = ("email")::"text")); + + + +CREATE POLICY "Prevent non 2FA access" ON "public"."apikeys" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa"()); + + + +CREATE POLICY "Prevent non 2FA access" ON "public"."app_versions" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa"()); + + + +CREATE POLICY "Prevent non 2FA access" ON "public"."apps" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa"()); + + + +CREATE POLICY "Prevent non 2FA access" ON "public"."channel_devices" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa"()); + + + +CREATE POLICY "Prevent non 2FA access" ON "public"."channels" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa"()); + + + +CREATE POLICY "Prevent non 2FA access" ON "public"."org_users" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa"()); + + + +CREATE POLICY "Prevent non 2FA access" ON "public"."orgs" AS RESTRICTIVE TO "authenticated" USING ("public"."verify_mfa"()); + + + +CREATE POLICY "Prevent update on deploy history" ON "public"."deploy_history" FOR UPDATE USING (false) WITH CHECK (false); + + + +CREATE POLICY "Prevent users from deleting manifest entries" ON "public"."manifest" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "Prevent users from inserting manifest entries" ON "public"."manifest" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "Prevent users from updating manifest entries" ON "public"."manifest" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "Service role manages build logs" ON "public"."build_logs" TO "service_role" USING (true) WITH CHECK (true); + + + +CREATE POLICY "Service role manages build requests" ON "public"."build_requests" TO "service_role" USING (true) WITH CHECK (true); + + + +CREATE POLICY "Service role manages build time" ON "public"."daily_build_time" TO "service_role" USING (true) WITH CHECK (true); + + + +CREATE POLICY "Users can read own password compliance" ON "public"."user_password_compliance" FOR SELECT TO "authenticated" USING (("user_id" = ( SELECT "auth"."uid"() AS "uid"))); + + + +CREATE POLICY "allow_org_admins_insert_sso_providers" ON "public"."sso_providers" FOR INSERT TO "authenticated", "anon" WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_allowed"('{write,all}'::"public"."key_mode"[], "org_id"), "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "allow_org_admins_select_sso_providers" ON "public"."sso_providers" FOR SELECT TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], "org_id"), "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "allow_org_admins_update_sso_providers" ON "public"."sso_providers" FOR UPDATE TO "authenticated", "anon" USING ("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_allowed"('{write,all}'::"public"."key_mode"[], "org_id"), "org_id", NULL::character varying, NULL::bigint)) WITH CHECK ("public"."check_min_rights"('admin'::"public"."user_min_right", "public"."get_identity_org_allowed"('{write,all}'::"public"."key_mode"[], "org_id"), "org_id", NULL::character varying, NULL::bigint)); + + + +CREATE POLICY "allow_org_super_admins_delete_sso_providers" ON "public"."sso_providers" FOR DELETE TO "authenticated", "anon" USING ("public"."check_min_rights"('super_admin'::"public"."user_min_right", "public"."get_identity_org_allowed"('{all}'::"public"."key_mode"[], "org_id"), "org_id", NULL::character varying, NULL::bigint)); + + + +ALTER TABLE "public"."apikey_global_permissions" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."apikeys" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."app_metrics_cache" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."app_versions" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."app_versions_meta" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."apps" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."audit_logs" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."bandwidth_usage" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."build_logs" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."build_requests" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."capgo_credits_steps" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."channel_devices" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."channel_permission_overrides" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "channel_permission_overrides_admin_delete" ON "public"."channel_permission_overrides" FOR DELETE TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ("public"."channels" + JOIN "public"."apps" ON ((("channels"."app_id")::"text" = ("apps"."app_id")::"text"))) + WHERE (("channels"."id" = "channel_permission_overrides"."channel_id") AND "public"."rbac_check_permission"("public"."rbac_perm_app_update_user_roles"(), "apps"."owner_org", "apps"."app_id", NULL::bigint))))); + + + +COMMENT ON POLICY "channel_permission_overrides_admin_delete" ON "public"."channel_permission_overrides" IS 'Authenticated app admins can delete channel permission overrides.'; + + + +CREATE POLICY "channel_permission_overrides_admin_insert" ON "public"."channel_permission_overrides" FOR INSERT TO "authenticated" WITH CHECK ((EXISTS ( SELECT 1 + FROM ("public"."channels" + JOIN "public"."apps" ON ((("channels"."app_id")::"text" = ("apps"."app_id")::"text"))) + WHERE (("channels"."id" = "channel_permission_overrides"."channel_id") AND "public"."rbac_check_permission"("public"."rbac_perm_app_update_user_roles"(), "apps"."owner_org", "apps"."app_id", NULL::bigint))))); + + + +COMMENT ON POLICY "channel_permission_overrides_admin_insert" ON "public"."channel_permission_overrides" IS 'Authenticated app admins can insert channel permission overrides.'; + + + +CREATE POLICY "channel_permission_overrides_admin_select" ON "public"."channel_permission_overrides" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ("public"."channels" + JOIN "public"."apps" ON ((("channels"."app_id")::"text" = ("apps"."app_id")::"text"))) + WHERE (("channels"."id" = "channel_permission_overrides"."channel_id") AND "public"."rbac_check_permission"("public"."rbac_perm_app_update_user_roles"(), "apps"."owner_org", "apps"."app_id", NULL::bigint))))); + + + +COMMENT ON POLICY "channel_permission_overrides_admin_select" ON "public"."channel_permission_overrides" IS 'Authenticated app admins can read channel permission overrides. Single SELECT policy to avoid multiple permissive policies.'; + + + +CREATE POLICY "channel_permission_overrides_admin_update" ON "public"."channel_permission_overrides" FOR UPDATE TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ("public"."channels" + JOIN "public"."apps" ON ((("channels"."app_id")::"text" = ("apps"."app_id")::"text"))) + WHERE (("channels"."id" = "channel_permission_overrides"."channel_id") AND "public"."rbac_check_permission"("public"."rbac_perm_app_update_user_roles"(), "apps"."owner_org", "apps"."app_id", NULL::bigint))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM ("public"."channels" + JOIN "public"."apps" ON ((("channels"."app_id")::"text" = ("apps"."app_id")::"text"))) + WHERE (("channels"."id" = "channel_permission_overrides"."channel_id") AND "public"."rbac_check_permission"("public"."rbac_perm_app_update_user_roles"(), "apps"."owner_org", "apps"."app_id", NULL::bigint))))); + + + +COMMENT ON POLICY "channel_permission_overrides_admin_update" ON "public"."channel_permission_overrides" IS 'Authenticated app admins can update channel permission overrides.'; + + + +ALTER TABLE "public"."channels" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."compatibility_events" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "compatibility_events_deny_delete" ON "public"."compatibility_events" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "compatibility_events_deny_insert" ON "public"."compatibility_events" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "compatibility_events_deny_update" ON "public"."compatibility_events" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "compatibility_events_select" ON "public"."compatibility_events" FOR SELECT TO "authenticated" USING ("public"."rbac_check_permission"("public"."rbac_perm_app_read"(), "org_id", ("app_id")::character varying, NULL::bigint)); + + + +ALTER TABLE "public"."cron_tasks" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."daily_bandwidth" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."daily_build_time" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."daily_mau" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."daily_revenue_metrics" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."daily_storage" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."daily_storage_hourly" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."daily_version" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."deleted_account" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."deleted_apps" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "deny_all_access" ON "public"."deleted_apps" USING (false) WITH CHECK (false); + + + +CREATE POLICY "deny_direct_delete_on_webhook_deliveries" ON "public"."webhook_deliveries" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "deny_direct_delete_on_webhooks" ON "public"."webhooks" AS RESTRICTIVE FOR DELETE TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "deny_direct_insert_on_webhook_deliveries" ON "public"."webhook_deliveries" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "deny_direct_insert_on_webhooks" ON "public"."webhooks" AS RESTRICTIVE FOR INSERT TO "authenticated", "anon" WITH CHECK (false); + + + +CREATE POLICY "deny_direct_select_on_webhook_deliveries" ON "public"."webhook_deliveries" AS RESTRICTIVE FOR SELECT TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "deny_direct_select_on_webhooks" ON "public"."webhooks" AS RESTRICTIVE FOR SELECT TO "authenticated", "anon" USING (false); + + + +CREATE POLICY "deny_direct_update_on_webhook_deliveries" ON "public"."webhook_deliveries" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +CREATE POLICY "deny_direct_update_on_webhooks" ON "public"."webhooks" AS RESTRICTIVE FOR UPDATE TO "authenticated", "anon" USING (false) WITH CHECK (false); + + + +ALTER TABLE "public"."deploy_history" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."device_usage" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."devices" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."global_stats" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."group_members" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "group_members_delete" ON "public"."group_members" FOR DELETE TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE (EXISTS ( SELECT 1 + FROM "public"."groups" + WHERE (("groups"."id" = "group_members"."group_id") AND "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "groups"."org_id", NULL::character varying, NULL::bigint))))))); + + + +CREATE POLICY "group_members_insert" ON "public"."group_members" FOR INSERT TO "authenticated" WITH CHECK ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE (EXISTS ( SELECT 1 + FROM "public"."groups" + WHERE (("groups"."id" = "group_members"."group_id") AND "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "groups"."org_id", NULL::character varying, NULL::bigint))))))); + + + +CREATE POLICY "group_members_select" ON "public"."group_members" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE (EXISTS ( SELECT 1 + FROM ("public"."groups" + JOIN "public"."org_users" ON (("groups"."org_id" = "org_users"."org_id"))) + WHERE (("groups"."id" = "group_members"."group_id") AND ("org_users"."user_id" = "actor_ref"."current_uid"))))))); + + + +CREATE POLICY "group_members_update" ON "public"."group_members" FOR UPDATE TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE (EXISTS ( SELECT 1 + FROM "public"."groups" + WHERE (("groups"."id" = "group_members"."group_id") AND "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "groups"."org_id", NULL::character varying, NULL::bigint))))))); + + + +ALTER TABLE "public"."groups" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "groups_delete" ON "public"."groups" FOR DELETE TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "groups"."org_id", NULL::character varying, NULL::bigint)))); + + + +CREATE POLICY "groups_insert" ON "public"."groups" FOR INSERT TO "authenticated" WITH CHECK ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "groups"."org_id", NULL::character varying, NULL::bigint)))); + + + +CREATE POLICY "groups_select" ON "public"."groups" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE (EXISTS ( SELECT 1 + FROM "public"."org_users" + WHERE (("org_users"."org_id" = "groups"."org_id") AND ("org_users"."user_id" = "actor_ref"."current_uid"))))))); + + + +CREATE POLICY "groups_update" ON "public"."groups" FOR UPDATE TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "groups"."org_id", NULL::character varying, NULL::bigint)))); + + + +ALTER TABLE "public"."manifest" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."notifications" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."onboarding_demo_data" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."org_metrics_cache" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."org_users" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."orgs" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."permissions" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "permissions_delete" ON "public"."permissions" FOR DELETE TO "authenticated" USING (false); + + + +CREATE POLICY "permissions_insert" ON "public"."permissions" FOR INSERT TO "authenticated" WITH CHECK (false); + + + +CREATE POLICY "permissions_select" ON "public"."permissions" FOR SELECT TO "authenticated" USING (true); + + + +COMMENT ON POLICY "permissions_select" ON "public"."permissions" IS 'All authenticated users can read permissions. Single SELECT policy to avoid multiple permissive policies.'; + + + +CREATE POLICY "permissions_update" ON "public"."permissions" FOR UPDATE TO "authenticated" USING (false); + + + +ALTER TABLE "public"."plans" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."processed_stripe_events" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."role_bindings" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "role_bindings_delete" ON "public"."role_bindings" FOR DELETE TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE ((("role_bindings"."scope_type" = "public"."rbac_scope_org"()) AND "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "role_bindings"."org_id", NULL::character varying, NULL::bigint)) OR (("role_bindings"."scope_type" = "public"."rbac_scope_app"()) AND (EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE (("apps"."id" = "role_bindings"."app_id") AND "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "apps"."owner_org", "apps"."app_id", NULL::bigint))))) OR (("role_bindings"."scope_type" = "public"."rbac_scope_channel"()) AND (EXISTS ( SELECT 1 + FROM ("public"."channels" + JOIN "public"."apps" ON ((("channels"."app_id")::"text" = ("apps"."app_id")::"text"))) + WHERE (("channels"."rbac_id" = "role_bindings"."channel_id") AND "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "apps"."owner_org", "channels"."app_id", "channels"."id"))))) OR (("role_bindings"."scope_type" = "public"."rbac_scope_app"()) AND "public"."user_has_app_update_user_roles"("actor_ref"."current_uid", "role_bindings"."app_id")) OR (("role_bindings"."scope_type" = "public"."rbac_scope_app"()) AND ("role_bindings"."principal_type" = "public"."rbac_principal_user"()) AND ("role_bindings"."principal_id" = "actor_ref"."current_uid")))))); + + + +CREATE POLICY "role_bindings_insert" ON "public"."role_bindings" FOR INSERT TO "authenticated" WITH CHECK ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE ((("role_bindings"."scope_type" = "public"."rbac_scope_org"()) AND "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "role_bindings"."org_id", NULL::character varying, NULL::bigint)) OR (("role_bindings"."scope_type" = "public"."rbac_scope_app"()) AND (EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE (("apps"."id" = "role_bindings"."app_id") AND ("public"."check_min_rights"("public"."rbac_right_admin"(), "public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."owner_org", "apps"."app_id", NULL::bigint) OR "public"."user_has_app_update_user_roles"("public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."id")))))) OR (("role_bindings"."scope_type" = "public"."rbac_scope_channel"()) AND (EXISTS ( SELECT 1 + FROM ("public"."channels" + JOIN "public"."apps" ON ((("channels"."app_id")::"text" = ("apps"."app_id")::"text"))) + WHERE (("channels"."rbac_id" = "role_bindings"."channel_id") AND "public"."check_min_rights"("public"."rbac_right_admin"(), "public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."owner_org", "channels"."app_id", "channels"."id"))))))))); + + + +CREATE POLICY "role_bindings_select" ON "public"."role_bindings" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE ("public"."is_user_org_admin"("actor_ref"."current_uid", "role_bindings"."org_id") OR (("role_bindings"."scope_type" = "public"."rbac_scope_app"()) AND "public"."is_user_app_admin"("actor_ref"."current_uid", "role_bindings"."app_id")) OR (("role_bindings"."scope_type" = "public"."rbac_scope_app"()) AND ("role_bindings"."app_id" IS NOT NULL) AND "public"."user_has_role_in_app"("actor_ref"."current_uid", "role_bindings"."app_id")) OR (("role_bindings"."scope_type" = "public"."rbac_scope_channel"()) AND ("role_bindings"."channel_id" IS NOT NULL) AND (EXISTS ( SELECT 1 + FROM ("public"."channels" "c" + JOIN "public"."apps" "a" ON ((("c"."app_id")::"text" = ("a"."app_id")::"text"))) + WHERE (("c"."rbac_id" = "role_bindings"."channel_id") AND "public"."is_user_app_admin"("actor_ref"."current_uid", "a"."id"))))))))); + + + +CREATE POLICY "role_bindings_update" ON "public"."role_bindings" FOR UPDATE TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "current_uid") "actor_ref" + WHERE ((("role_bindings"."scope_type" = "public"."rbac_scope_org"()) AND "public"."check_min_rights"("public"."rbac_right_admin"(), "actor_ref"."current_uid", "role_bindings"."org_id", NULL::character varying, NULL::bigint)) OR (("role_bindings"."scope_type" = "public"."rbac_scope_app"()) AND (EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE (("apps"."id" = "role_bindings"."app_id") AND ("public"."check_min_rights"("public"."rbac_right_admin"(), "public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."owner_org", "apps"."app_id", NULL::bigint) OR "public"."user_has_app_update_user_roles"("public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."id")))))) OR (("role_bindings"."scope_type" = "public"."rbac_scope_channel"()) AND (EXISTS ( SELECT 1 + FROM ("public"."channels" + JOIN "public"."apps" ON ((("channels"."app_id")::"text" = ("apps"."app_id")::"text"))) + WHERE (("channels"."rbac_id" = "role_bindings"."channel_id") AND "public"."check_min_rights"("public"."rbac_right_admin"(), "public"."get_identity_org_appid"('{all}'::"public"."key_mode"[], "apps"."owner_org", "apps"."app_id"), "apps"."owner_org", "channels"."app_id", "channels"."id"))))))))); + + + +ALTER TABLE "public"."role_hierarchy" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "role_hierarchy_delete" ON "public"."role_hierarchy" FOR DELETE TO "authenticated" USING (false); + + + +CREATE POLICY "role_hierarchy_insert" ON "public"."role_hierarchy" FOR INSERT TO "authenticated" WITH CHECK (false); + + + +CREATE POLICY "role_hierarchy_select" ON "public"."role_hierarchy" FOR SELECT TO "authenticated" USING (true); + + + +COMMENT ON POLICY "role_hierarchy_select" ON "public"."role_hierarchy" IS 'All authenticated users can read role_hierarchy. Single SELECT policy to avoid multiple permissive policies.'; + + + +CREATE POLICY "role_hierarchy_update" ON "public"."role_hierarchy" FOR UPDATE TO "authenticated" USING (false); + + + +ALTER TABLE "public"."role_permissions" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "role_permissions_delete" ON "public"."role_permissions" FOR DELETE TO "authenticated" USING (false); + + + +CREATE POLICY "role_permissions_insert" ON "public"."role_permissions" FOR INSERT TO "authenticated" WITH CHECK (false); + + + +CREATE POLICY "role_permissions_select" ON "public"."role_permissions" FOR SELECT TO "authenticated" USING (true); + + + +COMMENT ON POLICY "role_permissions_select" ON "public"."role_permissions" IS 'All authenticated users can read role_permissions. Single SELECT policy to avoid multiple permissive policies.'; + + + +CREATE POLICY "role_permissions_update" ON "public"."role_permissions" FOR UPDATE TO "authenticated" USING (false); + + + +ALTER TABLE "public"."roles" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "roles_delete" ON "public"."roles" FOR DELETE TO "authenticated" USING (false); + + + +CREATE POLICY "roles_insert" ON "public"."roles" FOR INSERT TO "authenticated" WITH CHECK (false); + + + +CREATE POLICY "roles_select" ON "public"."roles" FOR SELECT TO "authenticated" USING (true); + + + +COMMENT ON POLICY "roles_select" ON "public"."roles" IS 'All authenticated users can read roles. Single SELECT policy to avoid multiple permissive policies.'; + + + +CREATE POLICY "roles_update" ON "public"."roles" FOR UPDATE TO "authenticated" USING (false); + + + +ALTER TABLE "public"."sso_providers" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."stats" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."storage_usage" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."stripe_info" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."tmp_users" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."to_delete_accounts" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."usage_credit_consumptions" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."usage_credit_grants" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."usage_credit_transactions" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."usage_overage_events" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."user_password_compliance" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."user_security" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."users" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "users_can_read_own_security_status" ON "public"."user_security" FOR SELECT TO "authenticated" USING (("user_id" = ( SELECT "auth"."uid"() AS "uid"))); + + + +ALTER TABLE "public"."version_meta" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."version_usage" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."webhook_deliveries" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."webhooks" ENABLE ROW LEVEL SECURITY; + + + + +ALTER PUBLICATION "supabase_realtime" OWNER TO "postgres"; + + +GRANT USAGE ON SCHEMA "capgo_private" TO "anon"; +GRANT USAGE ON SCHEMA "capgo_private" TO "authenticated"; +GRANT USAGE ON SCHEMA "capgo_private" TO "service_role"; + + + + + + + + + +REVOKE USAGE ON SCHEMA "public" FROM PUBLIC; +GRANT USAGE ON SCHEMA "public" TO "anon"; +GRANT USAGE ON SCHEMA "public" TO "authenticated"; +GRANT USAGE ON SCHEMA "public" TO "service_role"; + + + +REVOKE ALL ON FUNCTION "capgo_private"."matches_app_storage_apikey_owner"("folder_user_id" "text", "target_app_id" character varying, "keymode" "public"."key_mode"[]) FROM PUBLIC; +GRANT ALL ON FUNCTION "capgo_private"."matches_app_storage_apikey_owner"("folder_user_id" "text", "target_app_id" character varying, "keymode" "public"."key_mode"[]) TO "anon"; +GRANT ALL ON FUNCTION "capgo_private"."matches_app_storage_apikey_owner"("folder_user_id" "text", "target_app_id" character varying, "keymode" "public"."key_mode"[]) TO "authenticated"; +GRANT ALL ON FUNCTION "capgo_private"."matches_app_storage_apikey_owner"("folder_user_id" "text", "target_app_id" character varying, "keymode" "public"."key_mode"[]) TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +REVOKE ALL ON FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."acknowledge_compatibility_event"("event_id" bigint, "note" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."acknowledge_compatibility_event"("event_id" bigint, "note" "text") TO "service_role"; +GRANT ALL ON FUNCTION "public"."acknowledge_compatibility_event"("event_id" bigint, "note" "text") TO "authenticated"; + + + +GRANT ALL ON FUNCTION "public"."aggregate_build_log_to_daily"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."apikey_has_current_org_create_capability"("p_apikey_rbac_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."apikey_has_current_org_create_capability"("p_apikey_rbac_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."apikey_has_global_permission"("p_apikey" "text", "p_permission_key" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."apikey_has_global_permission"("p_apikey" "text", "p_permission_key" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."apikey_permission_for_keymode"("keymode" "public"."key_mode"[], "scope_type" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."apikey_permission_for_keymode"("keymode" "public"."key_mode"[], "scope_type" "text") TO "service_role"; +GRANT ALL ON FUNCTION "public"."apikey_permission_for_keymode"("keymode" "public"."key_mode"[], "scope_type" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."apikey_permission_for_keymode"("keymode" "public"."key_mode"[], "scope_type" "text") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."apikeys_force_server_key"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."apikeys_force_server_key"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."apikeys_strip_plain_key_for_hashed"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."apikeys_strip_plain_key_for_hashed"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."app_versions_has_app_permission"("p_min_right" "public"."user_min_right", "p_owner_org" "uuid", "p_app_id" character varying, "p_user_id" "uuid", "p_apikey" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."app_versions_has_app_permission"("p_min_right" "public"."user_min_right", "p_owner_org" "uuid", "p_app_id" character varying, "p_user_id" "uuid", "p_apikey" "text") TO "service_role"; +GRANT ALL ON FUNCTION "public"."app_versions_has_app_permission"("p_min_right" "public"."user_min_right", "p_owner_org" "uuid", "p_app_id" character varying, "p_user_id" "uuid", "p_apikey" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."app_versions_has_app_permission"("p_min_right" "public"."user_min_right", "p_owner_org" "uuid", "p_app_id" character varying, "p_user_id" "uuid", "p_apikey" "text") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."app_versions_readable_app_ids"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."app_versions_readable_app_ids"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."app_versions_readable_app_ids"() TO "anon"; +GRANT ALL ON FUNCTION "public"."app_versions_readable_app_ids"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."apply_usage_overage"("p_org_id" "uuid", "p_metric" "public"."credit_metric_type", "p_overage_amount" numeric, "p_billing_cycle_start" timestamp with time zone, "p_billing_cycle_end" timestamp with time zone, "p_details" "jsonb") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."apply_usage_overage"("p_org_id" "uuid", "p_metric" "public"."credit_metric_type", "p_overage_amount" numeric, "p_billing_cycle_start" timestamp with time zone, "p_billing_cycle_end" timestamp with time zone, "p_details" "jsonb") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."audit_log_trigger"() FROM PUBLIC; + + + +GRANT ALL ON FUNCTION "public"."audit_logs_allowed_orgs"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."audit_logs_allowed_orgs"() TO "anon"; +GRANT ALL ON FUNCTION "public"."audit_logs_allowed_orgs"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."auto_apikey_name_by_id"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."auto_owner_org_by_app_id"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."bind_creating_apikey_to_org_on_create"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."bind_creating_apikey_to_org_on_create"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."calculate_credit_cost"("p_metric" "public"."credit_metric_type", "p_overage_amount" numeric) FROM PUBLIC; + + + +GRANT ALL ON TABLE "public"."org_metrics_cache" TO "anon"; +GRANT ALL ON TABLE "public"."org_metrics_cache" TO "authenticated"; +GRANT ALL ON TABLE "public"."org_metrics_cache" TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."calculate_org_metrics_cache_entry"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") FROM PUBLIC; + + + +GRANT ALL ON TABLE "public"."apikeys" TO "anon"; +GRANT ALL ON TABLE "public"."apikeys" TO "authenticated"; +GRANT ALL ON TABLE "public"."apikeys" TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_apikey_hashed_key_enforcement"("apikey_row" "public"."apikeys") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_domain_sso"("p_domain" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_domain_sso"("p_domain" "text") TO "service_role"; +GRANT ALL ON FUNCTION "public"."check_domain_sso"("p_domain" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."check_domain_sso"("p_domain" "text") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."check_encrypted_bundle_on_insert"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_encrypted_bundle_on_insert"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_if_org_can_exist"() FROM PUBLIC; + + + +GRANT ALL ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "anon"; +GRANT ALL ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "anon"; +GRANT ALL ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."check_min_rights"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_min_rights_legacy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_min_rights_legacy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "anon"; +GRANT ALL ON FUNCTION "public"."check_min_rights_legacy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."check_min_rights_legacy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_min_rights_legacy_no_password_policy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_min_rights_legacy_no_password_policy"("min_right" "public"."user_min_right", "user_id" "uuid", "org_id" "uuid", "app_id" character varying, "channel_id" bigint) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."check_org_encrypted_bundle_enforcement"("org_id" "uuid", "session_key" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."check_org_encrypted_bundle_enforcement"("org_id" "uuid", "session_key" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."check_org_encrypted_bundle_enforcement"("org_id" "uuid", "session_key" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_org_hashed_key_enforcement"("org_id" "uuid", "apikey_row" "public"."apikeys") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_org_hashed_key_enforcement"("org_id" "uuid", "apikey_row" "public"."apikeys") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."check_org_user_privileges"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."claim_legacy_onboarding_demo_data"("p_app_uuid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."claim_legacy_onboarding_demo_data"("p_app_uuid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_apikey_role_bindings"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cleanup_apikey_role_bindings"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_expired_apikeys"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_expired_demo_apps"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cleanup_expired_demo_apps"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_frequent_job_details"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_job_run_details_7days"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_old_audit_logs"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cleanup_old_audit_logs"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_old_channel_devices"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cleanup_old_channel_devices"() TO "anon"; +GRANT ALL ON FUNCTION "public"."cleanup_old_channel_devices"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."cleanup_old_channel_devices"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cleanup_onboarding_app_data_on_complete"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_queue_messages"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_tmp_users"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cleanup_tmp_users"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."cleanup_webhook_deliveries"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."cli_check_permission"("apikey" "text", "permission_key" "text", "org_id" "uuid", "app_id" "text", "channel_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cli_check_permission"("apikey" "text", "permission_key" "text", "org_id" "uuid", "app_id" "text", "channel_id" bigint) TO "service_role"; +GRANT ALL ON FUNCTION "public"."cli_check_permission"("apikey" "text", "permission_key" "text", "org_id" "uuid", "app_id" "text", "channel_id" bigint) TO "anon"; +GRANT ALL ON FUNCTION "public"."cli_check_permission"("apikey" "text", "permission_key" "text", "org_id" "uuid", "app_id" "text", "channel_id" bigint) TO "authenticated"; + + + +GRANT ALL ON FUNCTION "public"."convert_bytes_to_gb"("bytes_value" double precision) TO "anon"; +GRANT ALL ON FUNCTION "public"."convert_bytes_to_gb"("bytes_value" double precision) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."convert_bytes_to_gb"("bytes_value" double precision) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."convert_bytes_to_mb"("bytes_value" double precision) TO "anon"; +GRANT ALL ON FUNCTION "public"."convert_bytes_to_mb"("bytes_value" double precision) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."convert_bytes_to_mb"("bytes_value" double precision) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."convert_gb_to_bytes"("gb" double precision) TO "anon"; +GRANT ALL ON FUNCTION "public"."convert_gb_to_bytes"("gb" double precision) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."convert_gb_to_bytes"("gb" double precision) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."convert_mb_to_bytes"("gb" double precision) TO "anon"; +GRANT ALL ON FUNCTION "public"."convert_mb_to_bytes"("gb" double precision) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."convert_mb_to_bytes"("gb" double precision) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."convert_number_to_percent"("val" double precision, "max_val" double precision) TO "anon"; +GRANT ALL ON FUNCTION "public"."convert_number_to_percent"("val" double precision, "max_val" double precision) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."convert_number_to_percent"("val" double precision, "max_val" double precision) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."count_active_users"("app_ids" character varying[]) TO "anon"; +GRANT ALL ON FUNCTION "public"."count_active_users"("app_ids" character varying[]) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."count_active_users"("app_ids" character varying[]) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."count_all_need_upgrade"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."count_all_need_upgrade"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."count_all_onboarded"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."count_all_onboarded"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."count_all_plans_v2"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."count_all_plans_v2"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."count_non_compliant_bundles"("org_id" "uuid", "required_key" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."count_non_compliant_bundles"("org_id" "uuid", "required_key" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."count_non_compliant_bundles"("org_id" "uuid", "required_key" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."current_request_role"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."delete_accounts_marked_for_deletion"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."delete_group_with_bindings"("group_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."delete_group_with_bindings"("group_id" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."delete_group_with_bindings"("group_id" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."delete_http_response"("request_id" bigint) FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."delete_non_compliant_bundles"("org_id" "uuid", "required_key" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."delete_non_compliant_bundles"("org_id" "uuid", "required_key" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."delete_non_compliant_bundles"("org_id" "uuid", "required_key" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."delete_old_deleted_apps"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."delete_old_deleted_versions"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."delete_old_deleted_versions"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."delete_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."delete_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."delete_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."delete_user"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."delete_user"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."delete_user"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."enforce_apikey_expiration_policy"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."enforce_apikey_expiration_policy"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."enforce_apikey_role_binding_expiration_policy"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."enforce_channel_version_promotion_permission"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."enforce_channel_version_promotion_permission"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."enforce_email_otp_for_mfa"() FROM PUBLIC; + + + +GRANT ALL ON FUNCTION "public"."enforce_role_binding_role_scope"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."enqueue_channel_device_counts"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."enqueue_credit_usage_alert"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."exist_app_v2"("appid" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."exist_app_v2"("appid" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."exist_app_v2"("appid" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."exist_app_v2"("appid" character varying) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."exist_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."expire_usage_credits"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."find_apikey_by_value"("key_value" "text") TO "service_role"; + + + + + + + + + +REVOKE ALL ON FUNCTION "public"."force_valid_user_id_on_app"() FROM PUBLIC; + + + +GRANT ALL ON FUNCTION "public"."generate_org_on_user_create"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() TO "service_role"; + + + +GRANT ALL ON TABLE "public"."apps" TO "anon"; +GRANT ALL ON TABLE "public"."apps" TO "authenticated"; +GRANT ALL ON TABLE "public"."apps" TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_accessible_apps_for_apikey_v2"("apikey" "text") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_account_removal_date"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_account_removal_date"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_account_removal_date"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_apikey"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_apikey"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_apikey_header"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_apikey_header"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_apikey_header"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_app_access_rbac"("p_app_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_app_access_rbac"("p_app_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_app_access_rbac"("p_app_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_app_metrics"("org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_app_metrics"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_app_metrics"("org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_app_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_app_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_app_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_app_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_app_metrics"("p_org_id" "uuid", "p_app_id" character varying, "p_start_date" "date", "p_end_date" "date") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_app_metrics"("p_org_id" "uuid", "p_app_id" character varying, "p_start_date" "date", "p_end_date" "date") TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_app_metrics"("p_org_id" "uuid", "p_app_id" character varying, "p_start_date" "date", "p_end_date" "date") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_app_metrics"("p_org_id" "uuid", "p_app_id" character varying, "p_start_date" "date", "p_end_date" "date") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_app_versions"("appid" character varying, "name_version" character varying, "apikey" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_current_plan_max_org"("orgid" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_current_plan_name_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_current_plan_name_org"("orgid" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_current_plan_name_org"("orgid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_customer_counts"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_customer_counts"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_cycle_info_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_cycle_info_org"("orgid" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_cycle_info_org"("orgid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_db_url"() FROM PUBLIC; + + + +GRANT ALL ON FUNCTION "public"."get_global_metrics"("org_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_global_metrics"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_global_metrics"("org_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_global_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_global_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_global_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_identity"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_identity"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_identity"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_identity"("keymode" "public"."key_mode"[]) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_identity"("keymode" "public"."key_mode"[]) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_identity"("keymode" "public"."key_mode"[]) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_identity"("keymode" "public"."key_mode"[]) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_identity_apikey_only"("keymode" "public"."key_mode"[]) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_identity_apikey_only"("keymode" "public"."key_mode"[]) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_identity_for_apikey_creation"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_identity_for_apikey_creation"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_identity_for_apikey_creation"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_identity_for_apikey_creation"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_identity_org_allowed"("keymode" "public"."key_mode"[], "org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_identity_org_allowed"("keymode" "public"."key_mode"[], "org_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_identity_org_allowed"("keymode" "public"."key_mode"[], "org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_identity_org_allowed"("keymode" "public"."key_mode"[], "org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_identity_org_allowed_apikey_only"("keymode" "public"."key_mode"[], "org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_identity_org_allowed_apikey_only"("keymode" "public"."key_mode"[], "org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_identity_org_appid"("keymode" "public"."key_mode"[], "org_id" "uuid", "app_id" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_identity_org_appid"("keymode" "public"."key_mode"[], "org_id" "uuid", "app_id" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_identity_org_appid"("keymode" "public"."key_mode"[], "org_id" "uuid", "app_id" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_identity_org_appid"("keymode" "public"."key_mode"[], "org_id" "uuid", "app_id" character varying) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_invite_by_magic_lookup"("lookup" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_invite_by_magic_lookup"("lookup" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_invite_by_magic_lookup"("lookup" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_mfa_email_otp_enforced_at"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_next_cron_time"("p_schedule" "text", "p_timestamp" timestamp with time zone) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_next_cron_time"("p_schedule" "text", "p_timestamp" timestamp with time zone) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_next_cron_time"("p_schedule" "text", "p_timestamp" timestamp with time zone) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_next_cron_value"("pattern" "text", "current_val" integer, "max_val" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_next_cron_value"("pattern" "text", "current_val" integer, "max_val" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_next_cron_value"("pattern" "text", "current_val" integer, "max_val" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_next_stats_update_date"("org" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_next_stats_update_date"("org" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_next_stats_update_date"("org" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_org_apikeys"("p_org_id" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_org_apps_with_last_upload"("p_org_id" "uuid", "p_search" "text", "p_sort_by" "text", "p_sort_desc" boolean, "p_limit" integer, "p_offset" integer) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_org_apps_with_last_upload"("p_org_id" "uuid", "p_search" "text", "p_sort_by" "text", "p_sort_desc" boolean, "p_limit" integer, "p_offset" integer) TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_org_apps_with_last_upload"("p_org_id" "uuid", "p_search" "text", "p_sort_by" "text", "p_sort_desc" boolean, "p_limit" integer, "p_offset" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_org_apps_with_last_upload"("p_org_id" "uuid", "p_search" "text", "p_sort_by" "text", "p_sort_desc" boolean, "p_limit" integer, "p_offset" integer) TO "authenticated"; + + + +GRANT ALL ON FUNCTION "public"."get_org_build_time_unit"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_org_build_time_unit"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_org_build_time_unit"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_org_members"("guild_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_org_members"("guild_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_org_members"("guild_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_org_members"("user_id" "uuid", "guild_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_org_members"("user_id" "uuid", "guild_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_org_members"("user_id" "uuid", "guild_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_org_members"("user_id" "uuid", "guild_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_org_owner_id"("apikey" "text", "app_id" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_org_owner_id"("apikey" "text", "app_id" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_org_owner_id"("apikey" "text", "app_id" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_org_perm_for_apikey"("apikey" "text", "app_id" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_org_perm_for_apikey"("apikey" "text", "app_id" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_org_perm_for_apikey"("apikey" "text", "app_id" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_org_perm_for_apikey_v2"("apikey" "text", "app_id" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_org_user_access_rbac"("p_user_id" "uuid", "p_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_org_user_access_rbac"("p_user_id" "uuid", "p_org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_org_user_access_rbac"("p_user_id" "uuid", "p_org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" "uuid", "cli_version" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_orgs_v6"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_orgs_v6"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_orgs_v6"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_orgs_v6"("userid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_orgs_v6"("userid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_orgs_v7"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_orgs_v7"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_orgs_v7"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_orgs_v7"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_orgs_v7"("userid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_orgs_v7"("userid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_owner_org_by_app_id_internal"("p_app_id" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_owner_org_by_app_id_internal"("p_app_id" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_password_policy_hash"("policy_config" "jsonb") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_password_policy_hash"("policy_config" "jsonb") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_password_policy_hash"("policy_config" "jsonb") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_plan_usage_and_fit"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_plan_usage_and_fit"("orgid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_plan_usage_and_fit_uncached"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_plan_usage_and_fit_uncached"("orgid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid", "cycle_start" "date", "cycle_end" "date") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid", "cycle_start" "date", "cycle_end" "date") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_plan_usage_percent_detailed"("orgid" "uuid", "cycle_start" "date", "cycle_end" "date") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_sso_enforcement_by_domain"("p_domain" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_sso_enforcement_by_domain"("p_domain" "text") TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_sso_enforcement_by_domain"("p_domain" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_sso_enforcement_by_domain"("p_domain" "text") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_total_app_storage_size_orgs"("org_id" "uuid", "app_id" character varying) TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_total_metrics"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_total_metrics"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_total_metrics"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_total_metrics"("org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_total_metrics"("org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_total_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_total_metrics"("org_id" "uuid", "start_date" "date", "end_date" "date") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_total_storage_size_org"("org_id" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_update_stats"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."get_user_id"("apikey" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_user_id"("apikey" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_user_id"("apikey" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_user_id"("apikey" "text", "app_id" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_user_id"("apikey" "text", "app_id" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_user_id"("apikey" "text", "app_id" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."get_user_main_org_id"("user_id" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_user_org_ids"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_user_org_ids"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_user_org_ids"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_user_org_ids"() TO "service_role"; + + + +GRANT ALL ON TABLE "public"."app_versions" TO "anon"; +GRANT ALL ON TABLE "public"."app_versions" TO "authenticated"; +GRANT ALL ON TABLE "public"."app_versions" TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."get_versions_with_no_metadata"() FROM PUBLIC; + + + +GRANT ALL ON FUNCTION "public"."get_weekly_stats"("app_id" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_weekly_stats"("app_id" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_weekly_stats"("app_id" character varying) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."guard_owner_org_reassignment"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."has_2fa_enabled"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."has_2fa_enabled"() TO "anon"; +GRANT ALL ON FUNCTION "public"."has_2fa_enabled"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."has_2fa_enabled"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."has_2fa_enabled"("user_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."has_app_right"("appid" character varying, "right" "public"."user_min_right") TO "anon"; +GRANT ALL ON FUNCTION "public"."has_app_right"("appid" character varying, "right" "public"."user_min_right") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."has_app_right"("appid" character varying, "right" "public"."user_min_right") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."has_app_right_apikey"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid", "apikey" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."has_app_right_apikey"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid", "apikey" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."has_app_right_apikey"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid", "apikey" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."has_app_right_apikey"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid", "apikey" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."has_app_right_userid"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."has_app_right_userid"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."has_app_right_userid"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."has_app_right_userid"("appid" character varying, "right" "public"."user_min_right", "userid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."has_seeded_demo_data"("p_app_id" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."has_seeded_demo_data"("p_app_id" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."internal_request_db_user_names"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."internal_request_db_user_names"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."internal_request_role_names"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."internal_request_role_names"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") TO "anon"; +GRANT ALL ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."invite_user_to_org"("email" character varying, "org_id" "uuid", "invite_type" "public"."user_min_right") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_account_disabled"("user_id" "uuid") TO "authenticated"; + + + +GRANT ALL ON FUNCTION "public"."is_allowed_action"("apikey" "text", "appid" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_allowed_action"("apikey" "text", "appid" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_allowed_action"("apikey" "text", "appid" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_allowed_action_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_allowed_action_org"("orgid" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_allowed_action_org"("orgid" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_allowed_action_org"("orgid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) TO "anon"; +GRANT ALL ON FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."is_allowed_action_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[]) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[]) TO "anon"; +GRANT ALL ON FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[]) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[]) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[], "app_id" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[], "app_id" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[], "app_id" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_allowed_capgkey"("apikey" "text", "keymode" "public"."key_mode"[], "app_id" character varying) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_apikey_expired"("key_expires_at" timestamp with time zone) TO "anon"; +GRANT ALL ON FUNCTION "public"."is_apikey_expired"("key_expires_at" timestamp with time zone) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_apikey_expired"("key_expires_at" timestamp with time zone) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_app_owner"("appid" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."is_app_owner"("appid" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_app_owner"("appid" character varying) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_app_owner"("apikey" "text", "appid" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."is_app_owner"("apikey" "text", "appid" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_app_owner"("apikey" "text", "appid" character varying) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_app_owner"("userid" "uuid", "appid" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."is_app_owner"("userid" "uuid", "appid" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_app_owner"("userid" "uuid", "appid" character varying) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_bandwidth_exceeded_by_org"("org_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_bandwidth_exceeded_by_org"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_bandwidth_exceeded_by_org"("org_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_build_time_exceeded_by_org"("org_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_build_time_exceeded_by_org"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_build_time_exceeded_by_org"("org_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_bundle_encrypted"("session_key" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_bundle_encrypted"("session_key" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_bundle_encrypted"("session_key" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_canceled_org"("orgid" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_good_plan_v5_org"("orgid" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_internal_request_role"("caller_role" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_internal_request_role"("caller_role" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_mau_exceeded_by_org"("org_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_mau_exceeded_by_org"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_mau_exceeded_by_org"("org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "authenticated"; + + + +GRANT ALL ON FUNCTION "public"."is_not_deleted"("email_check" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."is_not_deleted"("email_check" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_not_deleted"("email_check" character varying) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_numeric"("text") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_numeric"("text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_numeric"("text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_onboarded_org"("orgid" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_onboarding_needed_org"("orgid" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_org_yearly"("orgid" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org"("orgid" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[]) TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_paying_and_good_plan_org_action"("orgid" "uuid", "actions" "public"."action_type"[], "appid" character varying) TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_paying_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_paying_org"("orgid" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_paying_org"("orgid" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_paying_org"("orgid" "uuid") TO "anon"; + + + +REVOKE ALL ON FUNCTION "public"."is_platform_admin"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_platform_admin"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_platform_admin"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."is_platform_admin"("userid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_platform_admin"("userid" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_rbac_enabled_globally"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_recent_email_otp_verified"("p_user_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_storage_exceeded_by_org"("org_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_storage_exceeded_by_org"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_storage_exceeded_by_org"("org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_trial_org"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_trial_org"("orgid" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_trial_org"("orgid" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_trial_org"("orgid" "uuid") TO "anon"; + + + +REVOKE ALL ON FUNCTION "public"."is_user_app_admin"("p_user_id" "uuid", "p_app_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_user_app_admin"("p_user_id" "uuid", "p_app_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_user_app_admin"("p_user_id" "uuid", "p_app_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."is_user_org_admin"("p_user_id" "uuid", "p_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."is_user_org_admin"("p_user_id" "uuid", "p_org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_user_org_admin"("p_user_id" "uuid", "p_org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."mark_app_stats_refreshed"("p_app_id" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."mark_app_stats_refreshed"("p_app_id" character varying) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."mass_edit_queue_messages_cf_ids"("updates" "public"."message_update"[]) FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."modify_permissions_tmp"("email" "text", "org_id" "uuid", "new_role" "public"."user_min_right") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."modify_permissions_tmp"("email" "text", "org_id" "uuid", "new_role" "public"."user_min_right") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."modify_permissions_tmp"("email" "text", "org_id" "uuid", "new_role" "public"."user_min_right") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."normalize_public_channel_overlap"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."normalize_public_channel_overlap"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."normalize_sso_provider_domain"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."normalize_sso_provider_domain"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."noupdate"() FROM PUBLIC; + + + +GRANT ALL ON FUNCTION "public"."one_month_ahead"() TO "anon"; +GRANT ALL ON FUNCTION "public"."one_month_ahead"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."one_month_ahead"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."parse_cron_field"("field" "text", "current_val" integer, "max_val" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."parse_cron_field"("field" "text", "current_val" integer, "max_val" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."parse_cron_field"("field" "text", "current_val" integer, "max_val" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."parse_step_pattern"("pattern" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."parse_step_pattern"("pattern" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."parse_step_pattern"("pattern" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."pg_log"("decision" "text", "input" "jsonb") FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."prevent_last_super_admin_binding_delete"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."prevent_last_super_admin_binding_delete"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."process_admin_stats"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."process_all_cron_tasks"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."process_all_cron_tasks"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."process_billing_period_stats_email"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."process_channel_device_counts_queue"("batch_size" integer) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."process_channel_device_counts_queue"("batch_size" integer) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."process_cron_stats_jobs"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."process_cron_stats_jobs"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."process_cron_sync_sub_jobs"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."process_cron_sync_sub_jobs"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."process_daily_fail_ratio_email"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."process_daily_fail_ratio_email"() TO "anon"; +GRANT ALL ON FUNCTION "public"."process_daily_fail_ratio_email"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."process_daily_fail_ratio_email"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."process_deploy_install_stats_email"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."process_failed_uploads"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."process_free_trial_expired"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."process_function_queue"("queue_names" "text"[], "batch_size" integer) FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."process_function_queue"("queue_name" "text", "batch_size" integer) FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."process_queue_with_healthcheck"("queue_names" "text"[], "batch_size" integer, "healthcheck_url" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."process_queue_with_healthcheck"("queue_names" "text"[], "batch_size" integer, "healthcheck_url" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."process_stats_email_monthly"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."process_stats_email_weekly"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."process_subscribed_orgs"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."process_subscribed_orgs"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."queue_cron_stat_app_for_app"("p_app_id" character varying, "p_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."queue_cron_stat_app_for_app"("p_app_id" character varying, "p_org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."queue_cron_stat_org_for_org"("org_id" "uuid", "customer_id" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."queue_cron_stat_org_for_org"("org_id" "uuid", "customer_id" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_check_permission"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_check_permission"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_check_permission"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") TO "service_role"; +GRANT ALL ON FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") TO "service_role"; +GRANT ALL ON FUNCTION "public"."rbac_check_permission_direct_no_password_policy"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_check_permission_no_password_policy"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_check_permission_no_password_policy"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_check_permission_no_password_policy"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_check_permission_request"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_check_permission_request"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) TO "service_role"; +GRANT ALL ON FUNCTION "public"."rbac_check_permission_request"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_check_permission_request"("p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_enable_for_org"("p_org_id" "uuid", "p_granted_by" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_enable_for_org"("p_org_id" "uuid", "p_granted_by" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_has_permission"("p_principal_type" "text", "p_principal_id" "uuid", "p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_has_permission"("p_principal_type" "text", "p_principal_id" "uuid", "p_permission_key" "text", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_is_enabled_for_org"("p_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_is_enabled_for_org"("p_org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_is_enabled_for_org"("p_org_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_legacy_right_for_org_role"("p_role_name" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_legacy_right_for_org_role"("p_role_name" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_legacy_right_for_org_role"("p_role_name" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_legacy_role_hint"("p_user_right" "public"."user_min_right", "p_app_id" character varying, "p_channel_id" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_legacy_role_hint"("p_user_right" "public"."user_min_right", "p_app_id" character varying, "p_channel_id" bigint) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_legacy_role_hint"("p_user_right" "public"."user_min_right", "p_app_id" character varying, "p_channel_id" bigint) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_migrate_org_users_to_bindings"("p_org_id" "uuid", "p_granted_by" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_migrate_org_users_to_bindings"("p_org_id" "uuid", "p_granted_by" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_org_role_for_legacy_right"("legacy_right" "public"."user_min_right") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_org_role_for_legacy_right"("legacy_right" "public"."user_min_right") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_build_native"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_build_native"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_build_native"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_create_channel"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_create_channel"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_create_channel"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_delete"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_delete"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_delete"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_manage_devices"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_manage_devices"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_manage_devices"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_audit"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_audit"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_audit"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_bundles"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_bundles"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_bundles"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_channels"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_channels"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_channels"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_devices"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_devices"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_devices"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_logs"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_logs"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_read_logs"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_transfer"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_transfer"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_transfer"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_update_settings"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_update_settings"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_update_settings"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_update_user_roles"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_update_user_roles"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_update_user_roles"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_app_upload_bundle"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_upload_bundle"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_app_upload_bundle"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_bundle_delete"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_bundle_delete"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_bundle_delete"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_bundle_read"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_bundle_read"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_bundle_read"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_bundle_update"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_bundle_update"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_bundle_update"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_delete"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_delete"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_delete"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_manage_forced_devices"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_manage_forced_devices"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_manage_forced_devices"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_promote_bundle"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_promote_bundle"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_promote_bundle"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read_audit"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read_audit"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read_audit"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read_forced_devices"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read_forced_devices"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read_forced_devices"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read_history"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read_history"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_read_history"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_rollback_bundle"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_rollback_bundle"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_rollback_bundle"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_update_settings"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_update_settings"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_channel_update_settings"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_perm_org_create"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_create"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_create"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_create"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_perm_org_create_app"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_create_app"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_create_app"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_create_app"() TO "authenticated"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_delete"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_delete"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_delete"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_invite_user"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_invite_user"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_invite_user"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_audit"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_audit"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_audit"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_billing"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_billing"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_billing"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_billing_audit"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_billing_audit"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_billing_audit"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_invoices"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_invoices"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_invoices"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_members"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_members"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_read_members"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_update_billing"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_update_billing"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_update_billing"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_update_settings"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_update_settings"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_update_settings"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_org_update_user_roles"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_update_user_roles"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_org_update_user_roles"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_db_break_glass"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_db_break_glass"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_db_break_glass"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_delete_orphan_users"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_delete_orphan_users"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_delete_orphan_users"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_impersonate_user"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_impersonate_user"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_impersonate_user"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_manage_apps_any"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_manage_apps_any"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_manage_apps_any"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_manage_channels_any"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_manage_channels_any"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_manage_channels_any"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_manage_orgs_any"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_manage_orgs_any"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_manage_orgs_any"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_read_all_audit"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_read_all_audit"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_read_all_audit"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_run_maintenance_jobs"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_run_maintenance_jobs"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_perm_platform_run_maintenance_jobs"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_permission_for_legacy"("p_min_right" "public"."user_min_right", "p_scope" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_permission_for_legacy"("p_min_right" "public"."user_min_right", "p_scope" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_permission_for_legacy"("p_min_right" "public"."user_min_right", "p_scope" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_preview_migration"("p_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_preview_migration"("p_org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_preview_migration"("p_org_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_principal_apikey"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_principal_apikey"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_principal_apikey"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_principal_group"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_principal_group"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_principal_group"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_principal_user"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_principal_user"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_principal_user"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_right_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_right_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_right_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_right_invite_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_right_invite_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_right_invite_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_right_invite_super_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_right_invite_super_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_right_invite_super_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_right_invite_upload"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_right_invite_upload"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_right_invite_upload"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_right_invite_write"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_right_invite_write"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_right_invite_write"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_right_read"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_right_read"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_right_read"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_right_super_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_right_super_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_right_super_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_right_upload"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_right_upload"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_right_upload"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_right_write"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_right_write"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_right_write"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_role_apikey_org_reader"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_role_apikey_org_reader"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."rbac_role_apikey_org_reader"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_apikey_org_reader"() TO "authenticated"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_app_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_app_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_app_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_app_developer"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_app_developer"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_app_developer"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_app_reader"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_app_reader"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_app_reader"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_app_uploader"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_app_uploader"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_app_uploader"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_bundle_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_bundle_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_bundle_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_bundle_reader"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_bundle_reader"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_bundle_reader"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_channel_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_channel_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_channel_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_channel_reader"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_channel_reader"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_channel_reader"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_org_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_org_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_org_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_org_billing_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_org_billing_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_org_billing_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_org_member"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_org_member"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_org_member"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_org_super_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_org_super_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_org_super_admin"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_role_platform_super_admin"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_role_platform_super_admin"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_role_platform_super_admin"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rbac_rollback_org"("p_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rbac_rollback_org"("p_org_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_scope_app"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_scope_app"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_scope_app"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_scope_bundle"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_scope_bundle"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_scope_bundle"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_scope_channel"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_scope_channel"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_scope_channel"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_scope_org"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_scope_org"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_scope_org"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."rbac_scope_platform"() TO "anon"; +GRANT ALL ON FUNCTION "public"."rbac_scope_platform"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."rbac_scope_platform"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."read_bandwidth_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."read_bandwidth_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "anon"; +GRANT ALL ON FUNCTION "public"."read_bandwidth_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."read_bandwidth_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."read_device_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."read_device_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "anon"; +GRANT ALL ON FUNCTION "public"."read_device_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."read_device_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."read_native_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."read_native_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "service_role"; +GRANT ALL ON FUNCTION "public"."read_native_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."read_native_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "anon"; + + + +GRANT ALL ON FUNCTION "public"."read_storage_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "anon"; +GRANT ALL ON FUNCTION "public"."read_storage_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."read_storage_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."read_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "anon"; +GRANT ALL ON FUNCTION "public"."read_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."read_version_usage"("p_app_id" character varying, "p_period_start" timestamp without time zone, "p_period_end" timestamp without time zone) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."reassign_webhook_created_by_before_user_delete"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."reassign_webhook_created_by_before_user_delete"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."record_build_time"("p_org_id" "uuid", "p_user_id" "uuid", "p_build_id" character varying, "p_platform" character varying, "p_build_time_unit" bigint, "p_app_id" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."record_build_time"("p_org_id" "uuid", "p_user_id" "uuid", "p_build_id" character varying, "p_platform" character varying, "p_build_time_unit" bigint, "p_app_id" character varying) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."record_deployment_history"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."record_email_otp_verified"("p_user_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."record_email_otp_verified"("p_user_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."refresh_app_rollups_after_demo_reset"("p_app_uuid" "uuid", "p_app_id" "text", "p_owner_org" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."refresh_app_rollups_after_demo_reset"("p_app_uuid" "uuid", "p_app_id" "text", "p_owner_org" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."refresh_orgs_has_usage_credits"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."refresh_orgs_has_usage_credits"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."regenerate_hashed_apikey"("p_apikey_id" bigint) TO "service_role"; +GRANT ALL ON FUNCTION "public"."regenerate_hashed_apikey"("p_apikey_id" bigint) TO "anon"; +GRANT ALL ON FUNCTION "public"."regenerate_hashed_apikey"("p_apikey_id" bigint) TO "authenticated"; + + + +GRANT ALL ON FUNCTION "public"."regenerate_hashed_apikey_for_user"("p_apikey_id" bigint, "p_user_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."reject_access_due_to_2fa"("org_id" "uuid", "user_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) TO "anon"; +GRANT ALL ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."reject_access_due_to_2fa_for_app"("app_id" character varying) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."reject_access_due_to_2fa_for_org"("org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."reject_access_due_to_password_policy"("org_id" "uuid", "user_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."remove_old_jobs"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."request_app_chart_refresh"("app_id" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."request_app_chart_refresh"("app_id" character varying) TO "service_role"; +GRANT ALL ON FUNCTION "public"."request_app_chart_refresh"("app_id" character varying) TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."request_has_app_read_access"("orgid" "uuid", "appid" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."request_has_app_read_access"("orgid" "uuid", "appid" character varying) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."request_has_org_read_access"("orgid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."request_has_org_read_access"("orgid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."request_org_chart_refresh"("org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."request_org_chart_refresh"("org_id" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."request_org_chart_refresh"("org_id" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."request_read_key_modes"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."request_read_key_modes"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."rescind_invitation"("email" "text", "org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."rescind_invitation"("email" "text", "org_id" "uuid") TO "service_role"; +GRANT ALL ON FUNCTION "public"."rescind_invitation"("email" "text", "org_id" "uuid") TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."reset_onboarding_demo_app_data"("p_app_uuid" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."reset_onboarding_demo_app_data"("p_app_uuid" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."restore_deleted_account"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."restore_deleted_account"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."restore_deleted_account"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."resync_org_user_role_bindings"("p_user_id" "uuid", "p_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."resync_org_user_role_bindings"("p_user_id" "uuid", "p_org_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."sanitize_apps_text_fields"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."sanitize_apps_text_fields"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."sanitize_orgs_text_fields"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."sanitize_orgs_text_fields"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."sanitize_tmp_users_text_fields"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."sanitize_tmp_users_text_fields"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."sanitize_users_text_fields"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."sanitize_users_text_fields"() TO "service_role"; + + + +GRANT ALL ON TABLE "public"."app_metrics_cache" TO "anon"; +GRANT ALL ON TABLE "public"."app_metrics_cache" TO "authenticated"; +GRANT ALL ON TABLE "public"."app_metrics_cache" TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."seed_get_app_metrics_caches"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."seed_org_metrics_cache"("p_org_id" "uuid", "p_start_date" "date", "p_end_date" "date") FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."set_build_time_exceeded_by_org"("org_id" "uuid", "disabled" boolean) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."set_build_time_exceeded_by_org"("org_id" "uuid", "disabled" boolean) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."set_deleted_at_on_soft_delete"() TO "anon"; +GRANT ALL ON FUNCTION "public"."set_deleted_at_on_soft_delete"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."set_deleted_at_on_soft_delete"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."set_webhook_created_by"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."set_webhook_created_by"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."strip_html"("input" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."strip_html"("input" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."strip_html"("input" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."sync_org_has_usage_credits_from_grants"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."sync_org_has_usage_credits_from_grants"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."sync_org_user_role_binding_on_delete"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."sync_org_user_role_binding_on_delete"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."sync_org_user_role_binding_on_update"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."sync_org_user_role_binding_on_update"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."sync_org_user_to_role_binding"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."sync_org_user_to_role_binding"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."top_up_usage_credits"("p_org_id" "uuid", "p_amount" numeric, "p_expires_at" timestamp with time zone, "p_source" "text", "p_source_ref" "jsonb", "p_notes" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."top_up_usage_credits"("p_org_id" "uuid", "p_amount" numeric, "p_expires_at" timestamp with time zone, "p_source" "text", "p_source_ref" "jsonb", "p_notes" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."total_bundle_storage_bytes"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."total_bundle_storage_bytes"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."track_onboarding_demo_data"("p_app_id" "text", "p_owner_org" "uuid", "p_relation_name" "text", "p_row_keys" "text"[], "p_seed_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."track_onboarding_demo_data"("p_app_id" "text", "p_owner_org" "uuid", "p_relation_name" "text", "p_row_keys" "text"[], "p_seed_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."transfer_app"("p_app_id" character varying, "p_new_org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."transfer_app"("p_app_id" character varying, "p_new_org_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."transfer_app"("p_app_id" character varying, "p_new_org_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."transform_role_to_invite"("role_input" "public"."user_min_right") TO "anon"; +GRANT ALL ON FUNCTION "public"."transform_role_to_invite"("role_input" "public"."user_min_right") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."transform_role_to_invite"("role_input" "public"."user_min_right") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."transform_role_to_non_invite"("role_input" "public"."user_min_right") TO "anon"; +GRANT ALL ON FUNCTION "public"."transform_role_to_non_invite"("role_input" "public"."user_min_right") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."transform_role_to_non_invite"("role_input" "public"."user_min_right") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."trigger_http_queue_post_to_function"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."trigger_webhook_on_audit_log"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."update_app_versions_retention"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."update_app_versions_retention"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."update_apps_build_timeout_updated_at"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."update_apps_build_timeout_updated_at"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."update_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."update_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."update_org_member_role"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."update_sso_providers_updated_at"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."update_sso_providers_updated_at"() TO "anon"; +GRANT ALL ON FUNCTION "public"."update_sso_providers_updated_at"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uuid", "p_email" "text", "p_new_role_name" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uuid", "p_email" "text", "p_new_role_name" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uuid", "p_email" "text", "p_new_role_name" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."update_webhook_updated_at"() FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "public"."upsert_version_meta"("p_app_id" character varying, "p_version_id" bigint, "p_size" bigint) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."upsert_version_meta"("p_app_id" character varying, "p_version_id" bigint, "p_size" bigint) TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."usage_credit_readable_org_ids"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."usage_credit_readable_org_ids"() TO "service_role"; +GRANT ALL ON FUNCTION "public"."usage_credit_readable_org_ids"() TO "anon"; +GRANT ALL ON FUNCTION "public"."usage_credit_readable_org_ids"() TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."user_has_app_update_user_roles"("p_user_id" "uuid", "p_app_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."user_has_app_update_user_roles"("p_user_id" "uuid", "p_app_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."user_has_app_update_user_roles"("p_user_id" "uuid", "p_app_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."user_has_role_in_app"("p_user_id" "uuid", "p_app_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."user_has_role_in_app"("p_user_id" "uuid", "p_app_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."user_has_role_in_app"("p_user_id" "uuid", "p_app_id" "uuid") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."user_meets_password_policy"("user_id" "uuid", "org_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."verify_api_key_hash"("plain_key" "text", "stored_hash" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."verify_api_key_hash"("plain_key" "text", "stored_hash" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."verify_api_key_hash"("plain_key" "text", "stored_hash" "text") TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."verify_mfa"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."verify_mfa"() TO "anon"; +GRANT ALL ON FUNCTION "public"."verify_mfa"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."verify_mfa"() TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +GRANT ALL ON TABLE "public"."apikey_global_permissions" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."apikey_global_permissions_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."apikey_global_permissions_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."apikey_global_permissions_id_seq" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."apikeys_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."apikeys_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."apikeys_id_seq" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."app_metrics_cache_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."app_metrics_cache_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."app_metrics_cache_id_seq" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."app_versions_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."app_versions_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."app_versions_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."app_versions_meta" TO "anon"; +GRANT ALL ON TABLE "public"."app_versions_meta" TO "authenticated"; +GRANT ALL ON TABLE "public"."app_versions_meta" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."app_versions_meta_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."app_versions_meta_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."app_versions_meta_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."audit_logs" TO "authenticated"; +GRANT ALL ON TABLE "public"."audit_logs" TO "service_role"; +GRANT SELECT ON TABLE "public"."audit_logs" TO "anon"; + + + +GRANT ALL ON SEQUENCE "public"."audit_logs_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."audit_logs_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."bandwidth_usage" TO "anon"; +GRANT ALL ON TABLE "public"."bandwidth_usage" TO "authenticated"; +GRANT ALL ON TABLE "public"."bandwidth_usage" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."bandwidth_usage_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."bandwidth_usage_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."bandwidth_usage_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."build_logs" TO "anon"; +GRANT ALL ON TABLE "public"."build_logs" TO "authenticated"; +GRANT ALL ON TABLE "public"."build_logs" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."build_requests" TO "anon"; +GRANT ALL ON TABLE "public"."build_requests" TO "authenticated"; +GRANT ALL ON TABLE "public"."build_requests" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."capgo_credits_steps" TO "anon"; +GRANT ALL ON TABLE "public"."capgo_credits_steps" TO "authenticated"; +GRANT ALL ON TABLE "public"."capgo_credits_steps" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."capgo_credits_steps_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."capgo_credits_steps_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."capgo_credits_steps_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."channel_devices" TO "anon"; +GRANT ALL ON TABLE "public"."channel_devices" TO "authenticated"; +GRANT ALL ON TABLE "public"."channel_devices" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."channel_devices_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."channel_devices_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."channel_devices_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."channels" TO "anon"; +GRANT ALL ON TABLE "public"."channels" TO "authenticated"; +GRANT ALL ON TABLE "public"."channels" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."channel_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."channel_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."channel_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."channel_permission_overrides" TO "anon"; +GRANT ALL ON TABLE "public"."channel_permission_overrides" TO "authenticated"; +GRANT ALL ON TABLE "public"."channel_permission_overrides" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."compatibility_events" TO "anon"; +GRANT ALL ON TABLE "public"."compatibility_events" TO "authenticated"; +GRANT ALL ON TABLE "public"."compatibility_events" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."compatibility_events_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."compatibility_events_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."compatibility_events_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."cron_tasks" TO "anon"; +GRANT ALL ON TABLE "public"."cron_tasks" TO "authenticated"; +GRANT ALL ON TABLE "public"."cron_tasks" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."cron_tasks_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."cron_tasks_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."cron_tasks_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."daily_bandwidth" TO "anon"; +GRANT ALL ON TABLE "public"."daily_bandwidth" TO "authenticated"; +GRANT ALL ON TABLE "public"."daily_bandwidth" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."daily_bandwidth_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."daily_bandwidth_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."daily_bandwidth_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."daily_build_time" TO "anon"; +GRANT ALL ON TABLE "public"."daily_build_time" TO "authenticated"; +GRANT ALL ON TABLE "public"."daily_build_time" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."daily_mau" TO "anon"; +GRANT ALL ON TABLE "public"."daily_mau" TO "authenticated"; +GRANT ALL ON TABLE "public"."daily_mau" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."daily_mau_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."daily_mau_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."daily_mau_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."daily_revenue_metrics" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."daily_storage" TO "anon"; +GRANT ALL ON TABLE "public"."daily_storage" TO "authenticated"; +GRANT ALL ON TABLE "public"."daily_storage" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."daily_storage_hourly" TO "anon"; +GRANT ALL ON TABLE "public"."daily_storage_hourly" TO "authenticated"; +GRANT ALL ON TABLE "public"."daily_storage_hourly" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."daily_storage_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."daily_storage_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."daily_storage_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."daily_version" TO "anon"; +GRANT ALL ON TABLE "public"."daily_version" TO "authenticated"; +GRANT ALL ON TABLE "public"."daily_version" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."deleted_account" TO "anon"; +GRANT ALL ON TABLE "public"."deleted_account" TO "authenticated"; +GRANT ALL ON TABLE "public"."deleted_account" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."deleted_apps" TO "anon"; +GRANT ALL ON TABLE "public"."deleted_apps" TO "authenticated"; +GRANT ALL ON TABLE "public"."deleted_apps" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."deleted_apps_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."deleted_apps_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."deleted_apps_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."deploy_history" TO "anon"; +GRANT ALL ON TABLE "public"."deploy_history" TO "authenticated"; +GRANT ALL ON TABLE "public"."deploy_history" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."deploy_history_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."deploy_history_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."deploy_history_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."device_usage" TO "anon"; +GRANT ALL ON TABLE "public"."device_usage" TO "authenticated"; +GRANT ALL ON TABLE "public"."device_usage" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."device_usage_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."device_usage_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."device_usage_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."devices" TO "anon"; +GRANT ALL ON TABLE "public"."devices" TO "authenticated"; +GRANT ALL ON TABLE "public"."devices" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."devices_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."devices_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."devices_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."global_stats" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."group_members" TO "anon"; +GRANT ALL ON TABLE "public"."group_members" TO "authenticated"; +GRANT ALL ON TABLE "public"."group_members" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."groups" TO "anon"; +GRANT ALL ON TABLE "public"."groups" TO "authenticated"; +GRANT ALL ON TABLE "public"."groups" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."manifest" TO "anon"; +GRANT ALL ON TABLE "public"."manifest" TO "authenticated"; +GRANT ALL ON TABLE "public"."manifest" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."manifest_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."manifest_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."manifest_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."notifications" TO "anon"; +GRANT ALL ON TABLE "public"."notifications" TO "authenticated"; +GRANT ALL ON TABLE "public"."notifications" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."onboarding_demo_data" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."org_users" TO "anon"; +GRANT ALL ON TABLE "public"."org_users" TO "authenticated"; +GRANT ALL ON TABLE "public"."org_users" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."org_users_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."org_users_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."org_users_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."orgs" TO "anon"; +GRANT ALL ON TABLE "public"."orgs" TO "authenticated"; +GRANT ALL ON TABLE "public"."orgs" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."permissions" TO "anon"; +GRANT ALL ON TABLE "public"."permissions" TO "authenticated"; +GRANT ALL ON TABLE "public"."permissions" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."plans" TO "anon"; +GRANT ALL ON TABLE "public"."plans" TO "authenticated"; +GRANT ALL ON TABLE "public"."plans" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."processed_stripe_events" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."role_bindings" TO "anon"; +GRANT ALL ON TABLE "public"."role_bindings" TO "authenticated"; +GRANT ALL ON TABLE "public"."role_bindings" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."role_hierarchy" TO "anon"; +GRANT ALL ON TABLE "public"."role_hierarchy" TO "authenticated"; +GRANT ALL ON TABLE "public"."role_hierarchy" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."role_permissions" TO "anon"; +GRANT ALL ON TABLE "public"."role_permissions" TO "authenticated"; +GRANT ALL ON TABLE "public"."role_permissions" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."roles" TO "anon"; +GRANT ALL ON TABLE "public"."roles" TO "authenticated"; +GRANT ALL ON TABLE "public"."roles" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."sso_providers" TO "anon"; +GRANT ALL ON TABLE "public"."sso_providers" TO "authenticated"; +GRANT ALL ON TABLE "public"."sso_providers" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."stats" TO "anon"; +GRANT ALL ON TABLE "public"."stats" TO "authenticated"; +GRANT ALL ON TABLE "public"."stats" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."stats_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."stats_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."stats_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."storage_usage" TO "anon"; +GRANT ALL ON TABLE "public"."storage_usage" TO "authenticated"; +GRANT ALL ON TABLE "public"."storage_usage" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."storage_usage_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."storage_usage_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."storage_usage_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."stripe_info" TO "anon"; +GRANT ALL ON TABLE "public"."stripe_info" TO "authenticated"; +GRANT ALL ON TABLE "public"."stripe_info" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."stripe_info_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."stripe_info_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."stripe_info_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."tmp_users" TO "anon"; +GRANT ALL ON TABLE "public"."tmp_users" TO "authenticated"; +GRANT ALL ON TABLE "public"."tmp_users" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."tmp_users_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."tmp_users_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."tmp_users_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."to_delete_accounts" TO "anon"; +GRANT ALL ON TABLE "public"."to_delete_accounts" TO "authenticated"; +GRANT ALL ON TABLE "public"."to_delete_accounts" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."to_delete_accounts_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."to_delete_accounts_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."to_delete_accounts_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."usage_credit_grants" TO "anon"; +GRANT ALL ON TABLE "public"."usage_credit_grants" TO "authenticated"; +GRANT ALL ON TABLE "public"."usage_credit_grants" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."usage_credit_balances" TO "anon"; +GRANT ALL ON TABLE "public"."usage_credit_balances" TO "authenticated"; +GRANT ALL ON TABLE "public"."usage_credit_balances" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."usage_credit_consumptions" TO "anon"; +GRANT ALL ON TABLE "public"."usage_credit_consumptions" TO "authenticated"; +GRANT ALL ON TABLE "public"."usage_credit_consumptions" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."usage_credit_consumptions_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."usage_credit_consumptions_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."usage_credit_consumptions_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."usage_credit_transactions" TO "anon"; +GRANT ALL ON TABLE "public"."usage_credit_transactions" TO "authenticated"; +GRANT ALL ON TABLE "public"."usage_credit_transactions" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."usage_overage_events" TO "anon"; +GRANT ALL ON TABLE "public"."usage_overage_events" TO "authenticated"; +GRANT ALL ON TABLE "public"."usage_overage_events" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."usage_credit_ledger" TO "anon"; +GRANT ALL ON TABLE "public"."usage_credit_ledger" TO "authenticated"; +GRANT ALL ON TABLE "public"."usage_credit_ledger" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."usage_credit_transactions_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."usage_credit_transactions_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."usage_credit_transactions_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_password_compliance" TO "anon"; +GRANT ALL ON TABLE "public"."user_password_compliance" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_password_compliance" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."user_password_compliance_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."user_password_compliance_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."user_password_compliance_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_security" TO "anon"; +GRANT ALL ON TABLE "public"."user_security" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_security" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."users" TO "anon"; +GRANT ALL ON TABLE "public"."users" TO "authenticated"; +GRANT ALL ON TABLE "public"."users" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."version_meta" TO "anon"; +GRANT ALL ON TABLE "public"."version_meta" TO "authenticated"; +GRANT ALL ON TABLE "public"."version_meta" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."version_usage" TO "anon"; +GRANT ALL ON TABLE "public"."version_usage" TO "authenticated"; +GRANT ALL ON TABLE "public"."version_usage" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."webhook_deliveries" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."webhooks" TO "service_role"; + + + + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role"; + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role"; + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +-- +-- Dumped schema changes for auth and storage +-- + +CREATE OR REPLACE TRIGGER "trg_enforce_email_otp_for_mfa" BEFORE INSERT OR UPDATE ON "auth"."mfa_factors" FOR EACH ROW EXECUTE FUNCTION "public"."enforce_email_otp_for_mfa"(); + + + +CREATE POLICY "Allow user or apikey to delete they own folder in apps" ON "storage"."objects" FOR DELETE USING ((("bucket_id" = 'apps'::"text") AND (((( SELECT "auth"."uid"() AS "auth_user_id"))::"text" = ("storage"."foldername"("name"))[1]) OR "capgo_private"."matches_app_storage_apikey_owner"(("storage"."foldername"("name"))[1], (("storage"."foldername"("name"))[2])::character varying, '{all}'::"public"."key_mode"[])))); + + + +CREATE POLICY "Allow user or apikey to delete they own folder in images" ON "storage"."objects" FOR DELETE TO "authenticated", "anon" USING ((("bucket_id" = 'images'::"text") AND ( +CASE + WHEN ((("storage"."foldername"("name"))[1] = 'org'::"text") AND (("storage"."foldername"("name"))[3] IS NOT NULL) AND (("storage"."foldername"("name"))[3] <> 'logo'::"text")) THEN "public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], (("storage"."foldername"("name"))[2])::"uuid", (("storage"."foldername"("name"))[3])::character varying), (("storage"."foldername"("name"))[2])::"uuid", (("storage"."foldername"("name"))[3])::character varying, NULL::bigint) + ELSE false +END OR ((("storage"."foldername"("name"))[1] = 'org'::"text") AND (("storage"."foldername"("name"))[3] = 'logo'::"text") AND "public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_allowed"('{write,all}'::"public"."key_mode"[], (("storage"."foldername"("name"))[2])::"uuid"), (("storage"."foldername"("name"))[2])::"uuid", NULL::character varying, NULL::bigint)) OR (EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "uid") "auth_user" + WHERE (("auth_user"."uid" IS NOT NULL) AND (("auth_user"."uid")::"text" = ("storage"."foldername"("objects"."name"))[1]))))))); + + + +CREATE POLICY "Allow user or apikey to insert they own folder in apps" ON "storage"."objects" FOR INSERT WITH CHECK ((("bucket_id" = 'apps'::"text") AND (((( SELECT "auth"."uid"() AS "auth_user_id"))::"text" = ("storage"."foldername"("name"))[1]) OR "capgo_private"."matches_app_storage_apikey_owner"(("storage"."foldername"("name"))[1], (("storage"."foldername"("name"))[2])::character varying, '{write,all}'::"public"."key_mode"[])))); + + + +CREATE POLICY "Allow user or apikey to insert they own folder in images" ON "storage"."objects" FOR INSERT TO "authenticated", "anon" WITH CHECK ((("bucket_id" = 'images'::"text") AND ( +CASE + WHEN (("storage"."foldername"("name"))[1] = 'org'::"text") THEN (((EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE (("apps"."owner_org" = (("storage"."foldername"(("apps"."name")::"text"))[2])::"uuid") AND (("apps"."app_id")::"text" = ("storage"."foldername"(("apps"."name")::"text"))[3])))) AND "public"."rbac_check_permission_request"("public"."rbac_perm_app_update_settings"(), (("storage"."foldername"("name"))[2])::"uuid", (("storage"."foldername"("name"))[3])::character varying, NULL::bigint)) OR ((NOT (EXISTS ( SELECT 1 + FROM "public"."apps" + WHERE (("apps"."owner_org" = (("storage"."foldername"(("apps"."name")::"text"))[2])::"uuid") AND (("apps"."app_id")::"text" = ("storage"."foldername"(("apps"."name")::"text"))[3]))))) AND "public"."rbac_check_permission_request"("public"."rbac_perm_org_create_app"(), (("storage"."foldername"("name"))[2])::"uuid", NULL::character varying, NULL::bigint))) + ELSE false +END OR (EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "uid") "auth_user" + WHERE (("auth_user"."uid" IS NOT NULL) AND (("auth_user"."uid")::"text" = ("storage"."foldername"("objects"."name"))[1]))))))); + + + +CREATE POLICY "Allow user or apikey to read they own folder in apps" ON "storage"."objects" FOR SELECT USING ((("bucket_id" = 'apps'::"text") AND (((( SELECT "auth"."uid"() AS "auth_user_id"))::"text" = ("storage"."foldername"("name"))[1]) OR "capgo_private"."matches_app_storage_apikey_owner"(("storage"."foldername"("name"))[1], (("storage"."foldername"("name"))[2])::character varying, '{read,all}'::"public"."key_mode"[])))); + + + +CREATE POLICY "Allow user or apikey to read they own folder in images" ON "storage"."objects" FOR SELECT TO "authenticated", "anon" USING ((("bucket_id" = 'images'::"text") AND ((("storage"."foldername"("name"))[1] = 'public'::"text") OR +CASE + WHEN ((("storage"."foldername"("name"))[1] = 'org'::"text") AND (("storage"."foldername"("name"))[3] IS NOT NULL) AND (("storage"."foldername"("name"))[3] <> 'logo'::"text")) THEN "public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_appid"('{read,upload,write,all}'::"public"."key_mode"[], (("storage"."foldername"("name"))[2])::"uuid", (("storage"."foldername"("name"))[3])::character varying), (("storage"."foldername"("name"))[2])::"uuid", (("storage"."foldername"("name"))[3])::character varying, NULL::bigint) + ELSE false +END OR ((("storage"."foldername"("name"))[1] = 'org'::"text") AND (("storage"."foldername"("name"))[3] = 'logo'::"text") AND "public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], (("storage"."foldername"("name"))[2])::"uuid"), (("storage"."foldername"("name"))[2])::"uuid", NULL::character varying, NULL::bigint)) OR ((("storage"."foldername"("name"))[1] <> 'org'::"text") AND (("storage"."foldername"("name"))[1] <> 'public'::"text") AND (EXISTS ( SELECT 1 + FROM "public"."org_users" "ou" + WHERE ((("ou"."user_id")::"text" = ("storage"."foldername"("objects"."name"))[1]) AND "public"."check_min_rights"('read'::"public"."user_min_right", "public"."get_identity_org_allowed"('{read,upload,write,all}'::"public"."key_mode"[], "ou"."org_id"), "ou"."org_id", NULL::character varying, NULL::bigint)))))))); + + + +CREATE POLICY "Allow user or apikey to update they own folder in apps" ON "storage"."objects" FOR UPDATE USING ((("bucket_id" = 'apps'::"text") AND (((( SELECT "auth"."uid"() AS "auth_user_id"))::"text" = ("storage"."foldername"("name"))[1]) OR "capgo_private"."matches_app_storage_apikey_owner"(("storage"."foldername"("name"))[1], (("storage"."foldername"("name"))[2])::character varying, '{write,all}'::"public"."key_mode"[])))); + + + +CREATE POLICY "Allow user or apikey to update they own folder in images" ON "storage"."objects" FOR UPDATE TO "authenticated", "anon" USING ((("bucket_id" = 'images'::"text") AND ( +CASE + WHEN ((("storage"."foldername"("name"))[1] = 'org'::"text") AND (("storage"."foldername"("name"))[3] IS NOT NULL) AND (("storage"."foldername"("name"))[3] <> 'logo'::"text")) THEN "public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], (("storage"."foldername"("name"))[2])::"uuid", (("storage"."foldername"("name"))[3])::character varying), (("storage"."foldername"("name"))[2])::"uuid", (("storage"."foldername"("name"))[3])::character varying, NULL::bigint) + ELSE false +END OR ((("storage"."foldername"("name"))[1] = 'org'::"text") AND (("storage"."foldername"("name"))[3] = 'logo'::"text") AND "public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_allowed"('{write,all}'::"public"."key_mode"[], (("storage"."foldername"("name"))[2])::"uuid"), (("storage"."foldername"("name"))[2])::"uuid", NULL::character varying, NULL::bigint)) OR (EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "uid") "auth_user" + WHERE (("auth_user"."uid" IS NOT NULL) AND (("auth_user"."uid")::"text" = ("storage"."foldername"("objects"."name"))[1]))))))) WITH CHECK ((("bucket_id" = 'images'::"text") AND ( +CASE + WHEN ((("storage"."foldername"("name"))[1] = 'org'::"text") AND (("storage"."foldername"("name"))[3] IS NOT NULL) AND (("storage"."foldername"("name"))[3] <> 'logo'::"text")) THEN "public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_appid"('{write,all}'::"public"."key_mode"[], (("storage"."foldername"("name"))[2])::"uuid", (("storage"."foldername"("name"))[3])::character varying), (("storage"."foldername"("name"))[2])::"uuid", (("storage"."foldername"("name"))[3])::character varying, NULL::bigint) + ELSE false +END OR ((("storage"."foldername"("name"))[1] = 'org'::"text") AND (("storage"."foldername"("name"))[3] = 'logo'::"text") AND "public"."check_min_rights"('write'::"public"."user_min_right", "public"."get_identity_org_allowed"('{write,all}'::"public"."key_mode"[], (("storage"."foldername"("name"))[2])::"uuid"), (("storage"."foldername"("name"))[2])::"uuid", NULL::character varying, NULL::bigint)) OR (EXISTS ( SELECT 1 + FROM ( SELECT "auth"."uid"() AS "uid") "auth_user" + WHERE (("auth_user"."uid" IS NOT NULL) AND (("auth_user"."uid")::"text" = ("storage"."foldername"("objects"."name"))[1]))))))); + + + +CREATE POLICY "Disable act bucket for users" ON "storage"."buckets" USING (false) WITH CHECK (false); + + +-- +-- ACL parity from historical migrations. +-- Supabase migration squash restores schema, but public/default ACL changes +-- from old migrations must be replayed explicitly. +-- + +REVOKE ALL ON FUNCTION public.accept_invitation_to_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.accept_invitation_to_org(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.accept_invitation_to_org(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.accept_invitation_to_org(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.accept_invitation_to_org(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.accept_invitation_to_org(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.acknowledge_compatibility_event(bigint,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.acknowledge_compatibility_event(bigint,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.acknowledge_compatibility_event(bigint,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.acknowledge_compatibility_event(bigint,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.acknowledge_compatibility_event(bigint,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.acknowledge_compatibility_event(bigint,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.apikey_has_current_org_create_capability(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.apikey_has_current_org_create_capability(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.apikey_has_current_org_create_capability(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.apikey_has_current_org_create_capability(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.apikey_has_current_org_create_capability(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.apikey_has_global_permission(text,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.apikey_has_global_permission(text,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.apikey_has_global_permission(text,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.apikey_has_global_permission(text,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.apikey_has_global_permission(text,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.apikeys_force_server_key() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.apikeys_force_server_key() FROM "anon"; +REVOKE ALL ON FUNCTION public.apikeys_force_server_key() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.apikeys_force_server_key() FROM "service_role"; +GRANT ALL ON FUNCTION public.apikeys_force_server_key() TO "service_role"; + +REVOKE ALL ON FUNCTION public.apikeys_strip_plain_key_for_hashed() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.apikeys_strip_plain_key_for_hashed() FROM "anon"; +REVOKE ALL ON FUNCTION public.apikeys_strip_plain_key_for_hashed() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.apikeys_strip_plain_key_for_hashed() FROM "service_role"; +GRANT ALL ON FUNCTION public.apikeys_strip_plain_key_for_hashed() TO "service_role"; + +REVOKE ALL ON FUNCTION public.apply_usage_overage(uuid,public.credit_metric_type,numeric,timestamp with time zone,timestamp with time zone,jsonb) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.apply_usage_overage(uuid,public.credit_metric_type,numeric,timestamp with time zone,timestamp with time zone,jsonb) FROM "anon"; +REVOKE ALL ON FUNCTION public.apply_usage_overage(uuid,public.credit_metric_type,numeric,timestamp with time zone,timestamp with time zone,jsonb) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.apply_usage_overage(uuid,public.credit_metric_type,numeric,timestamp with time zone,timestamp with time zone,jsonb) FROM "service_role"; +GRANT ALL ON FUNCTION public.apply_usage_overage(uuid,public.credit_metric_type,numeric,timestamp with time zone,timestamp with time zone,jsonb) TO "service_role"; + +REVOKE ALL ON FUNCTION public.audit_log_trigger() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.audit_log_trigger() FROM "anon"; +REVOKE ALL ON FUNCTION public.audit_log_trigger() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.audit_log_trigger() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.auto_apikey_name_by_id() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.auto_apikey_name_by_id() FROM "anon"; +REVOKE ALL ON FUNCTION public.auto_apikey_name_by_id() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.auto_apikey_name_by_id() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.auto_owner_org_by_app_id() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.auto_owner_org_by_app_id() FROM "anon"; +REVOKE ALL ON FUNCTION public.auto_owner_org_by_app_id() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.auto_owner_org_by_app_id() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.bind_creating_apikey_to_org_on_create() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.bind_creating_apikey_to_org_on_create() FROM "anon"; +REVOKE ALL ON FUNCTION public.bind_creating_apikey_to_org_on_create() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.bind_creating_apikey_to_org_on_create() FROM "service_role"; +GRANT ALL ON FUNCTION public.bind_creating_apikey_to_org_on_create() TO "service_role"; + +REVOKE ALL ON FUNCTION public.calculate_credit_cost(public.credit_metric_type,numeric) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.calculate_credit_cost(public.credit_metric_type,numeric) FROM "anon"; +REVOKE ALL ON FUNCTION public.calculate_credit_cost(public.credit_metric_type,numeric) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.calculate_credit_cost(public.credit_metric_type,numeric) FROM "service_role"; + +REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid,date,date) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid,date,date) FROM "anon"; +REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid,date,date) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.calculate_org_metrics_cache_entry(uuid,date,date) FROM "service_role"; + +REVOKE ALL ON FUNCTION public.check_apikey_hashed_key_enforcement(public.apikeys) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_apikey_hashed_key_enforcement(public.apikeys) FROM "anon"; +REVOKE ALL ON FUNCTION public.check_apikey_hashed_key_enforcement(public.apikeys) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.check_apikey_hashed_key_enforcement(public.apikeys) FROM "service_role"; +GRANT ALL ON FUNCTION public.check_apikey_hashed_key_enforcement(public.apikeys) TO "service_role"; + +REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() FROM "anon"; +REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() FROM "service_role"; +GRANT ALL ON FUNCTION public.check_encrypted_bundle_on_insert() TO "service_role"; + +REVOKE ALL ON FUNCTION public.check_if_org_can_exist() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_if_org_can_exist() FROM "anon"; +REVOKE ALL ON FUNCTION public.check_if_org_can_exist() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.check_if_org_can_exist() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy(public.user_min_right,uuid,uuid,character varying,bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy(public.user_min_right,uuid,uuid,character varying,bigint) FROM "anon"; +REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy(public.user_min_right,uuid,uuid,character varying,bigint) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy(public.user_min_right,uuid,uuid,character varying,bigint) FROM "service_role"; +GRANT ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy(public.user_min_right,uuid,uuid,character varying,bigint) TO "service_role"; + +REVOKE ALL ON FUNCTION public.check_org_hashed_key_enforcement(uuid,public.apikeys) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_org_hashed_key_enforcement(uuid,public.apikeys) FROM "anon"; +REVOKE ALL ON FUNCTION public.check_org_hashed_key_enforcement(uuid,public.apikeys) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.check_org_hashed_key_enforcement(uuid,public.apikeys) FROM "service_role"; +GRANT ALL ON FUNCTION public.check_org_hashed_key_enforcement(uuid,public.apikeys) TO "service_role"; + +REVOKE ALL ON FUNCTION public.check_org_members_2fa_enabled(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_org_members_2fa_enabled(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.check_org_members_2fa_enabled(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.check_org_members_2fa_enabled(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.check_org_members_2fa_enabled(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.check_org_members_2fa_enabled(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.check_org_members_password_policy(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_org_members_password_policy(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.check_org_members_password_policy(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.check_org_members_password_policy(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.check_org_members_password_policy(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.check_org_members_password_policy(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.check_org_user_privileges() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_org_user_privileges() FROM "anon"; +REVOKE ALL ON FUNCTION public.check_org_user_privileges() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.check_org_user_privileges() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.claim_legacy_onboarding_demo_data(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.claim_legacy_onboarding_demo_data(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.claim_legacy_onboarding_demo_data(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.claim_legacy_onboarding_demo_data(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.claim_legacy_onboarding_demo_data(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_apikey_role_bindings() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_apikey_role_bindings() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_apikey_role_bindings() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_apikey_role_bindings() FROM "service_role"; +GRANT ALL ON FUNCTION public.cleanup_apikey_role_bindings() TO "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_expired_apikeys() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_expired_apikeys() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_expired_apikeys() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_expired_apikeys() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_expired_demo_apps() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_expired_demo_apps() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_expired_demo_apps() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_expired_demo_apps() FROM "service_role"; +GRANT ALL ON FUNCTION public.cleanup_expired_demo_apps() TO "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_frequent_job_details() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_frequent_job_details() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_frequent_job_details() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_frequent_job_details() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_job_run_details_7days() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_job_run_details_7days() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_job_run_details_7days() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_job_run_details_7days() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_old_audit_logs() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_old_audit_logs() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_old_audit_logs() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_old_audit_logs() FROM "service_role"; +GRANT ALL ON FUNCTION public.cleanup_old_audit_logs() TO "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_onboarding_app_data_on_complete() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_onboarding_app_data_on_complete() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_onboarding_app_data_on_complete() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_onboarding_app_data_on_complete() FROM "service_role"; +GRANT ALL ON FUNCTION public.cleanup_onboarding_app_data_on_complete() TO "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_queue_messages() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_queue_messages() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_queue_messages() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_queue_messages() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_tmp_users() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_tmp_users() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_tmp_users() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_tmp_users() FROM "service_role"; +GRANT ALL ON FUNCTION public.cleanup_tmp_users() TO "service_role"; + +REVOKE ALL ON FUNCTION public.cleanup_webhook_deliveries() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.cleanup_webhook_deliveries() FROM "anon"; +REVOKE ALL ON FUNCTION public.cleanup_webhook_deliveries() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.cleanup_webhook_deliveries() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.clear_onboarding_app_data(uuid,bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.clear_onboarding_app_data(uuid,bigint) FROM "anon"; +REVOKE ALL ON FUNCTION public.clear_onboarding_app_data(uuid,bigint) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.clear_onboarding_app_data(uuid,bigint) FROM "service_role"; +GRANT ALL ON FUNCTION public.clear_onboarding_app_data(uuid,bigint) TO "service_role"; + +REVOKE ALL ON FUNCTION public.clear_onboarding_app_data(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.clear_onboarding_app_data(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.clear_onboarding_app_data(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.clear_onboarding_app_data(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.clear_onboarding_app_data(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.count_all_need_upgrade() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.count_all_need_upgrade() FROM "anon"; +REVOKE ALL ON FUNCTION public.count_all_need_upgrade() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.count_all_need_upgrade() FROM "service_role"; +GRANT ALL ON FUNCTION public.count_all_need_upgrade() TO "service_role"; + +REVOKE ALL ON FUNCTION public.count_all_onboarded() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.count_all_onboarded() FROM "anon"; +REVOKE ALL ON FUNCTION public.count_all_onboarded() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.count_all_onboarded() FROM "service_role"; +GRANT ALL ON FUNCTION public.count_all_onboarded() TO "service_role"; + +REVOKE ALL ON FUNCTION public.count_all_plans_v2() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.count_all_plans_v2() FROM "anon"; +REVOKE ALL ON FUNCTION public.count_all_plans_v2() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.count_all_plans_v2() FROM "service_role"; +GRANT ALL ON FUNCTION public.count_all_plans_v2() TO "service_role"; + +REVOKE ALL ON FUNCTION public.count_non_compliant_bundles(uuid,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.count_non_compliant_bundles(uuid,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.count_non_compliant_bundles(uuid,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.count_non_compliant_bundles(uuid,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.count_non_compliant_bundles(uuid,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.count_non_compliant_bundles(uuid,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.delete_accounts_marked_for_deletion() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_accounts_marked_for_deletion() FROM "anon"; +REVOKE ALL ON FUNCTION public.delete_accounts_marked_for_deletion() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.delete_accounts_marked_for_deletion() FROM "service_role"; +GRANT ALL ON FUNCTION public.delete_accounts_marked_for_deletion() TO "service_role"; + +REVOKE ALL ON FUNCTION public.delete_group_with_bindings(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_group_with_bindings(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.delete_group_with_bindings(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.delete_group_with_bindings(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.delete_group_with_bindings(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.delete_group_with_bindings(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.delete_http_response(bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_http_response(bigint) FROM "anon"; +REVOKE ALL ON FUNCTION public.delete_http_response(bigint) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.delete_http_response(bigint) FROM "service_role"; + +REVOKE ALL ON FUNCTION public.delete_non_compliant_bundles(uuid,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_non_compliant_bundles(uuid,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.delete_non_compliant_bundles(uuid,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.delete_non_compliant_bundles(uuid,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.delete_non_compliant_bundles(uuid,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.delete_non_compliant_bundles(uuid,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.delete_old_deleted_apps() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_old_deleted_apps() FROM "anon"; +REVOKE ALL ON FUNCTION public.delete_old_deleted_apps() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.delete_old_deleted_apps() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.delete_old_deleted_versions() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_old_deleted_versions() FROM "anon"; +REVOKE ALL ON FUNCTION public.delete_old_deleted_versions() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.delete_old_deleted_versions() FROM "service_role"; +GRANT ALL ON FUNCTION public.delete_old_deleted_versions() TO "service_role"; + +REVOKE ALL ON FUNCTION public.delete_org_member_role(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_org_member_role(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.delete_org_member_role(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.delete_org_member_role(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.delete_org_member_role(uuid,uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.delete_org_member_role(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.delete_user() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_user() FROM "anon"; +REVOKE ALL ON FUNCTION public.delete_user() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.delete_user() FROM "service_role"; +GRANT ALL ON FUNCTION public.delete_user() TO "authenticated"; +GRANT ALL ON FUNCTION public.delete_user() TO "service_role"; + +REVOKE ALL ON FUNCTION public.enforce_apikey_expiration_policy() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.enforce_apikey_expiration_policy() FROM "anon"; +REVOKE ALL ON FUNCTION public.enforce_apikey_expiration_policy() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.enforce_apikey_expiration_policy() FROM "service_role"; +GRANT ALL ON FUNCTION public.enforce_apikey_expiration_policy() TO "service_role"; + +REVOKE ALL ON FUNCTION public.enforce_apikey_role_binding_expiration_policy() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.enforce_apikey_role_binding_expiration_policy() FROM "anon"; +REVOKE ALL ON FUNCTION public.enforce_apikey_role_binding_expiration_policy() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.enforce_apikey_role_binding_expiration_policy() FROM "service_role"; +GRANT ALL ON FUNCTION public.enforce_apikey_role_binding_expiration_policy() TO "service_role"; + +REVOKE ALL ON FUNCTION public.enforce_channel_version_promotion_permission() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.enforce_channel_version_promotion_permission() FROM "anon"; +REVOKE ALL ON FUNCTION public.enforce_channel_version_promotion_permission() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.enforce_channel_version_promotion_permission() FROM "service_role"; +GRANT ALL ON FUNCTION public.enforce_channel_version_promotion_permission() TO "service_role"; + +REVOKE ALL ON FUNCTION public.enforce_email_otp_for_mfa() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.enforce_email_otp_for_mfa() FROM "anon"; +REVOKE ALL ON FUNCTION public.enforce_email_otp_for_mfa() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.enforce_email_otp_for_mfa() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.enqueue_channel_device_counts() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.enqueue_channel_device_counts() FROM "anon"; +REVOKE ALL ON FUNCTION public.enqueue_channel_device_counts() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.enqueue_channel_device_counts() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.enqueue_credit_usage_alert() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.enqueue_credit_usage_alert() FROM "anon"; +REVOKE ALL ON FUNCTION public.enqueue_credit_usage_alert() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.enqueue_credit_usage_alert() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.expire_usage_credits() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.expire_usage_credits() FROM "anon"; +REVOKE ALL ON FUNCTION public.expire_usage_credits() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.expire_usage_credits() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.find_apikey_by_value(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.find_apikey_by_value(text) FROM "anon"; +REVOKE ALL ON FUNCTION public.find_apikey_by_value(text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.find_apikey_by_value(text) FROM "service_role"; +GRANT ALL ON FUNCTION public.find_apikey_by_value(text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.force_valid_user_id_on_app() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.force_valid_user_id_on_app() FROM "anon"; +REVOKE ALL ON FUNCTION public.force_valid_user_id_on_app() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.force_valid_user_id_on_app() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.generate_org_user_stripe_info_on_org_create() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.generate_org_user_stripe_info_on_org_create() FROM "anon"; +REVOKE ALL ON FUNCTION public.generate_org_user_stripe_info_on_org_create() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.generate_org_user_stripe_info_on_org_create() FROM "service_role"; +GRANT ALL ON FUNCTION public.generate_org_user_stripe_info_on_org_create() TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_account_removal_date() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_account_removal_date() FROM "anon"; +REVOKE ALL ON FUNCTION public.get_account_removal_date() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_account_removal_date() FROM "service_role"; +GRANT ALL ON FUNCTION public.get_account_removal_date() TO "authenticated"; +GRANT ALL ON FUNCTION public.get_account_removal_date() TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_apikey() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_apikey() FROM "anon"; +REVOKE ALL ON FUNCTION public.get_apikey() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_apikey() FROM "service_role"; +GRANT ALL ON FUNCTION public.get_apikey() TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_app_access_rbac(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_app_access_rbac(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_app_access_rbac(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_app_access_rbac(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_app_access_rbac(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_app_access_rbac(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_app_metrics(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_app_metrics(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_app_metrics(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_app_metrics(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_app_metrics(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_app_metrics(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_current_plan_max_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_current_plan_max_org(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_current_plan_max_org(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_current_plan_max_org(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_current_plan_max_org(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_current_plan_max_org(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_current_plan_name_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_current_plan_name_org(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_current_plan_name_org(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_current_plan_name_org(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_current_plan_name_org(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_current_plan_name_org(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_customer_counts() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_customer_counts() FROM "anon"; +REVOKE ALL ON FUNCTION public.get_customer_counts() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_customer_counts() FROM "service_role"; +GRANT ALL ON FUNCTION public.get_customer_counts() TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_cycle_info_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_cycle_info_org(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_cycle_info_org(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_cycle_info_org(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_cycle_info_org(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_cycle_info_org(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_db_url() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_db_url() FROM "anon"; +REVOKE ALL ON FUNCTION public.get_db_url() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_db_url() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.get_identity_apikey_only(public.key_mode[]) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_identity_apikey_only(public.key_mode[]) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_identity_apikey_only(public.key_mode[]) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_identity_apikey_only(public.key_mode[]) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_identity_apikey_only(public.key_mode[]) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_identity_org_allowed_apikey_only(public.key_mode[],uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_identity_org_allowed_apikey_only(public.key_mode[],uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_identity_org_allowed_apikey_only(public.key_mode[],uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_identity_org_allowed_apikey_only(public.key_mode[],uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_identity_org_allowed_apikey_only(public.key_mode[],uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_org_apikeys(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_apikeys(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_org_apikeys(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_org_apikeys(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_org_apikeys(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_org_apikeys(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_org_members_rbac(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_members_rbac(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_org_members_rbac(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_org_members_rbac(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_org_members_rbac(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_org_members_rbac(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_org_members(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_members(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_org_members(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_org_members(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_org_members(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_org_members(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey_v2(text,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey_v2(text,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey_v2(text,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey_v2(text,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_org_perm_for_apikey_v2(text,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey(text,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey(text,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey(text,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_org_perm_for_apikey(text,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_org_perm_for_apikey(text,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_org_perm_for_apikey(text,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_org_user_access_rbac(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_user_access_rbac(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_org_user_access_rbac(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_org_user_access_rbac(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_org_user_access_rbac(uuid,uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_org_user_access_rbac(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_orgs_v6(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_orgs_v6(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_orgs_v7(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_owner_org_by_app_id_internal(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_owner_org_by_app_id_internal(text) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_owner_org_by_app_id_internal(text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_owner_org_by_app_id_internal(text) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_owner_org_by_app_id_internal(text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit_uncached(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_plan_usage_and_fit_uncached(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_plan_usage_and_fit(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid,date,date) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid,date,date) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid,date,date) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid,date,date) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid,date,date) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid,date,date) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_plan_usage_percent_detailed(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_total_metrics() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_total_metrics() FROM "anon"; +REVOKE ALL ON FUNCTION public.get_total_metrics() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_total_metrics() FROM "service_role"; +GRANT ALL ON FUNCTION public.get_total_metrics() TO "authenticated"; +GRANT ALL ON FUNCTION public.get_total_metrics() TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_total_metrics(uuid,date,date) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_total_metrics(uuid,date,date) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_total_metrics(uuid,date,date) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_total_metrics(uuid,date,date) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_total_metrics(uuid,date,date) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_total_metrics(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_total_metrics(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_update_stats() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_update_stats() FROM "anon"; +REVOKE ALL ON FUNCTION public.get_update_stats() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_update_stats() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.get_user_id(text,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_user_id(text,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_user_id(text,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_user_id(text,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_user_id(text,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_user_id(text,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_user_id(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_user_id(text) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_user_id(text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_user_id(text) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_user_id(text) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_user_id(text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_user_main_org_id(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_user_main_org_id(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.get_user_main_org_id(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_user_main_org_id(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.get_user_main_org_id(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.get_user_main_org_id(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.get_versions_with_no_metadata() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_versions_with_no_metadata() FROM "anon"; +REVOKE ALL ON FUNCTION public.get_versions_with_no_metadata() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.get_versions_with_no_metadata() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.has_2fa_enabled(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.has_2fa_enabled(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.has_2fa_enabled(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.has_2fa_enabled(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.has_2fa_enabled(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.has_seeded_demo_data(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.has_seeded_demo_data(text) FROM "anon"; +REVOKE ALL ON FUNCTION public.has_seeded_demo_data(text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.has_seeded_demo_data(text) FROM "service_role"; +GRANT ALL ON FUNCTION public.has_seeded_demo_data(text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.internal_request_db_user_names() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.internal_request_db_user_names() FROM "anon"; +REVOKE ALL ON FUNCTION public.internal_request_db_user_names() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.internal_request_db_user_names() FROM "service_role"; +GRANT ALL ON FUNCTION public.internal_request_db_user_names() TO "service_role"; + +REVOKE ALL ON FUNCTION public.internal_request_role_names() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.internal_request_role_names() FROM "anon"; +REVOKE ALL ON FUNCTION public.internal_request_role_names() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.internal_request_role_names() FROM "service_role"; +GRANT ALL ON FUNCTION public.internal_request_role_names() TO "service_role"; + +REVOKE ALL ON FUNCTION public.is_account_disabled(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_account_disabled(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.is_account_disabled(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.is_account_disabled(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.is_account_disabled(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.is_account_disabled(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.is_internal_request_role(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_internal_request_role(text) FROM "anon"; +REVOKE ALL ON FUNCTION public.is_internal_request_role(text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.is_internal_request_role(text) FROM "service_role"; +GRANT ALL ON FUNCTION public.is_internal_request_role(text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[],character varying) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[],character varying) FROM "anon"; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[],character varying) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[],character varying) FROM "service_role"; +GRANT ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[],character varying) TO "authenticated"; +GRANT ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[],character varying) TO "service_role"; + +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[]) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[]) FROM "anon"; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[]) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[]) FROM "service_role"; +GRANT ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[]) TO "authenticated"; +GRANT ALL ON FUNCTION public.is_paying_and_good_plan_org_action(uuid,public.action_type[]) TO "service_role"; + +REVOKE ALL ON FUNCTION public.is_platform_admin() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_platform_admin() FROM "anon"; +REVOKE ALL ON FUNCTION public.is_platform_admin() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.is_platform_admin() FROM "service_role"; +GRANT ALL ON FUNCTION public.is_platform_admin() TO "authenticated"; +GRANT ALL ON FUNCTION public.is_platform_admin() TO "service_role"; + +REVOKE ALL ON FUNCTION public.is_platform_admin(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_platform_admin(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.is_platform_admin(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.is_platform_admin(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.is_platform_admin(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.is_user_app_admin(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_user_app_admin(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.is_user_app_admin(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.is_user_app_admin(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.is_user_app_admin(uuid,uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.is_user_app_admin(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.is_user_org_admin(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_user_org_admin(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.is_user_org_admin(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.is_user_org_admin(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.is_user_org_admin(uuid,uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.is_user_org_admin(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.mark_app_stats_refreshed(character varying) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.mark_app_stats_refreshed(character varying) FROM "anon"; +REVOKE ALL ON FUNCTION public.mark_app_stats_refreshed(character varying) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.mark_app_stats_refreshed(character varying) FROM "service_role"; +GRANT ALL ON FUNCTION public.mark_app_stats_refreshed(character varying) TO "service_role"; + +REVOKE ALL ON FUNCTION public.mass_edit_queue_messages_cf_ids(public.message_update[]) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.mass_edit_queue_messages_cf_ids(public.message_update[]) FROM "anon"; +REVOKE ALL ON FUNCTION public.mass_edit_queue_messages_cf_ids(public.message_update[]) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.mass_edit_queue_messages_cf_ids(public.message_update[]) FROM "service_role"; + +REVOKE ALL ON FUNCTION public.modify_permissions_tmp(text,uuid,public.user_min_right) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.modify_permissions_tmp(text,uuid,public.user_min_right) FROM "anon"; +REVOKE ALL ON FUNCTION public.modify_permissions_tmp(text,uuid,public.user_min_right) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.modify_permissions_tmp(text,uuid,public.user_min_right) FROM "service_role"; +GRANT ALL ON FUNCTION public.modify_permissions_tmp(text,uuid,public.user_min_right) TO "authenticated"; +GRANT ALL ON FUNCTION public.modify_permissions_tmp(text,uuid,public.user_min_right) TO "service_role"; + +REVOKE ALL ON FUNCTION public.normalize_public_channel_overlap() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.normalize_public_channel_overlap() FROM "anon"; +REVOKE ALL ON FUNCTION public.normalize_public_channel_overlap() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.normalize_public_channel_overlap() FROM "service_role"; +GRANT ALL ON FUNCTION public.normalize_public_channel_overlap() TO "service_role"; + +REVOKE ALL ON FUNCTION public.normalize_sso_provider_domain() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.normalize_sso_provider_domain() FROM "anon"; +REVOKE ALL ON FUNCTION public.normalize_sso_provider_domain() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.normalize_sso_provider_domain() FROM "service_role"; +GRANT ALL ON FUNCTION public.normalize_sso_provider_domain() TO "service_role"; + +REVOKE ALL ON FUNCTION public.noupdate() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.noupdate() FROM "anon"; +REVOKE ALL ON FUNCTION public.noupdate() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.noupdate() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.pg_log(text,jsonb) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.pg_log(text,jsonb) FROM "anon"; +REVOKE ALL ON FUNCTION public.pg_log(text,jsonb) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.pg_log(text,jsonb) FROM "service_role"; + +REVOKE ALL ON FUNCTION public.prevent_last_super_admin_binding_delete() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.prevent_last_super_admin_binding_delete() FROM "anon"; +REVOKE ALL ON FUNCTION public.prevent_last_super_admin_binding_delete() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.prevent_last_super_admin_binding_delete() FROM "service_role"; +GRANT ALL ON FUNCTION public.prevent_last_super_admin_binding_delete() TO "service_role"; + +REVOKE ALL ON FUNCTION public.prevent_last_super_admin_binding_update() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.prevent_last_super_admin_binding_update() FROM "anon"; +REVOKE ALL ON FUNCTION public.prevent_last_super_admin_binding_update() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.prevent_last_super_admin_binding_update() FROM "service_role"; +GRANT ALL ON FUNCTION public.prevent_last_super_admin_binding_update() TO "service_role"; + +REVOKE ALL ON FUNCTION public.process_admin_stats() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_admin_stats() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_admin_stats() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_admin_stats() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.process_all_cron_tasks() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_all_cron_tasks() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_all_cron_tasks() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_all_cron_tasks() FROM "service_role"; +GRANT ALL ON FUNCTION public.process_all_cron_tasks() TO "service_role"; + +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.process_channel_device_counts_queue(integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_channel_device_counts_queue(integer) FROM "anon"; +REVOKE ALL ON FUNCTION public.process_channel_device_counts_queue(integer) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_channel_device_counts_queue(integer) FROM "service_role"; +GRANT ALL ON FUNCTION public.process_channel_device_counts_queue(integer) TO "service_role"; + +REVOKE ALL ON FUNCTION public.process_cron_stats_jobs() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_cron_stats_jobs() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_cron_stats_jobs() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_cron_stats_jobs() FROM "service_role"; +GRANT ALL ON FUNCTION public.process_cron_stats_jobs() TO "service_role"; + +REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_cron_sync_sub_jobs() FROM "service_role"; +GRANT ALL ON FUNCTION public.process_cron_sync_sub_jobs() TO "service_role"; + +REVOKE ALL ON FUNCTION public.process_deploy_install_stats_email() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_deploy_install_stats_email() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_deploy_install_stats_email() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_deploy_install_stats_email() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.process_failed_uploads() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_failed_uploads() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_failed_uploads() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_failed_uploads() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.process_free_trial_expired() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_free_trial_expired() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_free_trial_expired() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_free_trial_expired() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.process_function_queue(text,integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_function_queue(text,integer) FROM "anon"; +REVOKE ALL ON FUNCTION public.process_function_queue(text,integer) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_function_queue(text,integer) FROM "service_role"; + +REVOKE ALL ON FUNCTION public.process_function_queue(text[],integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_function_queue(text[],integer) FROM "anon"; +REVOKE ALL ON FUNCTION public.process_function_queue(text[],integer) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_function_queue(text[],integer) FROM "service_role"; + +REVOKE ALL ON FUNCTION public.process_queue_with_healthcheck(text[],integer,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_queue_with_healthcheck(text[],integer,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.process_queue_with_healthcheck(text[],integer,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_queue_with_healthcheck(text[],integer,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.process_queue_with_healthcheck(text[],integer,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.process_stats_email_monthly() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_stats_email_monthly() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_stats_email_monthly() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_stats_email_monthly() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.process_stats_email_weekly() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_stats_email_weekly() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_stats_email_weekly() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_stats_email_weekly() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.process_subscribed_orgs() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_subscribed_orgs() FROM "anon"; +REVOKE ALL ON FUNCTION public.process_subscribed_orgs() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.process_subscribed_orgs() FROM "service_role"; +GRANT ALL ON FUNCTION public.process_subscribed_orgs() TO "service_role"; + +REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.queue_cron_stat_app_for_app(character varying,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_check_permission_direct_no_password_policy(text,uuid,uuid,character varying,bigint,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_check_permission_direct_no_password_policy(text,uuid,uuid,character varying,bigint,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_check_permission_direct_no_password_policy(text,uuid,uuid,character varying,bigint,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_check_permission_direct_no_password_policy(text,uuid,uuid,character varying,bigint,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_check_permission_direct_no_password_policy(text,uuid,uuid,character varying,bigint,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.rbac_check_permission_direct_no_password_policy(text,uuid,uuid,character varying,bigint,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text,uuid,uuid,character varying,bigint,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text,uuid,uuid,character varying,bigint,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text,uuid,uuid,character varying,bigint,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text,uuid,uuid,character varying,bigint,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_check_permission_direct(text,uuid,uuid,character varying,bigint,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.rbac_check_permission_direct(text,uuid,uuid,character varying,bigint,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_check_permission_no_password_policy(text,uuid,character varying,bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_check_permission_no_password_policy(text,uuid,character varying,bigint) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_check_permission_no_password_policy(text,uuid,character varying,bigint) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_check_permission_no_password_policy(text,uuid,character varying,bigint) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_check_permission_no_password_policy(text,uuid,character varying,bigint) TO "authenticated"; +GRANT ALL ON FUNCTION public.rbac_check_permission_no_password_policy(text,uuid,character varying,bigint) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_check_permission(text,uuid,character varying,bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_check_permission(text,uuid,character varying,bigint) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_check_permission(text,uuid,character varying,bigint) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_check_permission(text,uuid,character varying,bigint) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_check_permission(text,uuid,character varying,bigint) TO "authenticated"; +GRANT ALL ON FUNCTION public.rbac_check_permission(text,uuid,character varying,bigint) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_enable_for_org(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_enable_for_org(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_enable_for_org(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_enable_for_org(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_enable_for_org(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_has_permission(text,uuid,text,uuid,character varying,bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_has_permission(text,uuid,text,uuid,character varying,bigint) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_has_permission(text,uuid,text,uuid,character varying,bigint) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_has_permission(text,uuid,text,uuid,character varying,bigint) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_has_permission(text,uuid,text,uuid,character varying,bigint) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_is_enabled_for_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_is_enabled_for_org(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_is_enabled_for_org(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_is_enabled_for_org(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_is_enabled_for_org(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.rbac_is_enabled_for_org(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_legacy_role_hint(public.user_min_right,character varying,bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_legacy_role_hint(public.user_min_right,character varying,bigint) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_legacy_role_hint(public.user_min_right,character varying,bigint) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_legacy_role_hint(public.user_min_right,character varying,bigint) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_legacy_role_hint(public.user_min_right,character varying,bigint) TO "authenticated"; +GRANT ALL ON FUNCTION public.rbac_legacy_role_hint(public.user_min_right,character varying,bigint) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_migrate_org_users_to_bindings(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_org_role_for_legacy_right(public.user_min_right) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_org_role_for_legacy_right(public.user_min_right) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_org_role_for_legacy_right(public.user_min_right) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_org_role_for_legacy_right(public.user_min_right) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_org_role_for_legacy_right(public.user_min_right) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_permission_for_legacy(public.user_min_right,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_permission_for_legacy(public.user_min_right,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_permission_for_legacy(public.user_min_right,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_permission_for_legacy(public.user_min_right,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_permission_for_legacy(public.user_min_right,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.rbac_permission_for_legacy(public.user_min_right,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_preview_migration(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_preview_migration(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_preview_migration(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_preview_migration(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_preview_migration(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.rbac_preview_migration(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.rbac_rollback_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rbac_rollback_org(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.rbac_rollback_org(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rbac_rollback_org(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.rbac_rollback_org(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.reassign_webhook_created_by_before_user_delete() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.reassign_webhook_created_by_before_user_delete() FROM "anon"; +REVOKE ALL ON FUNCTION public.reassign_webhook_created_by_before_user_delete() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.reassign_webhook_created_by_before_user_delete() FROM "service_role"; +GRANT ALL ON FUNCTION public.reassign_webhook_created_by_before_user_delete() TO "service_role"; + +REVOKE ALL ON FUNCTION public.record_build_time(uuid,uuid,character varying,character varying,bigint,character varying) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.record_build_time(uuid,uuid,character varying,character varying,bigint,character varying) FROM "anon"; +REVOKE ALL ON FUNCTION public.record_build_time(uuid,uuid,character varying,character varying,bigint,character varying) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.record_build_time(uuid,uuid,character varying,character varying,bigint,character varying) FROM "service_role"; +GRANT ALL ON FUNCTION public.record_build_time(uuid,uuid,character varying,character varying,bigint,character varying) TO "service_role"; + +REVOKE ALL ON FUNCTION public.record_deployment_history() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.record_deployment_history() FROM "anon"; +REVOKE ALL ON FUNCTION public.record_deployment_history() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.record_deployment_history() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.record_email_otp_verified(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.record_email_otp_verified(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.record_email_otp_verified(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.record_email_otp_verified(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.record_email_otp_verified(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.refresh_app_rollups_after_demo_reset(uuid,text,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.refresh_app_rollups_after_demo_reset(uuid,text,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.refresh_app_rollups_after_demo_reset(uuid,text,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.refresh_app_rollups_after_demo_reset(uuid,text,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.refresh_app_rollups_after_demo_reset(uuid,text,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.refresh_orgs_has_usage_credits() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.refresh_orgs_has_usage_credits() FROM "anon"; +REVOKE ALL ON FUNCTION public.refresh_orgs_has_usage_credits() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.refresh_orgs_has_usage_credits() FROM "service_role"; +GRANT ALL ON FUNCTION public.refresh_orgs_has_usage_credits() TO "service_role"; + +REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.reject_access_due_to_2fa(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.reject_access_due_to_password_policy(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.reject_access_due_to_password_policy(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.reject_access_due_to_password_policy(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.reject_access_due_to_password_policy(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.reject_access_due_to_password_policy(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.remove_old_jobs() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.remove_old_jobs() FROM "anon"; +REVOKE ALL ON FUNCTION public.remove_old_jobs() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.remove_old_jobs() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.request_app_chart_refresh(character varying) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.request_app_chart_refresh(character varying) FROM "anon"; +REVOKE ALL ON FUNCTION public.request_app_chart_refresh(character varying) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.request_app_chart_refresh(character varying) FROM "service_role"; +GRANT ALL ON FUNCTION public.request_app_chart_refresh(character varying) TO "authenticated"; +GRANT ALL ON FUNCTION public.request_app_chart_refresh(character varying) TO "service_role"; + +REVOKE ALL ON FUNCTION public.request_has_app_read_access(uuid,character varying) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.request_has_app_read_access(uuid,character varying) FROM "anon"; +REVOKE ALL ON FUNCTION public.request_has_app_read_access(uuid,character varying) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.request_has_app_read_access(uuid,character varying) FROM "service_role"; +GRANT ALL ON FUNCTION public.request_has_app_read_access(uuid,character varying) TO "service_role"; + +REVOKE ALL ON FUNCTION public.request_has_org_read_access(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.request_has_org_read_access(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.request_has_org_read_access(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.request_has_org_read_access(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.request_has_org_read_access(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.request_org_chart_refresh(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.request_org_chart_refresh(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.request_org_chart_refresh(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.request_org_chart_refresh(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.request_org_chart_refresh(uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.request_org_chart_refresh(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.request_read_key_modes() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.request_read_key_modes() FROM "anon"; +REVOKE ALL ON FUNCTION public.request_read_key_modes() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.request_read_key_modes() FROM "service_role"; +GRANT ALL ON FUNCTION public.request_read_key_modes() TO "service_role"; + +REVOKE ALL ON FUNCTION public.rescind_invitation(text,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.rescind_invitation(text,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.rescind_invitation(text,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.rescind_invitation(text,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.rescind_invitation(text,uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.rescind_invitation(text,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.reset_onboarding_demo_app_data(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.reset_onboarding_demo_app_data(uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.reset_onboarding_demo_app_data(uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.reset_onboarding_demo_app_data(uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.reset_onboarding_demo_app_data(uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.restore_deleted_account() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.restore_deleted_account() FROM "anon"; +REVOKE ALL ON FUNCTION public.restore_deleted_account() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.restore_deleted_account() FROM "service_role"; +GRANT ALL ON FUNCTION public.restore_deleted_account() TO "authenticated"; +GRANT ALL ON FUNCTION public.restore_deleted_account() TO "service_role"; + +REVOKE ALL ON FUNCTION public.resync_org_user_role_bindings(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.resync_org_user_role_bindings(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.resync_org_user_role_bindings(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.resync_org_user_role_bindings(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.resync_org_user_role_bindings(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM "anon"; +REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM "service_role"; +GRANT ALL ON FUNCTION public.sanitize_apps_text_fields() TO "service_role"; + +REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM "anon"; +REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM "service_role"; +GRANT ALL ON FUNCTION public.sanitize_orgs_text_fields() TO "service_role"; + +REVOKE ALL ON FUNCTION public.sanitize_tmp_users_text_fields() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sanitize_tmp_users_text_fields() FROM "anon"; +REVOKE ALL ON FUNCTION public.sanitize_tmp_users_text_fields() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.sanitize_tmp_users_text_fields() FROM "service_role"; +GRANT ALL ON FUNCTION public.sanitize_tmp_users_text_fields() TO "service_role"; + +REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM "anon"; +REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM "service_role"; +GRANT ALL ON FUNCTION public.sanitize_users_text_fields() TO "service_role"; + +REVOKE ALL ON FUNCTION public.seed_get_app_metrics_caches(uuid,date,date) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.seed_get_app_metrics_caches(uuid,date,date) FROM "anon"; +REVOKE ALL ON FUNCTION public.seed_get_app_metrics_caches(uuid,date,date) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.seed_get_app_metrics_caches(uuid,date,date) FROM "service_role"; + +REVOKE ALL ON FUNCTION public.seed_org_metrics_cache(uuid,date,date) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.seed_org_metrics_cache(uuid,date,date) FROM "anon"; +REVOKE ALL ON FUNCTION public.seed_org_metrics_cache(uuid,date,date) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.seed_org_metrics_cache(uuid,date,date) FROM "service_role"; + +REVOKE ALL ON FUNCTION public.set_build_time_exceeded_by_org(uuid,boolean) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.set_build_time_exceeded_by_org(uuid,boolean) FROM "anon"; +REVOKE ALL ON FUNCTION public.set_build_time_exceeded_by_org(uuid,boolean) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.set_build_time_exceeded_by_org(uuid,boolean) FROM "service_role"; +GRANT ALL ON FUNCTION public.set_build_time_exceeded_by_org(uuid,boolean) TO "service_role"; + +REVOKE ALL ON FUNCTION public.set_webhook_created_by() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.set_webhook_created_by() FROM "anon"; +REVOKE ALL ON FUNCTION public.set_webhook_created_by() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.set_webhook_created_by() FROM "service_role"; +GRANT ALL ON FUNCTION public.set_webhook_created_by() TO "service_role"; + +REVOKE ALL ON FUNCTION public.sync_org_has_usage_credits_from_grants() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sync_org_has_usage_credits_from_grants() FROM "anon"; +REVOKE ALL ON FUNCTION public.sync_org_has_usage_credits_from_grants() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.sync_org_has_usage_credits_from_grants() FROM "service_role"; +GRANT ALL ON FUNCTION public.sync_org_has_usage_credits_from_grants() TO "service_role"; + +REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_delete() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_delete() FROM "anon"; +REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_delete() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_delete() FROM "service_role"; +GRANT ALL ON FUNCTION public.sync_org_user_role_binding_on_delete() TO "service_role"; + +REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_update() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_update() FROM "anon"; +REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_update() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_update() FROM "service_role"; +GRANT ALL ON FUNCTION public.sync_org_user_role_binding_on_update() TO "service_role"; + +REVOKE ALL ON FUNCTION public.sync_org_user_to_role_binding() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sync_org_user_to_role_binding() FROM "anon"; +REVOKE ALL ON FUNCTION public.sync_org_user_to_role_binding() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.sync_org_user_to_role_binding() FROM "service_role"; +GRANT ALL ON FUNCTION public.sync_org_user_to_role_binding() TO "service_role"; + +REVOKE ALL ON FUNCTION public.top_up_usage_credits(uuid,numeric,timestamp with time zone,text,jsonb,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.top_up_usage_credits(uuid,numeric,timestamp with time zone,text,jsonb,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.top_up_usage_credits(uuid,numeric,timestamp with time zone,text,jsonb,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.top_up_usage_credits(uuid,numeric,timestamp with time zone,text,jsonb,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.top_up_usage_credits(uuid,numeric,timestamp with time zone,text,jsonb,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.total_bundle_storage_bytes() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.total_bundle_storage_bytes() FROM "anon"; +REVOKE ALL ON FUNCTION public.total_bundle_storage_bytes() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.total_bundle_storage_bytes() FROM "service_role"; +GRANT ALL ON FUNCTION public.total_bundle_storage_bytes() TO "service_role"; + +REVOKE ALL ON FUNCTION public.track_onboarding_demo_data(text,uuid,text,text[],uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.track_onboarding_demo_data(text,uuid,text,text[],uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.track_onboarding_demo_data(text,uuid,text,text[],uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.track_onboarding_demo_data(text,uuid,text,text[],uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.track_onboarding_demo_data(text,uuid,text,text[],uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.transfer_app(character varying,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.transfer_app(character varying,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.transfer_app(character varying,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.transfer_app(character varying,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.transfer_app(character varying,uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.transfer_app(character varying,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.trigger_http_queue_post_to_function() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.trigger_http_queue_post_to_function() FROM "anon"; +REVOKE ALL ON FUNCTION public.trigger_http_queue_post_to_function() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.trigger_http_queue_post_to_function() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.trigger_webhook_on_audit_log() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.trigger_webhook_on_audit_log() FROM "anon"; +REVOKE ALL ON FUNCTION public.trigger_webhook_on_audit_log() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.trigger_webhook_on_audit_log() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.update_app_versions_retention() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.update_app_versions_retention() FROM "anon"; +REVOKE ALL ON FUNCTION public.update_app_versions_retention() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.update_app_versions_retention() FROM "service_role"; +GRANT ALL ON FUNCTION public.update_app_versions_retention() TO "service_role"; + +REVOKE ALL ON FUNCTION public.update_apps_build_timeout_updated_at() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.update_apps_build_timeout_updated_at() FROM "anon"; +REVOKE ALL ON FUNCTION public.update_apps_build_timeout_updated_at() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.update_apps_build_timeout_updated_at() FROM "service_role"; +GRANT ALL ON FUNCTION public.update_apps_build_timeout_updated_at() TO "service_role"; + +REVOKE ALL ON FUNCTION public.update_org_invite_role_rbac(uuid,uuid,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.update_org_invite_role_rbac(uuid,uuid,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.update_org_invite_role_rbac(uuid,uuid,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.update_org_invite_role_rbac(uuid,uuid,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.update_org_invite_role_rbac(uuid,uuid,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.update_org_invite_role_rbac(uuid,uuid,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.update_org_member_role(uuid,uuid,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.update_org_member_role(uuid,uuid,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.update_org_member_role(uuid,uuid,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.update_org_member_role(uuid,uuid,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.update_org_member_role(uuid,uuid,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.update_org_member_role(uuid,uuid,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.update_tmp_invite_role_rbac(uuid,text,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.update_tmp_invite_role_rbac(uuid,text,text) FROM "anon"; +REVOKE ALL ON FUNCTION public.update_tmp_invite_role_rbac(uuid,text,text) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.update_tmp_invite_role_rbac(uuid,text,text) FROM "service_role"; +GRANT ALL ON FUNCTION public.update_tmp_invite_role_rbac(uuid,text,text) TO "authenticated"; +GRANT ALL ON FUNCTION public.update_tmp_invite_role_rbac(uuid,text,text) TO "service_role"; + +REVOKE ALL ON FUNCTION public.update_webhook_updated_at() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.update_webhook_updated_at() FROM "anon"; +REVOKE ALL ON FUNCTION public.update_webhook_updated_at() FROM "authenticated"; +REVOKE ALL ON FUNCTION public.update_webhook_updated_at() FROM "service_role"; + +REVOKE ALL ON FUNCTION public.upsert_version_meta(character varying,bigint,bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.upsert_version_meta(character varying,bigint,bigint) FROM "anon"; +REVOKE ALL ON FUNCTION public.upsert_version_meta(character varying,bigint,bigint) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.upsert_version_meta(character varying,bigint,bigint) FROM "service_role"; +GRANT ALL ON FUNCTION public.upsert_version_meta(character varying,bigint,bigint) TO "service_role"; + +REVOKE ALL ON FUNCTION public.user_has_app_update_user_roles(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.user_has_app_update_user_roles(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.user_has_app_update_user_roles(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.user_has_app_update_user_roles(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.user_has_app_update_user_roles(uuid,uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.user_has_app_update_user_roles(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.user_has_role_in_app(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.user_has_role_in_app(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.user_has_role_in_app(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.user_has_role_in_app(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.user_has_role_in_app(uuid,uuid) TO "authenticated"; +GRANT ALL ON FUNCTION public.user_has_role_in_app(uuid,uuid) TO "service_role"; + +REVOKE ALL ON FUNCTION public.user_meets_password_policy(uuid,uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.user_meets_password_policy(uuid,uuid) FROM "anon"; +REVOKE ALL ON FUNCTION public.user_meets_password_policy(uuid,uuid) FROM "authenticated"; +REVOKE ALL ON FUNCTION public.user_meets_password_policy(uuid,uuid) FROM "service_role"; +GRANT ALL ON FUNCTION public.user_meets_password_policy(uuid,uuid) TO "service_role"; + +REVOKE ALL ON TABLE public.apikey_global_permissions FROM PUBLIC; +REVOKE ALL ON TABLE public.apikey_global_permissions FROM "anon"; +REVOKE ALL ON TABLE public.apikey_global_permissions FROM "authenticated"; +REVOKE ALL ON TABLE public.apikey_global_permissions FROM "service_role"; +GRANT DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE public.apikey_global_permissions TO "service_role"; + +REVOKE ALL ON TABLE public.audit_logs FROM PUBLIC; +REVOKE ALL ON TABLE public.audit_logs FROM "anon"; +REVOKE ALL ON TABLE public.audit_logs FROM "authenticated"; +REVOKE ALL ON TABLE public.audit_logs FROM "service_role"; +GRANT SELECT ON TABLE public.audit_logs TO "anon"; +GRANT DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE public.audit_logs TO "authenticated"; +GRANT DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE public.audit_logs TO "service_role"; + +REVOKE ALL ON TABLE public.daily_revenue_metrics FROM PUBLIC; +REVOKE ALL ON TABLE public.daily_revenue_metrics FROM "anon"; +REVOKE ALL ON TABLE public.daily_revenue_metrics FROM "authenticated"; +REVOKE ALL ON TABLE public.daily_revenue_metrics FROM "service_role"; +GRANT DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE public.daily_revenue_metrics TO "service_role"; + +REVOKE ALL ON TABLE public.global_stats FROM PUBLIC; +REVOKE ALL ON TABLE public.global_stats FROM "anon"; +REVOKE ALL ON TABLE public.global_stats FROM "authenticated"; +REVOKE ALL ON TABLE public.global_stats FROM "service_role"; +GRANT DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE public.global_stats TO "service_role"; + +REVOKE ALL ON TABLE public.onboarding_demo_data FROM PUBLIC; +REVOKE ALL ON TABLE public.onboarding_demo_data FROM "anon"; +REVOKE ALL ON TABLE public.onboarding_demo_data FROM "authenticated"; +REVOKE ALL ON TABLE public.onboarding_demo_data FROM "service_role"; +GRANT DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE public.onboarding_demo_data TO "service_role"; + +REVOKE ALL ON TABLE public.processed_stripe_events FROM PUBLIC; +REVOKE ALL ON TABLE public.processed_stripe_events FROM "anon"; +REVOKE ALL ON TABLE public.processed_stripe_events FROM "authenticated"; +REVOKE ALL ON TABLE public.processed_stripe_events FROM "service_role"; +GRANT DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE public.processed_stripe_events TO "service_role"; + +REVOKE ALL ON TABLE public.webhook_deliveries FROM PUBLIC; +REVOKE ALL ON TABLE public.webhook_deliveries FROM "anon"; +REVOKE ALL ON TABLE public.webhook_deliveries FROM "authenticated"; +REVOKE ALL ON TABLE public.webhook_deliveries FROM "service_role"; +GRANT DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE public.webhook_deliveries TO "service_role"; + +REVOKE ALL ON TABLE public.webhooks FROM PUBLIC; +REVOKE ALL ON TABLE public.webhooks FROM "anon"; +REVOKE ALL ON TABLE public.webhooks FROM "authenticated"; +REVOKE ALL ON TABLE public.webhooks FROM "service_role"; +GRANT DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE public.webhooks TO "service_role"; + +REVOKE ALL ON SEQUENCE public.audit_logs_id_seq FROM PUBLIC; +REVOKE ALL ON SEQUENCE public.audit_logs_id_seq FROM "anon"; +REVOKE ALL ON SEQUENCE public.audit_logs_id_seq FROM "authenticated"; +REVOKE ALL ON SEQUENCE public.audit_logs_id_seq FROM "service_role"; +GRANT SELECT, UPDATE, USAGE ON SEQUENCE public.audit_logs_id_seq TO "authenticated"; +GRANT SELECT, UPDATE, USAGE ON SEQUENCE public.audit_logs_id_seq TO "service_role"; +-- +-- Retained setup/data from historical migrations. +-- `supabase migration squash` produces a schema-only dump, so operational +-- queues, cron metadata, and static RBAC rows are restored explicitly here. +-- + +DO $$ +DECLARE + target_queue text; +BEGIN + FOREACH target_queue IN ARRAY ARRAY[ + 'admin_stats', + 'channel_device_counts', + 'credit_usage_alerts', + 'cron_clean_orphan_images', + 'cron_clear_versions', + 'cron_email', + 'cron_reconcile_build_status', + 'cron_stat_app', + 'cron_stat_org', + 'cron_sync_sub', + 'manifest_bundle_counts', + 'on_app_create', + 'on_app_delete', + 'on_app_update', + 'on_channel_update', + 'on_deploy_history_create', + 'on_manifest_create', + 'on_org_update', + 'on_organization_create', + 'on_organization_delete', + 'on_user_create', + 'on_user_delete', + 'on_user_update', + 'on_version_create', + 'on_version_delete', + 'on_version_update', + 'webhook_delivery', + 'webhook_dispatcher' + ] LOOP + IF NOT EXISTS ( + SELECT 1 + FROM pgmq.list_queues() AS queues + WHERE queues.queue_name = target_queue + ) THEN + PERFORM pgmq.create(target_queue); + END IF; + END LOOP; +END $$; + +INSERT INTO "public"."capgo_credits_steps" ( + "id", + "step_min", + "step_max", + "price_per_unit", + "type", + "unit_factor", + "org_id" +) +VALUES + (1, 0, 6000, 0.16, 'build_time', 60, NULL), + (2, 6000, 30000, 0.14, 'build_time', 60, NULL), + (3, 30000, 60000, 0.12, 'build_time', 60, NULL), + (4, 60000, 300000, 0.1, 'build_time', 60, NULL), + (5, 300000, 600000, 0.09, 'build_time', 60, NULL), + (6, 600000, 9223372036854775807, 0.08, 'build_time', 60, NULL) +ON CONFLICT ("id") DO UPDATE +SET + "step_min" = EXCLUDED."step_min", + "step_max" = EXCLUDED."step_max", + "price_per_unit" = EXCLUDED."price_per_unit", + "type" = EXCLUDED."type", + "unit_factor" = EXCLUDED."unit_factor", + "org_id" = EXCLUDED."org_id"; + +SELECT pg_catalog.setval('"public"."capgo_credits_steps_id_seq"', 6, true); + +INSERT INTO "public"."permissions" ("key", "scope_type", "description") +VALUES + ('app.build_native', 'app', 'Trigger native builds'), + ('app.create_channel', 'app', 'Create channels'), + ('app.delete', 'app', 'Delete an app'), + ('app.manage_devices', 'app', 'Manage devices at app scope'), + ('app.read', 'app', 'Read app metadata'), + ('app.read_audit', 'app', 'Read app-level audit trail'), + ('app.read_bundles', 'app', 'Read app bundle metadata'), + ('app.read_channels', 'app', 'List/read channels'), + ('app.read_devices', 'app', 'Read devices at app scope'), + ('app.read_logs', 'app', 'Read app logs/metrics'), + ('app.transfer', 'app', 'Transfer app to another organization'), + ('app.update_settings', 'app', 'Update app settings'), + ('app.update_user_roles', 'app', 'Update user roles for this app'), + ('app.upload_bundle', 'app', 'Upload a bundle'), + ('bundle.delete', 'app', 'Delete a bundle'), + ('channel.delete', 'channel', 'Delete a channel'), + ('channel.manage_forced_devices', 'channel', 'Manage forced devices'), + ('channel.promote_bundle', 'channel', 'Promote bundle to channel'), + ('channel.read', 'channel', 'Read channel metadata'), + ('channel.read_audit', 'channel', 'Read channel-level audit'), + ('channel.read_forced_devices', 'channel', 'Read forced devices'), + ('channel.read_history', 'channel', 'Read deploy history'), + ('channel.rollback_bundle', 'channel', 'Rollback bundle on channel'), + ('channel.update_settings', 'channel', 'Update channel settings'), + ('org.create_app', 'org', 'Create a new app within an organization'), + ('org.delete', 'org', 'Delete an organization'), + ('org.invite_user', 'org', 'Invite or add members to org'), + ('org.read', 'org', 'Read org level settings and metadata'), + ('org.read_audit', 'org', 'Read org-level audit trail'), + ('org.read_billing', 'org', 'Read org billing settings'), + ('org.read_billing_audit', 'org', 'Read billing/audit details'), + ('org.read_invoices', 'org', 'Read invoices'), + ('org.read_members', 'org', 'Read org membership list'), + ('org.update_billing', 'org', 'Update org billing settings'), + ('org.update_settings', 'org', 'Update org configuration/settings'), + ('org.update_user_roles', 'org', 'Change org/member roles') +ON CONFLICT ("key") DO UPDATE +SET + "scope_type" = EXCLUDED."scope_type", + "description" = EXCLUDED."description"; + +INSERT INTO "public"."roles" ("name", "scope_type", "description", "priority_rank", "is_assignable", "created_by") +VALUES + ('apikey_org_reader', 'org', 'API key compatibility role: org metadata read only', 10, false, NULL), + ('app_admin', 'app', 'Full administration of an app', 70, true, NULL), + ('app_developer', 'app', 'Developer access: upload bundles, manage devices, but no destructive operations', 68, true, NULL), + ('app_reader', 'app', 'Read-only access to an app', 65, true, NULL), + ('app_uploader', 'app', 'Upload-only access: read app data and upload bundles', 66, true, NULL), + ('bundle_admin', 'bundle', 'Full administration of a bundle', 62, true, NULL), + ('bundle_reader', 'bundle', 'Read-only access to a bundle', 61, true, NULL), + ('channel_admin', 'channel', 'Full administration of a channel', 60, true, NULL), + ('channel_reader', 'channel', 'Read-only access to a channel', 55, true, NULL), + ('org_admin', 'org', 'Full org administration', 90, true, NULL), + ('org_billing_admin', 'org', 'Billing-only administrator for an org', 80, true, NULL), + ('org_member', 'org', 'Basic org member: org-only access', 75, true, NULL), + ('org_super_admin', 'org', 'Super admin for an org (same permissions as org_admin)', 95, true, NULL) +ON CONFLICT ("name") DO UPDATE +SET + "scope_type" = EXCLUDED."scope_type", + "description" = EXCLUDED."description", + "priority_rank" = EXCLUDED."priority_rank", + "is_assignable" = EXCLUDED."is_assignable", + "created_by" = EXCLUDED."created_by"; + +INSERT INTO "public"."role_permissions" ("role_id", "permission_id") +SELECT roles.id, permissions.id +FROM ( + VALUES + ('apikey_org_reader', ARRAY['org.read']), + ('app_admin', ARRAY[ + 'app.build_native', 'app.create_channel', 'app.manage_devices', 'app.read', + 'app.read_audit', 'app.read_bundles', 'app.read_channels', 'app.read_devices', + 'app.read_logs', 'app.update_settings', 'app.update_user_roles', 'app.upload_bundle', + 'bundle.delete', 'channel.delete', 'channel.manage_forced_devices', + 'channel.promote_bundle', 'channel.read', 'channel.read_audit', + 'channel.read_forced_devices', 'channel.read_history', 'channel.rollback_bundle', + 'channel.update_settings' + ]), + ('app_developer', ARRAY[ + 'app.build_native', 'app.manage_devices', 'app.read', 'app.read_audit', + 'app.read_bundles', 'app.read_channels', 'app.read_devices', 'app.read_logs', + 'app.upload_bundle', 'channel.manage_forced_devices', 'channel.promote_bundle', + 'channel.read', 'channel.read_audit', 'channel.read_forced_devices', + 'channel.read_history', 'channel.rollback_bundle', 'channel.update_settings' + ]), + ('app_reader', ARRAY[ + 'app.read', 'app.read_audit', 'app.read_bundles', 'app.read_channels', + 'app.read_devices', 'app.read_logs' + ]), + ('app_uploader', ARRAY[ + 'app.read', 'app.read_audit', 'app.read_bundles', 'app.read_channels', + 'app.read_devices', 'app.read_logs', 'app.upload_bundle', + 'channel.promote_bundle', 'channel.read', 'channel.read_history' + ]), + ('bundle_admin', ARRAY['bundle.delete']), + ('channel_admin', ARRAY[ + 'channel.delete', 'channel.manage_forced_devices', 'channel.promote_bundle', + 'channel.read', 'channel.read_audit', 'channel.read_forced_devices', + 'channel.read_history', 'channel.rollback_bundle', 'channel.update_settings' + ]), + ('channel_reader', ARRAY[ + 'channel.read', 'channel.read_audit', 'channel.read_forced_devices', + 'channel.read_history' + ]), + ('org_admin', ARRAY[ + 'app.build_native', 'app.create_channel', 'app.manage_devices', 'app.read', + 'app.read_audit', 'app.read_bundles', 'app.read_channels', 'app.read_devices', + 'app.read_logs', 'app.update_settings', 'app.update_user_roles', + 'app.upload_bundle', 'channel.manage_forced_devices', 'channel.promote_bundle', + 'channel.read', 'channel.read_audit', 'channel.read_forced_devices', + 'channel.read_history', 'channel.rollback_bundle', 'channel.update_settings', + 'org.create_app', 'org.invite_user', 'org.read', 'org.read_audit', + 'org.read_billing', 'org.read_billing_audit', 'org.read_invoices', + 'org.read_members', 'org.update_settings', 'org.update_user_roles' + ]), + ('org_billing_admin', ARRAY[ + 'org.create_app', 'org.read', 'org.read_billing', 'org.read_billing_audit', + 'org.read_invoices', 'org.update_billing' + ]), + ('org_member', ARRAY['org.create_app', 'org.read', 'org.read_members']), + ('org_super_admin', ARRAY[ + 'app.build_native', 'app.create_channel', 'app.delete', 'app.manage_devices', + 'app.read', 'app.read_audit', 'app.read_bundles', 'app.read_channels', + 'app.read_devices', 'app.read_logs', 'app.transfer', 'app.update_settings', + 'app.update_user_roles', 'app.upload_bundle', 'bundle.delete', + 'channel.delete', 'channel.manage_forced_devices', 'channel.promote_bundle', + 'channel.read', 'channel.read_audit', 'channel.read_forced_devices', + 'channel.read_history', 'channel.rollback_bundle', 'channel.update_settings', + 'org.create_app', 'org.delete', 'org.invite_user', 'org.read', + 'org.read_audit', 'org.read_billing', 'org.read_billing_audit', + 'org.read_invoices', 'org.read_members', 'org.update_billing', + 'org.update_settings', 'org.update_user_roles' + ]) +) AS role_permission_sets(role_name, permission_keys) +JOIN "public"."roles" roles + ON roles."name" = role_permission_sets.role_name +JOIN LATERAL unnest(role_permission_sets.permission_keys) AS permission_key("key") + ON true +JOIN "public"."permissions" permissions + ON permissions."key" = permission_key."key" +ON CONFLICT DO NOTHING; + +INSERT INTO "public"."role_hierarchy" ("parent_role_id", "child_role_id") +SELECT parent_roles.id, child_roles.id +FROM ( + VALUES + ('app_admin', 'app_developer'), + ('app_admin', 'bundle_admin'), + ('app_admin', 'channel_admin'), + ('app_developer', 'app_uploader'), + ('app_uploader', 'app_reader'), + ('bundle_admin', 'bundle_reader'), + ('channel_admin', 'channel_reader'), + ('org_admin', 'app_admin'), + ('org_super_admin', 'org_admin') +) AS hierarchy(parent_role_name, child_role_name) +JOIN "public"."roles" parent_roles + ON parent_roles."name" = hierarchy.parent_role_name +JOIN "public"."roles" child_roles + ON child_roles."name" = hierarchy.child_role_name +ON CONFLICT DO NOTHING; + +INSERT INTO "public"."cron_tasks" ( + "id", + "name", + "description", + "task_type", + "target", + "batch_size", + "payload", + "second_interval", + "minute_interval", + "hour_interval", + "run_at_hour", + "run_at_minute", + "run_at_second", + "run_on_dow", + "run_on_day", + "enabled", + "healthcheck_url" +) +VALUES + (1, 'high_frequency_queues', 'Process high-frequency event queues', 'function_queue', '["credit_usage_alerts", "on_app_create", "on_app_delete", "on_app_update", "on_channel_update", "on_org_update", "on_organization_create", "on_user_create", "on_user_delete", "on_user_update", "on_version_create", "on_version_delete", "on_version_update", "webhook_dispatcher", "webhook_delivery"]', 100, NULL, 10, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true, NULL), + (2, 'channel_device_counts', 'Process channel device counts queue', 'function', 'public.process_channel_device_counts_queue(1000)', NULL, NULL, 10, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true, NULL), + (3, 'delete_marked_accounts', 'Delete accounts marked for deletion', 'function', 'public.delete_accounts_marked_for_deletion()', NULL, NULL, NULL, 1, NULL, NULL, NULL, 0, NULL, NULL, true, NULL), + (4, 'per_minute_queues', 'Process per-minute queues', 'function_queue', '["cron_sync_sub", "cron_stat_app"]', 10, NULL, NULL, 1, NULL, NULL, NULL, 0, NULL, NULL, true, NULL), + (5, 'manifest_create_queue', 'Process manifest create queue', 'function_queue', '["on_manifest_create"]', NULL, NULL, NULL, 1, NULL, NULL, NULL, 0, NULL, NULL, true, NULL), + (6, 'orphan_images_queue', 'Process orphan images cleanup queue', 'function_queue', '["cron_clean_orphan_images"]', NULL, NULL, NULL, 1, NULL, NULL, NULL, 0, NULL, NULL, true, NULL), + (7, 'org_stats_queue', 'Process org stats queue', 'function_queue', '["cron_stat_org"]', 10, NULL, NULL, 5, NULL, NULL, NULL, 0, NULL, NULL, true, NULL), + (8, 'cleanup_job_details', 'Cleanup frequent job details', 'function', 'public.cleanup_frequent_job_details()', NULL, NULL, NULL, NULL, NULL, NULL, 0, 0, NULL, NULL, true, NULL), + (9, 'deploy_install_stats_email', 'Process deploy install stats email', 'function', 'public.process_deploy_install_stats_email()', NULL, NULL, NULL, NULL, NULL, NULL, 0, 0, NULL, NULL, true, NULL), + (10, 'low_frequency_queues', 'Process low-frequency queues', 'function_queue', '["admin_stats", "cron_email", "on_organization_delete", "on_deploy_history_create", "cron_clear_versions"]', NULL, NULL, NULL, NULL, 2, NULL, 0, 0, NULL, NULL, true, NULL), + (11, 'stats_jobs', 'Process cron stats jobs', 'function', 'public.process_cron_stats_jobs()', NULL, NULL, NULL, NULL, 6, NULL, 0, 0, NULL, NULL, true, NULL), + (12, 'cleanup_queue_messages', 'Cleanup old queue messages', 'function', 'public.cleanup_queue_messages()', NULL, NULL, NULL, NULL, NULL, 0, 0, 0, NULL, NULL, true, NULL), + (13, 'delete_old_apps', 'Delete old deleted apps', 'function', 'public.delete_old_deleted_apps()', NULL, NULL, NULL, NULL, NULL, 0, 0, 0, NULL, NULL, true, NULL), + (14, 'remove_old_jobs', 'Remove old cron jobs', 'function', 'public.remove_old_jobs()', NULL, NULL, NULL, NULL, NULL, 0, 0, 0, NULL, NULL, true, NULL), + (15, 'version_retention', 'Update app versions retention', 'function', 'public.update_app_versions_retention()', NULL, NULL, NULL, NULL, NULL, 0, 40, 0, NULL, NULL, true, NULL), + (16, 'admin_stats', 'Process admin stats', 'function', 'public.process_admin_stats()', NULL, NULL, NULL, NULL, NULL, 1, 1, 0, NULL, NULL, true, NULL), + (17, 'free_trial_expired', 'Process free trial expired', 'function', 'public.process_free_trial_expired()', NULL, NULL, NULL, NULL, NULL, 3, 0, 0, NULL, NULL, true, NULL), + (18, 'expire_credits', 'Expire usage credits', 'function', 'public.expire_usage_credits()', NULL, NULL, NULL, NULL, NULL, 3, 0, 0, NULL, NULL, true, NULL), + (19, 'orphan_images_cleanup', 'Queue orphan images cleanup job', 'queue', 'cron_clean_orphan_images', NULL, NULL, NULL, NULL, NULL, 3, 0, 0, 0, NULL, true, NULL), + (20, 'sync_sub_jobs', 'Process cron sync sub jobs', 'function', 'public.process_cron_sync_sub_jobs()', NULL, NULL, NULL, NULL, NULL, 4, 0, 0, NULL, NULL, true, NULL), + (21, 'cleanup_job_run_details', 'Cleanup old job run details', 'function', 'public.cleanup_job_run_details_7days()', NULL, NULL, NULL, NULL, NULL, 12, 0, 0, NULL, NULL, true, NULL), + (22, 'weekly_stats_email', 'Process weekly stats email', 'function', 'public.process_stats_email_weekly()', NULL, NULL, NULL, NULL, NULL, 12, 0, 0, 6, NULL, true, NULL), + (23, 'monthly_stats_email', 'Process monthly stats email', 'function', 'public.process_stats_email_monthly()', NULL, NULL, NULL, NULL, NULL, 12, 0, 0, NULL, 1, true, NULL), + (24, 'cleanup_old_channel_devices', 'Delete channel_devices older than one month', 'function', 'public.cleanup_old_channel_devices()', NULL, NULL, NULL, NULL, NULL, 2, 30, 0, NULL, NULL, true, NULL), + (25, 'delete_old_versions', 'Permanently delete app versions 90 days after soft delete', 'function', 'public.delete_old_deleted_versions()', NULL, NULL, NULL, NULL, NULL, 3, 0, 0, NULL, NULL, true, NULL), + (26, 'daily_fail_ratio_email', 'Send daily email alerts for apps with high install failure rates (>30%)', 'function', 'public.process_daily_fail_ratio_email()', NULL, NULL, NULL, NULL, NULL, 8, 0, 0, NULL, NULL, true, NULL), + (27, 'cleanup_expired_demo_apps', 'Delete demo apps (app_id starts with com.capdemo.) older than 14 days', 'function', 'public.cleanup_expired_demo_apps()', NULL, NULL, NULL, NULL, NULL, 3, 0, 0, NULL, NULL, true, NULL), + (28, 'refresh_org_usage_credits_flag', 'Refresh active credit flag for replica plugin gates', 'function', 'public.refresh_orgs_has_usage_credits()', NULL, NULL, NULL, NULL, NULL, 3, 0, 30, NULL, NULL, true, NULL), + (29, 'cleanup_tmp_users', 'Cleanup expired tmp_users invitations (7 days)', 'function', 'public.cleanup_tmp_users()', NULL, NULL, NULL, 1, NULL, NULL, NULL, 0, NULL, NULL, true, NULL), + (30, 'reconcile_build_status', 'Send build status reconciliation job to queue every 15 minutes', 'queue', 'cron_reconcile_build_status', NULL, NULL, NULL, 15, NULL, NULL, NULL, 0, NULL, NULL, true, NULL), + (31, 'reconcile_build_status_queue', 'Process build status reconciliation queue', 'function_queue', '["cron_reconcile_build_status"]', NULL, NULL, NULL, 1, NULL, NULL, NULL, 0, NULL, NULL, true, NULL), + (32, 'cleanup_old_audit_logs', 'Delete audit_logs older than 90 days', 'function', 'public.cleanup_old_audit_logs()', NULL, NULL, NULL, NULL, NULL, 3, 0, 0, NULL, NULL, true, NULL) +ON CONFLICT ("name") DO UPDATE +SET + "id" = EXCLUDED."id", + "description" = EXCLUDED."description", + "task_type" = EXCLUDED."task_type", + "target" = EXCLUDED."target", + "batch_size" = EXCLUDED."batch_size", + "payload" = EXCLUDED."payload", + "second_interval" = EXCLUDED."second_interval", + "minute_interval" = EXCLUDED."minute_interval", + "hour_interval" = EXCLUDED."hour_interval", + "run_at_hour" = EXCLUDED."run_at_hour", + "run_at_minute" = EXCLUDED."run_at_minute", + "run_at_second" = EXCLUDED."run_at_second", + "run_on_dow" = EXCLUDED."run_on_dow", + "run_on_day" = EXCLUDED."run_on_day", + "enabled" = EXCLUDED."enabled", + "healthcheck_url" = EXCLUDED."healthcheck_url"; + +SELECT pg_catalog.setval('"public"."cron_tasks_id_seq"', 32, true); + +SELECT cron.schedule( + 'process_all_cron_tasks', + '10 seconds', + $$SELECT public.process_all_cron_tasks();$$ +) +WHERE NOT EXISTS ( + SELECT 1 + FROM cron.job + WHERE jobname = 'process_all_cron_tasks' +); diff --git a/supabase/repair/20260608143906_pre_squash_repair.sql b/supabase/repair/20260608143906_pre_squash_repair.sql new file mode 100644 index 0000000000..9c690b2c75 --- /dev/null +++ b/supabase/repair/20260608143906_pre_squash_repair.sql @@ -0,0 +1,37 @@ +-- Repair path for existing databases when adopting the 20260608143906 squashed +-- baseline. Apply this file only when that version is not already applied. +DO $$ +BEGIN + IF to_regclass('public.compatibility_events') IS NULL THEN + RAISE EXCEPTION 'public.compatibility_events is missing; run the squashed baseline normally for fresh databases'; + END IF; + + ALTER TABLE public.compatibility_events + ADD COLUMN IF NOT EXISTS change_occurred_at timestamptz; + + UPDATE public.compatibility_events + SET change_occurred_at = created_at + WHERE change_occurred_at IS NULL; + + ALTER TABLE public.compatibility_events + ALTER COLUMN change_occurred_at SET NOT NULL; + + ALTER TABLE public.compatibility_events + ALTER COLUMN change_occurred_at SET DEFAULT now(); + + DROP INDEX IF EXISTS public.uq_compatibility_events_dedup; + + CREATE UNIQUE INDEX IF NOT EXISTS uq_compatibility_events_dedup + ON public.compatibility_events (app_id, channel_id, platform, current_version_id, previous_version_id, change_occurred_at) + NULLS NOT DISTINCT; + + DROP POLICY IF EXISTS "Prevent users from updating manifest entries" ON public.manifest; + + CREATE POLICY "Prevent users from updating manifest entries" + ON public.manifest + AS RESTRICTIVE + FOR UPDATE + TO authenticated, anon + USING (false) + WITH CHECK (false); +END $$; diff --git a/supabase/repair/20260608143906_reverted_versions.txt b/supabase/repair/20260608143906_reverted_versions.txt new file mode 100644 index 0000000000..2ffada17f4 --- /dev/null +++ b/supabase/repair/20260608143906_reverted_versions.txt @@ -0,0 +1,311 @@ +20250530233128 +20250601115144 +20250605151648 +20250608130257 +20250612131646 +20250613034031 +20250619221552 +20250714021423 +20250903010822 +20250908120000 +20250909094709 +20250913161225 +20250916032824 +20250920120000 +20250920120001 +20250921120000 +20250927082020 +20250928145642 +20251007132214 +20251007134349 +20251014105957 +20251014120000 +20251014135440 +20251019123107 +20251021141631 +20251024153920 +20251024230753 +20251026165357 +20251031202034 +20251103134045 +20251106024103 +20251107001223 +20251107153019 +20251113041643 +20251113140646 +20251119001844 +20251119001847 +20251120150750 +20251204163538 +20251208175306 +20251209184322 +20251212112948 +20251213114641 +20251213140000 +20251219192610 +20251220011455 +20251221091510 +20251222140030 +20251223234326 +20251224103713 +20251226120000 +20251226121000 +20251226125240 +20251227040840 +20251228033417 +20251228063320 +20251228065406 +20251228080032 +20251228080037 +20251228082157 +20251228100000 +20251228150000 +20251228160000 +20251228215402 +20251229030503 +20251229100000 +20251229233706 +20251230114041 +20251231060433 +20260101042511 +20260102120000 +20260102140000 +20260103030451 +20260104100000 +20260104110000 +20260104120000 +20260105014309 +20260105150626 +20260107000000 +20260108000000 +20260108024031 +20260109000000 +20260109000001 +20260110044840 +20260112140000 +20260113000000 +20260113132114 +20260113160650 +20260114214731 +20260115025158 +20260115051444 +20260118000000 +20260118005052 +20260119182934 +20260120165047 +20260121000000 +20260123140712 +20260124231940 +20260125151000 +20260127120000 +20260127121000 +20260127153000 +20260127232000 +20260129120000 +20260129123000 +20260130032543 +20260130033703 +20260130040811 +20260130190800 +20260201015640 +20260201042609 +20260202090000 +20260203010025 +20260203120000 +20260203140000 +20260203150000 +20260203160000 +20260203173000 +20260203190000 +20260203201308 +20260204100000 +20260204103000 +20260204103001 +20260204181424 +20260205031305 +20260205120000 +20260206120000 +20260206213247 +20260207180640 +20260209014020 +20260209024134 +20260210132811 +20260211034517 +20260214054927 +20260216102420 +20260221150207 +20260223000001 +20260224091500 +20260224093000 +20260224153000 +20260224153100 +20260224153200 +20260224153201 +20260224153300 +20260224153401 +20260224153500 +20260224160000 +20260225000000 +20260225000100 +20260225105000 +20260225120000 +20260226000000 +20260226000100 +20260226090000 +20260226153000 +20260227000000 +20260227000001 +20260227010000 +20260227150000 +20260228000000 +20260228000100 +20260228000200 +20260228000300 +20260228154639 +20260228172308 +20260228172309 +20260302000000 +20260302185011 +20260303150634 +20260308121758 +20260308121933 +20260308203352 +20260311120000 +20260311123000 +20260311124500 +20260311150453 +20260311162400 +20260311164503 +20260312000000 +20260312183000 +20260312202155 +20260312202212 +20260312202227 +20260312202250 +20260313104400 +20260313104427 +20260313121928 +20260313130044 +20260316132841 +20260316220423 +20260317020451 +20260317020500 +20260317021715 +20260317040310 +20260317090000 +20260317100429 +20260317160518 +20260318210857 +20260318220337 +20260319090430 +20260319094649 +20260319103952 +20260319155734 +20260319164053 +20260319221428 +20260319235626 +20260320044548 +20260320133752 +20260323075628 +20260324181219 +20260324181246 +20260325032835 +20260325043000 +20260325045835 +20260327044102 +20260327210500 +20260327220305 +20260330141128 +20260408134842 +20260408140215 +20260422104849 +20260422203355 +20260424090111 +20260424090125 +20260424090727 +20260424090854 +20260424090941 +20260424091645 +20260424094101 +20260424094225 +20260427092702 +20260427105151 +20260427105817 +20260427105834 +20260427105838 +20260427105909 +20260427110612 +20260427142358 +20260427144300 +20260427144323 +20260427144324 +20260427144325 +20260427144331 +20260427175506 +20260429094653 +20260429135552 +20260430145247 +20260430145518 +20260501162433 +20260501200000 +20260502134045 +20260502134234 +20260502134355 +20260504174812 +20260505163356 +20260505193449 +20260506101503 +20260506103727 +20260506152006 +20260507082135 +20260507090047 +20260507090436 +20260507091347 +20260507153639 +20260507165636 +20260508122137 +20260508135918 +20260510103516 +20260510161104 +20260510171814 +20260510183000 +20260510190432 +20260510191550 +20260510214140 +20260510214806 +20260510235542 +20260511101826 +20260511151503 +20260513000348 +20260513152636 +20260514093535 +20260514102952 +20260515170516 +20260516151507 +20260517102815 +20260518120000 +20260518121000 +20260518130000 +20260518131054 +20260519065534 +20260519123613 +20260519151250 +20260521210531 +20260524123635 +20260526133000 +20260528002613 +20260528013304 +20260528023934 +20260528090340 +20260528113224 +20260529075127 +20260530083657 +20260530114525 +20260531063221 +20260601101710 +20260603102048 +20260603113951 +20260603174942 +20260605104908 +20260608094944 +20260608111805