diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 61aeffb7d7..9c686e5fc5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,12 +2,14 @@ name: Run tests concurrency: # Include event_name so push and pull_request on the same branch do not cancel each other. - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_call' && github.sha || github.head_ref || github.ref_name || github.ref }} + # Include PR head SHA so re-triggers on the same commit do not cancel an in-flight run. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_call' && github.sha || (github.event_name == 'pull_request' && format('{0}-{1}', github.head_ref || github.ref_name, github.event.pull_request.head.sha) || format('{0}-{1}', github.head_ref || github.ref_name || github.ref, github.sha)) }} # Keep in-progress pull_request suites running; duplicate synchronize events were # cancelling shards mid-queue and leaving cancelled required checks on the PR. cancel-in-progress: ${{ github.event_name != 'pull_request' }} on: + pull_request: workflow_dispatch: push: branches-ignore: @@ -323,13 +325,13 @@ jobs: actions: write concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-backend-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }} + group: capgo-local-services-backend-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard_id }} SUPABASE_WORKTREE_PORT_OFFSET: ${{ matrix.supabase_port_offset }} strategy: - fail-fast: true + fail-fast: false matrix: include: - shard: 1/6 @@ -489,7 +491,7 @@ jobs: contents: read actions: write concurrency: - group: capgo-local-services-backend-plugin-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-backend-plugin-${{ github.event_name }}-${{ github.repository }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-plugin-${{ github.run_id }}-${{ github.run_attempt }} @@ -599,7 +601,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-backend-sql-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-backend-sql-${{ github.event_name }}-${{ github.repository }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-sql-${{ github.run_id }}-${{ github.run_attempt }} @@ -669,7 +671,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-backend-sql-catalog-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-backend-sql-catalog-${{ github.event_name }}-${{ github.repository }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-sql-catalog-${{ github.run_id }}-${{ github.run_attempt }} @@ -741,7 +743,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-cloudflare-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }} + group: capgo-local-services-cloudflare-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: cloudflare-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard_id }} @@ -749,7 +751,7 @@ jobs: CLOUDFLARE_WORKER_PORT_OFFSET: ${{ matrix.cloudflare_worker_port_offset }} CLOUDFLARE_PERSIST_DIR: .context/cloudflare-workers-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.supabase_port_offset }} strategy: - fail-fast: true + fail-fast: false matrix: include: - shard: 1/8 @@ -883,7 +885,7 @@ jobs: permissions: contents: read concurrency: - group: capgo-local-services-cloudflare-plugin-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-cloudflare-plugin-${{ github.event_name }}-${{ github.repository }}-${{ github.sha }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: cloudflare-plugin-${{ github.run_id }}-${{ github.run_attempt }} @@ -998,7 +1000,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-playwright-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }} + group: capgo-local-services-playwright-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }}-${{ github.sha }} cancel-in-progress: false strategy: fail-fast: false diff --git a/supabase/migrations/20260826120000_lock_r2_direct_ota_versions.sql b/supabase/migrations/20260826120000_lock_r2_direct_ota_versions.sql new file mode 100644 index 0000000000..2f9cbcca24 --- /dev/null +++ b/supabase/migrations/20260826120000_lock_r2_direct_ota_versions.sql @@ -0,0 +1,515 @@ +-- GHSA-5rg9-rhwj-wj76: r2-direct staging rows were treated as "not ready" and +-- stayed mutable via PostgREST after checksum/session_key were set. Upload is +-- complete once storage_provider is no longer r2-direct; during r2-direct +-- staging, identity fields lock after first set (checksum/session_key/key_id) +-- but r2_path updates, public.manifest writes via /private/set_manifest, and +-- finalize (r2-direct -> r2) remain allowed until upload completes. PostgREST +-- app_versions.manifest jsonb writes on r2-direct are blocked separately +-- (20260826101500; re-applied in this migration after function replace). +-- allowed until upload completes. Channel linkage is intentionally NOT part of +-- the freeze gate (upload-complete is storage_provider != r2-direct). Staged +-- r2-direct rows may still UPDATE r2_path while channel-linked so upload +-- finalization can complete; only identity fields lock after first set. +-- Channel triggers call RBAC before lock_channel_bundle_lifecycle (no oracle). +-- +-- Execution profile (check_encrypted_bundle_on_insert): +-- - Where: 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 (per row). +-- - Frequency: console-scale bundle uploads and metadata edits (hundreds/day), +-- not plugin hot path. +-- - Roles: anon/authenticated via PostgREST and API keys; service_role for +-- internal reclaim/manifest paths (capgo.reclaim_manifest_null bypass). +-- - Cardinality: single app_versions row (OLD/NEW); org encryption lookup is +-- one indexed row on public.apps (app_id PK) and public.orgs (id PK). +-- Manifest anti-joins unnest(OLD.manifest) against public.manifest filtered +-- by app_version_id (= OLD.id); scales with manifest array length per row +-- (typical bundles ~1k-5k files; bounded by upload finalization). +-- - Indexes: app_versions PK on id; manifest (app_version_id, s3_path, file_hash) +-- for the per-entry EXISTS probes; apps(app_id), orgs(id). +-- - Worst-case EXPLAIN (ANALYZE, BUFFERS) on local seed (UPDATE checksum on +-- staged r2-direct row): Index Scan on app_versions_pkey; nested Index Scan +-- on manifest_app_version_id_s3_path_file_hash_idx per unnest entry; no seq +-- scan on apps/orgs/channels. +-- +-- Execution profile (lock_channel_bundle_lifecycle): +-- - Where: called from channel promotion/rollout triggers after RBAC (below). +-- - Frequency: console-scale channel writes; at most two bundle ids per call. +-- - Roles: service_role only (trigger context); not PostgREST-exposed. +-- - Cardinality: 0-2 rows in public.app_versions by PK lookup. +-- - Indexes: app_versions PK on id for FOR UPDATE row locks. +-- - Worst-case EXPLAIN (ANALYZE, BUFFERS): PK index scan per bundle id; O(1) +-- advisory lock per id; no table scans. +-- +-- Execution profile (enforce_channel_version_promotion_permission, +-- refresh_channel_rollout_id): +-- - Where: BEFORE INSERT OR UPDATE on public.channels (per row). +-- - Frequency: console-scale channel promotion/rollout edits. +-- - Roles: anon/authenticated API keys and JWT users; service_role/postgres +-- bypass RBAC; seed path uses capgo.seed_channel_targets GUC. +-- - Cardinality: one channel row; bundle validation is PK lookup on +-- app_versions plus owner_org/app_id/deleted filter. +-- - Indexes: channels PK; app_versions PK; finx_channels_version(version), +-- idx_channels_rollout_version(rollout_version) for downstream readers. +-- - Worst-case EXPLAIN (ANALYZE, BUFFERS) on local seed (channel version +-- promotion): Index Scan on channels_pkey; PK lookup on app_versions; RBAC +-- uses role_bindings_principal_scope_idx; no seq scan on channels. + +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_upload_complete boolean; + bundle_identity_locked boolean; + is_r2_direct_finalize boolean; + r2_direct_manifest_err constant text := + 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress ' + || 'r2-direct uploads instead of app_versions.manifest jsonb.'; +BEGIN + IF TG_OP = 'INSERT' + AND NEW.storage_provider = 'r2-direct' + AND NEW.manifest IS NOT NULL + THEN + PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', NEW.owner_org, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'old_storage_provider', NULL, + 'new_storage_provider', NEW.storage_provider, + 'reason', 'r2_direct_manifest_jsonb' + )); + RAISE EXCEPTION '%', r2_direct_manifest_err; + END IF; + + IF TG_OP = 'UPDATE' THEN + IF pg_catalog.current_setting('capgo.reclaim_manifest_null', true) = 'on' + AND NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND NEW.native_packages IS NOT DISTINCT FROM OLD.native_packages + AND NEW.name IS NOT DISTINCT FROM OLD.name + AND NEW.app_id IS NOT DISTINCT FROM OLD.app_id + AND NEW.session_key IS NOT DISTINCT FROM OLD.session_key + AND NEW.key_id IS NOT DISTINCT FROM OLD.key_id + AND NEW.storage_provider IS NOT DISTINCT FROM OLD.storage_provider + AND NEW.r2_path IS NOT DISTINCT FROM OLD.r2_path + AND NEW.external_url IS NOT DISTINCT FROM OLD.external_url + AND NEW.checksum IS NOT DISTINCT FROM OLD.checksum + THEN + RETURN NEW; + END IF; + + IF NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND public.app_version_manifest_jsonb_unmigrated(OLD.id, OLD.manifest) + THEN + RAISE EXCEPTION '%', + 'bundle_manifest_not_migrated: Cannot clear app_versions.manifest ' + || 'until every entry exists in public.manifest.'; + END IF; + + bundle_upload_complete := OLD.storage_provider IS DISTINCT FROM 'r2-direct'; + + IF bundle_upload_complete + 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 AND NEW.manifest IS NOT NULL) + OR ( + NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND public.app_version_manifest_jsonb_unmigrated(OLD.id, OLD.manifest) + ) + OR NEW.native_packages IS DISTINCT FROM OLD.native_packages + ) + THEN + PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', + pg_catalog.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_upload_complete' + )); + RAISE EXCEPTION '%', + 'bundle_already_ready: Bundle content cannot be changed ' + || 'after upload is complete. Upload a new bundle instead.'; + END IF; + + -- In-progress r2-direct uploads must use POST /private/set_manifest. + IF OLD.storage_provider = 'r2-direct' + AND NEW.manifest IS DISTINCT FROM OLD.manifest + AND NEW.manifest IS NOT NULL + THEN + PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', + pg_catalog.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', 'r2_direct_manifest_jsonb' + )); + RAISE EXCEPTION '%', r2_direct_manifest_err; + END IF; + + -- GHSA-5rg9-rhwj-wj76: CLI/TUS creates r2-direct rows with checksum before + -- finalize. Lock identity fields after first set (checksum/session_key/ + -- key_id); still allow r2_path writes and the one-shot finalize + -- (r2-direct -> r2). Blank-checksum in-progress rows stay writable for + -- upload completion; channel linkage is not the freeze gate. + IF OLD.storage_provider = 'r2-direct' THEN + bundle_identity_locked := ( + NULLIF(BTRIM(COALESCE(OLD.checksum, '')), '') IS NOT NULL + OR NULLIF(BTRIM(COALESCE(OLD.session_key, '')), '') IS NOT NULL + OR NULLIF(BTRIM(COALESCE(OLD.key_id, '')), '') IS NOT NULL + ); + + is_r2_direct_finalize := ( + NEW.storage_provider = 'r2' + AND NEW.name IS NOT DISTINCT FROM OLD.name + AND NEW.app_id IS NOT DISTINCT FROM OLD.app_id + AND NEW.session_key IS NOT DISTINCT FROM OLD.session_key + AND NEW.key_id IS NOT DISTINCT FROM OLD.key_id + AND NEW.checksum IS NOT DISTINCT FROM OLD.checksum + AND NEW.external_url IS NOT DISTINCT FROM OLD.external_url + AND NEW.native_packages IS NOT DISTINCT FROM OLD.native_packages + ); + + IF bundle_identity_locked + 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.checksum IS DISTINCT FROM OLD.checksum + OR NEW.external_url IS DISTINCT FROM OLD.external_url + OR NEW.native_packages IS DISTINCT FROM OLD.native_packages + OR ( + NEW.storage_provider IS DISTINCT FROM OLD.storage_provider + AND NOT is_r2_direct_finalize + ) + ) + THEN + PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', + pg_catalog.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', 'r2_direct_identity_locked' + )); + RAISE EXCEPTION '%', + 'bundle_identity_locked: Bundle identity fields cannot be changed ' + || 'after checksum, session_key, or key_id are first set during upload.'; + END IF; + END IF; + END IF; + + IF TG_OP = 'UPDATE' + AND NEW.session_key IS NOT DISTINCT FROM OLD.session_key + AND NEW.key_id IS NOT DISTINCT FROM OLD.key_id + AND NEW.name IS NOT DISTINCT FROM OLD.name + AND NEW.app_id IS NOT DISTINCT FROM OLD.app_id + AND NEW.storage_provider IS NOT DISTINCT FROM OLD.storage_provider + AND NEW.r2_path IS NOT DISTINCT FROM OLD.r2_path + AND NEW.external_url IS NOT DISTINCT FROM OLD.external_url + AND NEW.checksum IS NOT DISTINCT FROM OLD.checksum + AND NEW.native_packages IS NOT DISTINCT FROM OLD.native_packages + AND ( + NEW.manifest IS NOT DISTINCT FROM OLD.manifest + OR ( + NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND NOT public.app_version_manifest_jsonb_unmigrated(OLD.id, OLD.manifest) + ) + ) + THEN + RETURN NEW; + END IF; + + 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_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(pg_catalog.btrim(NEW.key_id), '')::varchar(20); + + IF NOT bundle_is_encrypted THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_ENCRYPTED_BUNDLES_TRIGGER', + pg_catalog.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', + pg_catalog.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; + + IF NOT ( + bundle_key_id = pg_catalog.left(org_required_key, 20) + OR pg_catalog.left(bundle_key_id, pg_catalog.length(org_required_key)) = org_required_key + ) THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', + pg_catalog.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; +$$; + +-- Keep bundle row locks before advisory locks so channel promotion and +-- app_versions protected-field updates serialize without deadlocks. +CREATE OR REPLACE FUNCTION "public"."lock_channel_bundle_lifecycle"("p_version_id" bigint, "p_rollout_version_id" bigint) RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_bundle_id bigint; +BEGIN + FOR v_bundle_id IN + SELECT bundle.bundle_id + FROM pg_catalog.unnest(ARRAY[p_version_id, p_rollout_version_id]) AS bundle(bundle_id) + WHERE bundle.bundle_id IS NOT NULL + ORDER BY bundle.bundle_id + LOOP + PERFORM 1 + FROM public.app_versions AS version + WHERE version.id = v_bundle_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'INVALID_CHANNEL_BUNDLE'; + END IF; + + PERFORM pg_catalog.pg_advisory_xact_lock(v_bundle_id); + END LOOP; +END; +$$; + +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); + v_owner_org uuid; + v_channel_id bigint; +BEGIN + IF TG_OP = 'UPDATE' AND NEW.version IS NOT DISTINCT FROM OLD.version THEN + RETURN NEW; + END IF; + + IF TG_OP = 'INSERT' THEN + v_owner_org := public.get_owner_org_by_app_id_internal(NEW.app_id); + v_channel_id := NULL::bigint; + ELSE + v_owner_org := OLD.owner_org; + v_channel_id := OLD.id; + END IF; + + -- A blank target is the native/builtin channel state; an initial target needs + -- app-level promotion, while changing an existing target is channel-scoped. + -- INSERT with rollout_version but no version still requires promotion RBAC + -- before lock_channel_bundle_lifecycle to avoid bundle-existence oracle leaks. + IF v_request_role NOT IN ('service_role', 'postgres') + AND pg_catalog.current_setting('capgo.seed_channel_targets', true) IS DISTINCT FROM 'true' + THEN + 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; + + -- Blank version on INSERT is the native channel state, but a rollout target still + -- needs promotion permission before bundle existence checks run. + IF NOT (TG_OP = 'INSERT' AND NEW.version IS NULL AND NEW.rollout_version IS NULL) + AND NOT public.rbac_check_permission_request( + public.rbac_perm_channel_promote_bundle(), + v_owner_org, + NEW.app_id, + v_channel_id + ) THEN + RAISE EXCEPTION 'PERMISSION_DENIED_CHANNEL_PROMOTE_BUNDLE' + USING ERRCODE = '42501'; + END IF; + END IF; + + PERFORM public.lock_channel_bundle_lifecycle(NEW.version, NEW.rollout_version); + + IF NEW.version IS NOT NULL THEN + PERFORM 1 + FROM public.app_versions AS version + WHERE version.id = NEW.version + AND version.app_id = NEW.app_id + AND version.owner_org = v_owner_org + AND version.deleted = false; + + IF NOT FOUND THEN + RAISE EXCEPTION 'INVALID_CHANNEL_VERSION'; + END IF; + + -- Service-role endpoints carry the key in request.headers. This helper + -- no-ops for other callers and preserves preview-key bundle ownership. + PERFORM public.assert_preview_bundle_owner( + v_owner_org, + NEW.app_id, + NEW.version + ); + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."refresh_channel_rollout_id"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_rollout_changed boolean; + v_channel_id bigint; +BEGIN + IF TG_OP = 'UPDATE' + AND NEW.version IS DISTINCT FROM OLD.version + AND NEW.rollout_version IS NOT DISTINCT FROM OLD.rollout_version + AND NEW.rollout_version IS NOT NULL + THEN + NEW.rollout_version := NULL; + NEW.rollout_enabled := false; + NEW.rollout_percentage_bps := 0; + END IF; + + IF TG_OP = 'INSERT' THEN + v_rollout_changed := NEW.rollout_version IS NOT NULL; + v_channel_id := NULL::bigint; + ELSE + v_rollout_changed := NEW.rollout_version IS DISTINCT FROM OLD.rollout_version; + v_channel_id := NEW.id; + END IF; + + IF v_rollout_changed THEN + IF (auth.uid() IS NOT NULL OR public.get_apikey_header() IS NOT NULL) + AND NOT public.rbac_check_permission_request( + public.rbac_perm_channel_promote_bundle(), + NEW.owner_org, + NEW.app_id, + v_channel_id + ) + THEN + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + PERFORM public.lock_channel_bundle_lifecycle(NEW.version, NEW.rollout_version); + + IF NEW.rollout_version IS NOT NULL THEN + PERFORM 1 + FROM public.app_versions AS version + WHERE version.id = NEW.rollout_version + AND version.app_id = NEW.app_id + AND version.owner_org = NEW.owner_org + AND version.deleted = false; + + IF NOT FOUND THEN + RAISE EXCEPTION 'INVALID_ROLLOUT_VERSION'; + END IF; + + PERFORM public.assert_preview_bundle_owner( + NEW.owner_org, + NEW.app_id, + NEW.rollout_version + ); + END IF; + + NEW.rollout_id = gen_random_uuid(); + IF NEW.rollout_version IS NULL THEN + NEW.rollout_paused_at = NULL; + IF TG_OP = 'INSERT' THEN + NEW.rollout_pause_reason = NULL; + NEW.auto_pause_last_triggered_at = NULL; + ELSE + IF NEW.rollout_pause_reason IS NOT DISTINCT FROM OLD.rollout_pause_reason THEN + NEW.rollout_pause_reason = NULL; + END IF; + IF NEW.auto_pause_last_triggered_at IS NOT DISTINCT FROM OLD.auto_pause_last_triggered_at THEN + NEW.auto_pause_last_triggered_at = NULL; + END IF; + END IF; + ELSE + NEW.rollout_paused_at = NULL; + NEW.rollout_pause_reason = NULL; + NEW.auto_pause_last_triggered_at = NULL; + END IF; + END IF; + + RETURN NEW; +END; +$$; diff --git a/supabase/tests/69_test_lock_r2_direct_ota_versions.sql b/supabase/tests/69_test_lock_r2_direct_ota_versions.sql new file mode 100644 index 0000000000..7b71c285db --- /dev/null +++ b/supabase/tests/69_test_lock_r2_direct_ota_versions.sql @@ -0,0 +1,224 @@ +-- GHSA-5rg9-rhwj-wj76: upload-complete bundles (storage_provider != r2-direct) lock +-- delivery fields (name, app_id, checksum, session_key, key_id, storage_provider, +-- r2_path, external_url, manifest, native_packages). Non-content metadata (e.g. +-- comment) and clearing manifest once migrated to public.manifest remain allowed. +-- r2-direct staging locks identity fields after first set; r2_path/manifest writes +-- and finalize (r2-direct -> r2) stay allowed until upload completes. +BEGIN; + +SELECT plan(11); + +SELECT tests.authenticate_as_service_role(); +SELECT tests.create_supabase_user('r2_direct_upload_lock_owner', 'r2_direct_upload_lock_owner@test.local'); + +INSERT INTO public.users (id, email, created_at, updated_at) +VALUES ( + tests.get_supabase_uid('r2_direct_upload_lock_owner'), + 'r2_direct_upload_lock_owner@test.local', + NOW(), + NOW() +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.orgs (id, created_by, name, management_email) +VALUES ( + '70000000-0000-4000-8000-000000000071', + tests.get_supabase_uid('r2_direct_upload_lock_owner'), + 'r2-direct upload lock org', + 'r2-direct-upload-lock@test.local' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.apps (app_id, icon_url, user_id, name, owner_org) +VALUES ( + 'com.test.r2direct.upload.lock', + '', + tests.get_supabase_uid('r2_direct_upload_lock_owner'), + 'r2-direct upload lock app', + '70000000-0000-4000-8000-000000000071' +) +ON CONFLICT (app_id) DO NOTHING; + +DELETE FROM public.app_versions +WHERE app_id = 'com.test.r2direct.upload.lock'; + +INSERT INTO public.app_versions ( + app_id, + name, + owner_org, + user_id, + storage_provider, + checksum, + session_key, + r2_path, + comment, + deleted +) +VALUES + ( + 'com.test.r2direct.upload.lock', + '1.0.0-staged', + '70000000-0000-4000-8000-000000000071', + tests.get_supabase_uid('r2_direct_upload_lock_owner'), + 'r2-direct', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'session-key-staged', + NULL, + 'staged comment', + false + ), + ( + 'com.test.r2direct.upload.lock', + '1.0.0-in-progress', + '70000000-0000-4000-8000-000000000071', + tests.get_supabase_uid('r2_direct_upload_lock_owner'), + 'r2-direct', + NULL, + NULL, + NULL, + 'in-progress comment', + false + ), + ( + 'com.test.r2direct.upload.lock', + '1.0.0-finalized', + '70000000-0000-4000-8000-000000000071', + tests.get_supabase_uid('r2_direct_upload_lock_owner'), + 'r2', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'session-key-finalized', + 'orgs/70000000-0000-4000-8000-000000000071/apps/com.test.r2direct.upload.lock/1.0.0-finalized.zip', + 'finalized comment', + false + ); + +SELECT throws_ok( + $sql$ + UPDATE public.app_versions + SET checksum = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-staged' + $sql$, + 'P0001', + 'bundle_identity_locked: Bundle identity fields cannot be changed after checksum, session_key, or key_id are first set during upload.', + 'staged r2-direct cannot UPDATE checksum' +); + +SELECT throws_ok( + $sql$ + UPDATE public.app_versions + SET session_key = 'session-key-mutated' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-staged' + $sql$, + 'P0001', + 'bundle_identity_locked: Bundle identity fields cannot be changed after checksum, session_key, or key_id are first set during upload.', + 'staged r2-direct cannot UPDATE session_key' +); + +SELECT throws_ok( + $sql$ + UPDATE public.app_versions + SET key_id = 'mutated-key-id' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-staged' + $sql$, + 'P0001', + 'bundle_identity_locked: Bundle identity fields cannot be changed after checksum, session_key, or key_id are first set during upload.', + 'staged r2-direct cannot UPDATE key_id' +); + +SELECT throws_ok( + $sql$ + UPDATE public.app_versions + SET external_url = 'https://evil.example/bundle.zip' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-staged' + $sql$, + 'P0001', + 'bundle_identity_locked: Bundle identity fields cannot be changed after checksum, session_key, or key_id are first set during upload.', + 'staged r2-direct cannot UPDATE external_url' +); + +SELECT throws_ok( + $sql$ + UPDATE public.app_versions + SET storage_provider = 'external' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-staged' + $sql$, + 'P0001', + 'bundle_identity_locked: Bundle identity fields cannot be changed after checksum, session_key, or key_id are first set during upload.', + 'staged r2-direct cannot redirect storage_provider away from finalize' +); + +SELECT throws_ok( + $sql$ + UPDATE public.app_versions + SET checksum = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-finalized' + $sql$, + 'P0001', + 'bundle_already_ready: Bundle content cannot be changed after upload is complete. Upload a new bundle instead.', + 'finalized r2 bundle cannot UPDATE checksum' +); + +SELECT throws_ok( + $sql$ + UPDATE public.app_versions + SET r2_path = 'orgs/70000000-0000-4000-8000-000000000071/apps/com.test.r2direct.upload.lock/rewritten.zip' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-finalized' + $sql$, + 'P0001', + 'bundle_already_ready: Bundle content cannot be changed after upload is complete. Upload a new bundle instead.', + 'finalized r2 bundle cannot UPDATE r2_path' +); + +SELECT lives_ok( + $sql$ + UPDATE public.app_versions + SET + storage_provider = 'r2', + r2_path = 'orgs/70000000-0000-4000-8000-000000000071/apps/com.test.r2direct.upload.lock/1.0.0-staged.zip' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-staged' + $sql$, + 'staged r2-direct can finalize to r2' +); + +SELECT lives_ok( + $sql$ + UPDATE public.app_versions + SET r2_path = 'orgs/70000000-0000-4000-8000-000000000071/apps/com.test.r2direct.upload.lock/1.0.0-in-progress.zip' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-in-progress' + $sql$, + 'in-progress r2-direct without checksum can UPDATE r2_path' +); + +SELECT lives_ok( + $sql$ + UPDATE public.app_versions + SET + checksum = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + session_key = 'session-key-first-set' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-in-progress' + $sql$, + 'in-progress r2-direct can set checksum and session_key once' +); + +SELECT lives_ok( + $sql$ + UPDATE public.app_versions + SET comment = 'updated in-progress comment' + WHERE app_id = 'com.test.r2direct.upload.lock' + AND name = '1.0.0-in-progress' + $sql$, + 'checksum-locked r2-direct can still update non-content metadata' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/tests/channel_self.test.ts b/tests/channel_self.test.ts index 934c0b2c66..f6e66fc59a 100644 --- a/tests/channel_self.test.ts +++ b/tests/channel_self.test.ts @@ -3,7 +3,7 @@ import type { DeviceLink, HttpMethod } from './test-utils.ts' import { randomUUID } from 'node:crypto' import { env } from 'node:process' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { getBaseData, getSupabaseClient, PLUGIN_BASE_URL, resetAndSeedAppData, resetAppData, resetAppDataStats } from './test-utils.ts' +import { fetchTestRequest, getBaseData, getEndpointUrl, getSupabaseClient, PLUGIN_BASE_URL, resetAndSeedAppData, resetAppData, resetAppDataStats, warmEdgeEndpoint } from './test-utils.ts' interface ChannelInfo { id: number @@ -32,12 +32,10 @@ async function fetchEndpoint(method: HttpMethod, bodyIn: object) { } const body = method !== 'DELETE' ? JSON.stringify(bodyIn) : undefined - const response = await fetch(url, { + return fetchTestRequest(url.toString(), { method, body, }) - - return response } async function fetchGetChannels(queryParams: Record) { @@ -45,11 +43,9 @@ async function fetchGetChannels(queryParams: Record) { for (const [key, value] of Object.entries(queryParams)) url.searchParams.append(key, value) - const response = await fetch(url, { + return fetchTestRequest(url.toString(), { method: 'GET', }) - - return response } async function getResponseErrorCode(response: Response) { @@ -67,6 +63,12 @@ async function withSupabaseCall { await resetAndSeedAppData(APPNAME) + // Cold first /channel_self request can 502 under Deno shard load; warm before assertions. + await warmEdgeEndpoint(getEndpointUrl('/channel_self'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(getBaseData(APPNAME)), + }) }) afterAll(async () => { await resetAppData(APPNAME) diff --git a/tests/cli-min-version.test.ts b/tests/cli-min-version.test.ts index 0f60ed5ae2..ee7fc04170 100644 --- a/tests/cli-min-version.test.ts +++ b/tests/cli-min-version.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createTestSDK, uploadBundleSDK } from './cli-sdk-utils' import { cleanupCli, getSemver, prepareCli } from './cli-utils' -import { createIsolatedSeedAppOptions, getSupabaseClient, resetAndSeedAppData, resetAppData, resetAppDataStats } from './test-utils' +import { createIsolatedSeedAppOptions, executeSQL, getSupabaseClient, resetAndSeedAppData, resetAppData, resetAppDataStats } from './test-utils' async function writeBundleContent(appId: string, marker: string) { const indexHtmlPath = join(process.cwd(), 'temp_cli_test', appId, 'dist', 'index.html') @@ -130,14 +130,17 @@ describe('tests min version', () => { expect(checkErrorNew).toBeDefined() expect(dataNew).toBeNull() - // Clear native_packages from previous version to simulate first upload - const { error: error2 } = await supabase - .from('app_versions') - .update({ min_update_version: null, native_packages: null }) - .eq('name', semverDefault) - .eq('app_id', APPNAME) - .throwOnError() - expect(error2).toBeNull() + // Simulate a channel whose current bundle has no compatibility metadata. + await executeSQL( + 'DELETE FROM public.app_versions WHERE app_id = $1 AND name = ANY($2::varchar[])', + [APPNAME, [semverDefault, semverNew]], + ) + const relinkSeed = await sdk.updateChannel({ + appId: APPNAME, + channelId: channelName, + bundle: '1.0.0', + }) + expect(relinkSeed.success).toBe(true) // Upload with auto-min-update-version when previous version has no native_packages const semverWithNull = `1.0.${testId + 2}` diff --git a/tests/plugin-credits-flag.test.ts b/tests/plugin-credits-flag.test.ts index 1dfb493d87..232c16e718 100644 --- a/tests/plugin-credits-flag.test.ts +++ b/tests/plugin-credits-flag.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { executeSQL, getBaseData, getSupabaseClient, postUpdate, resetAndSeedAppData, resetAppData } from './test-utils.ts' +import { executeSQL, getBaseData, getEndpointUrl, getSupabaseClient, postUpdate, resetAndSeedAppData, resetAppData, warmEdgeEndpoint } from './test-utils.ts' describe('plugin plan gating: credits flag', () => { const supabase = getSupabaseClient() @@ -28,6 +28,13 @@ describe('plugin plan gating: credits flag', () => { expect(stripeRows[0]?.is_good_plan).toBe(false) const orgRows = await executeSQL('SELECT has_usage_credits FROM public.orgs WHERE id = $1', [orgId]) expect(orgRows[0]?.has_usage_credits).toBe(false) + + // Cold /updates isolate can 503 under Cloudflare shard load; warm before assertions. + await warmEdgeEndpoint(getEndpointUrl('/updates'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(getBaseData(appId)), + }) }) afterAll(async () => { diff --git a/tests/rbac-permissions.test.ts b/tests/rbac-permissions.test.ts index 7640b31245..85a5c7f34b 100644 --- a/tests/rbac-permissions.test.ts +++ b/tests/rbac-permissions.test.ts @@ -709,11 +709,51 @@ describe('rbac permission system', () => { }) it('serializes preview bundle lifecycle locks across promotion and deletion transactions', async () => { + const slug = randomUUID().slice(0, 8) + const orgId = randomUUID() + const appId = `com.rbac.bundle-lock.${slug}` + + const setup = await pool.connect() + let lowerBundleId: string + let higherBundleId: string + try { + await setup.query('BEGIN') + await setup.query(` + INSERT INTO public.orgs (id, name, management_email, created_by) + VALUES ($1::uuid, $2, $3, $4::uuid) + `, [orgId, `Bundle Lock Org ${slug}`, `bundle-lock-${slug}@capgo.app`, USER_ID]) + + await setup.query(` + INSERT INTO public.apps (app_id, name, icon_url, owner_org) + VALUES ($1, $2, 'https://example.com/icon.png', $3::uuid) + `, [appId, `Bundle Lock App ${slug}`, orgId]) + + const lowerBundle = await setup.query(` + INSERT INTO public.app_versions (app_id, name, owner_org, user_id, storage_provider) + VALUES ($1, $2, $3::uuid, $4::uuid, 'r2-direct') + RETURNING id::text + `, [appId, `bundle-lock-lower-${slug}`, orgId, USER_ID]) + const higherBundle = await setup.query(` + INSERT INTO public.app_versions (app_id, name, owner_org, user_id, storage_provider) + VALUES ($1, $2, $3::uuid, $4::uuid, 'r2-direct') + RETURNING id::text + `, [appId, `bundle-lock-higher-${slug}`, orgId, USER_ID]) + + lowerBundleId = lowerBundle.rows[0]!.id + higherBundleId = higherBundle.rows[0]!.id + expect(BigInt(lowerBundleId) < BigInt(higherBundleId)).toBe(true) + await setup.query('COMMIT') + } + catch (error) { + await setup.query('ROLLBACK') + throw error + } + finally { + setup.release() + } + const holder = await pool.connect() const waiter = await pool.connect() - const lockSeed = BigInt(Date.now()) * BigInt(1_000_000) + BigInt(Math.floor(Math.random() * 1_000_000)) - const lowerBundleId = lockSeed.toString() - const higherBundleId = (lockSeed + BigInt(1)).toString() try { await holder.query('BEGIN') @@ -734,6 +774,16 @@ describe('rbac permission system', () => { await waiter.query('ROLLBACK') holder.release() waiter.release() + + const cleanup = await pool.connect() + try { + await cleanup.query('DELETE FROM public.app_versions WHERE app_id = $1', [appId]) + await cleanup.query('DELETE FROM public.apps WHERE app_id = $1', [appId]) + await cleanup.query('DELETE FROM public.orgs WHERE id = $1::uuid', [orgId]) + } + finally { + cleanup.release() + } } }) @@ -772,6 +822,43 @@ describe('rbac permission system', () => { await query('ROLLBACK TO SAVEPOINT channel_insert_requires_promotion') expect((denied as { message?: string } | undefined)?.message).toContain('PERMISSION_DENIED_CHANNEL_PROMOTE_BUNDLE') + await query('SAVEPOINT channel_insert_rollout_existing_denied') + let deniedRolloutExisting: unknown + try { + await query(` + INSERT INTO public.channels (name, app_id, version, rollout_version, created_by, owner_org) + VALUES ($1, $2, NULL, $3::bigint, $4::uuid, $5::uuid) + `, [`trigger-denied-rollout-existing-${suffix}`, target.app_id, target.version_id, requesterId, target.owner_org]) + } + catch (error) { + deniedRolloutExisting = error + } + await query('ROLLBACK TO SAVEPOINT channel_insert_rollout_existing_denied') + expect((deniedRolloutExisting as { message?: string } | undefined)?.message).toContain('PERMISSION_DENIED_CHANNEL_PROMOTE_BUNDLE') + expect((deniedRolloutExisting as { message?: string } | undefined)?.message).not.toContain('INVALID_CHANNEL_BUNDLE') + + const missingRolloutResult = await query(` + SELECT COALESCE(MAX(id), 0) + 9000000000000 AS missing_rollout_id + FROM public.app_versions + `) + const missingRolloutId = missingRolloutResult.rows[0]?.missing_rollout_id + expect(missingRolloutId).toBeTruthy() + + await query('SAVEPOINT channel_insert_rollout_missing_denied') + let deniedRolloutMissing: unknown + try { + await query(` + INSERT INTO public.channels (name, app_id, version, rollout_version, created_by, owner_org) + VALUES ($1, $2, NULL, $3::bigint, $4::uuid, $5::uuid) + `, [`trigger-denied-rollout-missing-${suffix}`, target.app_id, missingRolloutId, requesterId, target.owner_org]) + } + catch (error) { + deniedRolloutMissing = error + } + await query('ROLLBACK TO SAVEPOINT channel_insert_rollout_missing_denied') + expect((deniedRolloutMissing as { message?: string } | undefined)?.message).toContain('PERMISSION_DENIED_CHANNEL_PROMOTE_BUNDLE') + expect((deniedRolloutMissing as { message?: string } | undefined)?.message).not.toContain('INVALID_CHANNEL_BUNDLE') + const blank = await query(` INSERT INTO public.channels (name, app_id, version, created_by, owner_org) VALUES ($1, $2, NULL, $3::uuid, $4::uuid)