diff --git a/.github/workflows/reset-staging-account.yml b/.github/workflows/reset-staging-account.yml new file mode 100644 index 00000000..bfc4af47 --- /dev/null +++ b/.github/workflows/reset-staging-account.yml @@ -0,0 +1,81 @@ +name: Reset Staging Account + +on: + workflow_dispatch: + inputs: + expected_deploy_sha: + description: Exact 40-character SHA currently deployed to staging + required: true + type: string + email: + description: Exact staging account email + required: true + type: string + confirmation: + description: Type RESET followed by a space and the exact email + required: true + type: string + +permissions: + contents: read + +concurrency: + group: staging-mutation + cancel-in-progress: false + +jobs: + reset: + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: staging + url: ${{ vars.STAGING_URL }} + env: + DEPLOY_HOST: ${{ vars.STAGING_HOST }} + DEPLOY_PORT: ${{ vars.STAGING_PORT }} + DEPLOY_USER: ${{ vars.STAGING_USER }} + DEPLOY_PATH: ${{ vars.STAGING_PATH }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + EXPECTED_DEPLOY_SHA: ${{ inputs.expected_deploy_sha }} + RESET_EMAIL: ${{ inputs.email }} + RESET_CONFIRMATION: ${{ inputs.confirmation }} + + steps: + - name: Checkout trusted controller + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + + - name: Validate account reset request and staging target + run: | + set -euo pipefail + [[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$RESET_EMAIL" =~ ^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,63}$ ]] + test "$RESET_CONFIRMATION" = "RESET $RESET_EMAIL" + test "$DEPLOY_HOST" = "118.26.111.127" + test "$DEPLOY_PORT" = "22" + test "$DEPLOY_USER" = "deploy" + test "$DEPLOY_PATH" = "/opt/jyotisha-staging" + test -n "$STAGING_KNOWN_HOSTS" + bash -n deploy/reset-staging-account.sh + + - name: Configure pinned staging SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + test -n "$SSH_PRIVATE_KEY" + install -d -m 700 ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + chmod 600 ~/.ssh/jyotisha-staging + printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Reset one staging account under host lock + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=10" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "DEPLOY_PATH='$DEPLOY_PATH' EXPECTED_DEPLOY_SHA='$EXPECTED_DEPLOY_SHA' RESET_EMAIL='$RESET_EMAIL' RESET_CONFIRMATION='$RESET_CONFIRMATION' bash -s" \ + < deploy/reset-staging-account.sh diff --git a/deploy/reset-staging-account.sh b/deploy/reset-staging-account.sh new file mode 100755 index 00000000..844a4050 --- /dev/null +++ b/deploy/reset-staging-account.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=(DEPLOY_PATH EXPECTED_DEPLOY_SHA RESET_EMAIL RESET_CONFIRMATION) +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required staging account-reset input is missing: $key" >&2 + exit 1 + fi +done + +[[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "invalid expected deployment SHA" >&2 + exit 1 +} +[[ "$RESET_EMAIL" =~ ^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,63}$ ]] || { + echo "invalid reset email" >&2 + exit 1 +} +[ "$RESET_CONFIRMATION" = "RESET $RESET_EMAIL" ] || { + echo "account reset confirmation does not match" >&2 + exit 1 +} +[ "$DEPLOY_PATH" = "/opt/jyotisha-staging" ] || { + echo "refusing non-staging deployment path" >&2 + exit 1 +} + +state_directory="$DEPLOY_PATH/.state" +[ -f "$state_directory/deployed-revision" ] || { + echo "staging deployed revision is unavailable" >&2 + exit 1 +} +[ "$(<"$state_directory/deployed-revision")" = "$EXPECTED_DEPLOY_SHA" ] || { + echo "deployed staging revision does not match the approved reset SHA" >&2 + exit 1 +} + +install -d -m 700 "$state_directory" +exec 9>"$state_directory/mutation.lock" +flock -n 9 || { + echo "another staging mutation holds the host lock" >&2 + exit 75 +} + +cd "$DEPLOY_PATH" +compose=(docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml) +"${compose[@]}" ps --status running postgres --quiet | grep -q . || { + echo "staging postgres container is not running" >&2 + exit 1 +} + +run_psql() { + "${compose[@]}" exec -T -e RESET_EMAIL="$RESET_EMAIL" postgres sh -ceu ' + exec psql -X -v ON_ERROR_STOP=1 -v target_email="$RESET_EMAIL" \ + -U "$POSTGRES_USER" -d "$POSTGRES_DB" + ' +} + +run_psql <<'SQL' +begin; + +create temporary table reset_snapshot on commit drop as +select + identity_user.id, + identity_user.email, + profile.email as profile_email, + profile.credits, + (select count(*) from identity.accounts value where value.user_id = identity_user.id) as identity_accounts, + (select count(*) from identity.sessions value where value.user_id = identity_user.id) as identity_sessions, + (select count(*) from public.credit_transactions value where value.user_id = identity_user.id) as credit_transactions, + (select count(*) from public.credit_request_cancellations value where value.user_id = identity_user.id) as credit_cancellations, + (select count(*) from public.consultation_requests value where value.user_id = identity_user.id) as consultation_requests, + (select count(*) from public.birth_time_rectification_billing value where value.user_id = identity_user.id) as rectification_billing, + (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = identity_user.id) as action_receipts, + (select count(*) from public.redemption_codes value where value.redeemed_by = identity_user.id) as redeemed_codes, + (select count(*) from audit.admin_audit_logs value where value.actor_user_id = identity_user.id) as admin_audit_logs +from identity.users identity_user +join auth.users auth_user on auth_user.id = identity_user.id +join public.profiles profile on profile.id = identity_user.id +where lower(btrim(identity_user.email)) = lower(btrim(:'target_email')) + and lower(btrim(auth_user.email)) = lower(btrim(:'target_email')) +for update of identity_user, auth_user, profile; + +do $$ +begin + if (select count(*) from reset_snapshot) <> 1 then + raise exception 'account_not_found_or_identity_bridge_mismatch'; + end if; +end $$; + +select jsonb_build_object( + 'stage', 'preflight', + 'email', snapshot.email, + 'credits', snapshot.credits, + 'identityAccounts', snapshot.identity_accounts, + 'identitySessions', snapshot.identity_sessions, + 'creditTransactions', snapshot.credit_transactions, + 'creditCancellations', snapshot.credit_cancellations, + 'consultationRequests', snapshot.consultation_requests, + 'rectificationBilling', snapshot.rectification_billing, + 'actionReceipts', snapshot.action_receipts, + 'redeemedCodes', snapshot.redeemed_codes, + 'adminAuditLogs', snapshot.admin_audit_logs, + 'chatSessions', (select count(*) from public.chat_sessions value where value.user_id = snapshot.id), + 'chartProfiles', (select count(*) from public.chart_profiles value where value.user_id = snapshot.id), + 'synastryReports', (select count(*) from public.synastry_reports value where value.user_id = snapshot.id), + 'legacyRectificationCases', (select count(*) from public.birth_time_rectification_cases value where value.user_id = snapshot.id), + 'v5RectificationCases', (select count(*) from public.birth_time_rectification_v4_cases value where value.user_id = snapshot.id), + 'v5AgentRuns', (select count(*) from public.birth_time_rectification_agent_runs value where value.user_id = snapshot.id), + 'v5Diagnostics', (select count(*) from public.birth_time_rectification_diagnostics value where value.user_id = snapshot.id), + 'v5Jobs', (select count(*) from public.birth_time_rectification_v4_jobs value where value.user_id = snapshot.id) +) +from reset_snapshot snapshot; + +update public.profiles profile +set name = null, + birth_date = null, + birth_time = null, + country_code = null, + province_code = null, + city_code = null, + district_code = null, + onboarding_payload = null, + onboarding_version = null, + onboarding_generated_at = null, + latitude = null, + longitude = null, + timezone_offset = null, + reported_birth_time = null, + active_birth_time = null, + birth_time_source = null, + birth_time_period = null, + birth_time_clue = null, + uncertainty_before_minutes = null, + uncertainty_after_minutes = null, + birth_time_status = null, + rectification_confidence = null, + rectification_case_id = null, + birth_place_label = null, + birth_place_type = null, + birth_place_provider = null, + birth_place_provider_id = null, + timezone_id = null, + timezone_source = null, + updated_at = pg_catalog.now() +from reset_snapshot snapshot +where profile.id = snapshot.id; + +delete from public.chat_sessions value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.chart_profiles value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.synastry_reports value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.birth_time_rectification_v4_cases value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.birth_time_rectification_cases value using reset_snapshot snapshot where value.user_id = snapshot.id; + +do $$ +begin + if exists ( + select 1 + from reset_snapshot snapshot + join identity.users identity_user on identity_user.id = snapshot.id + join auth.users auth_user on auth_user.id = snapshot.id + join public.profiles profile on profile.id = snapshot.id + where identity_user.email is distinct from snapshot.email + or auth_user.email is distinct from snapshot.email + or profile.email is distinct from snapshot.profile_email + or profile.credits is distinct from snapshot.credits + or (select count(*) from identity.accounts value where value.user_id = snapshot.id) <> snapshot.identity_accounts + or (select count(*) from identity.sessions value where value.user_id = snapshot.id) <> snapshot.identity_sessions + or (select count(*) from public.credit_transactions value where value.user_id = snapshot.id) <> snapshot.credit_transactions + or (select count(*) from public.credit_request_cancellations value where value.user_id = snapshot.id) <> snapshot.credit_cancellations + or (select count(*) from public.consultation_requests value where value.user_id = snapshot.id) <> snapshot.consultation_requests + or (select count(*) from public.birth_time_rectification_billing value where value.user_id = snapshot.id) <> snapshot.rectification_billing + or (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = snapshot.id) <> snapshot.action_receipts + or (select count(*) from public.redemption_codes value where value.redeemed_by = snapshot.id) <> snapshot.redeemed_codes + or (select count(*) from audit.admin_audit_logs value where value.actor_user_id = snapshot.id) <> snapshot.admin_audit_logs + ) then + raise exception 'preserved_state_changed'; + end if; + + if exists (select 1 from public.chat_sessions value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.chart_profiles value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.synastry_reports value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_cases value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_v4_cases value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_v4_jobs value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_agent_runs value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_diagnostics value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_candidate_feature_snapshots value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_public_messages value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_pending_evidence value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists ( + select 1 from public.profiles profile join reset_snapshot snapshot on profile.id = snapshot.id + where profile.name is not null or profile.birth_date is not null or profile.birth_time is not null + or profile.country_code is not null or profile.province_code is not null or profile.city_code is not null or profile.district_code is not null + or profile.onboarding_payload is not null or profile.onboarding_version is not null or profile.onboarding_generated_at is not null + or profile.latitude is not null or profile.longitude is not null or profile.timezone_offset is not null + or profile.reported_birth_time is not null or profile.active_birth_time is not null or profile.birth_time_source is not null + or profile.birth_time_period is not null or profile.birth_time_clue is not null + or profile.uncertainty_before_minutes is not null or profile.uncertainty_after_minutes is not null + or profile.birth_time_status is not null or profile.rectification_confidence is not null or profile.rectification_case_id is not null + or profile.birth_place_label is not null or profile.birth_place_type is not null or profile.birth_place_provider is not null + or profile.birth_place_provider_id is not null or profile.timezone_id is not null or profile.timezone_source is not null + ) then + raise exception 'reset_state_not_empty'; + end if; +end $$; + +commit; +SQL + +run_psql <<'SQL' +begin; + +create temporary table postflight_target on commit drop as +select identity_user.id, identity_user.email, profile.credits, + not ( + profile.name is null and profile.birth_date is null and profile.birth_time is null + and profile.country_code is null and profile.province_code is null and profile.city_code is null and profile.district_code is null + and profile.onboarding_payload is null and profile.onboarding_version is null and profile.onboarding_generated_at is null + and profile.latitude is null and profile.longitude is null and profile.timezone_offset is null + and profile.reported_birth_time is null and profile.active_birth_time is null and profile.birth_time_source is null + and profile.birth_time_period is null and profile.birth_time_clue is null + and profile.uncertainty_before_minutes is null and profile.uncertainty_after_minutes is null + and profile.birth_time_status is null and profile.rectification_confidence is null and profile.rectification_case_id is null + and profile.birth_place_label is null and profile.birth_place_type is null and profile.birth_place_provider is null + and profile.birth_place_provider_id is null and profile.timezone_id is null and profile.timezone_source is null + ) as profile_not_reset, + lower(btrim(profile.email)) = lower(btrim(identity_user.email)) as profile_email_matches +from identity.users identity_user +join auth.users auth_user on auth_user.id = identity_user.id + and lower(btrim(auth_user.email)) = lower(btrim(identity_user.email)) +join public.profiles profile on profile.id = identity_user.id +where lower(btrim(identity_user.email)) = lower(btrim(:'target_email')); + +do $$ +declare + target_id uuid; +begin + if (select count(*) from postflight_target) <> 1 then + raise exception 'postflight_account_not_found_or_identity_bridge_mismatch'; + end if; + select id into target_id from postflight_target; + + if exists (select 1 from public.chat_sessions value where value.user_id = target_id) + or exists (select 1 from public.chart_profiles value where value.user_id = target_id) + or exists (select 1 from public.synastry_reports value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_cases value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_v4_cases value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_v4_jobs value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_agent_runs value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_diagnostics value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_candidate_feature_snapshots value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_public_messages value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_pending_evidence value where value.user_id = target_id) + or exists (select 1 from postflight_target where profile_not_reset) then + raise exception 'postflight_reset_state_not_empty'; + end if; +end $$; + +select jsonb_build_object( + 'stage', 'postflight', + 'matchedAccounts', (select count(*) from postflight_target), + 'email', (select email from postflight_target), + 'credits', (select credits from postflight_target), + 'profileEmailMatches', (select profile_email_matches from postflight_target), + 'profileNotReset', (select profile_not_reset from postflight_target), + 'chatSessions', (select count(*) from public.chat_sessions value where value.user_id = (select id from postflight_target)), + 'chartProfiles', (select count(*) from public.chart_profiles value where value.user_id = (select id from postflight_target)), + 'synastryReports', (select count(*) from public.synastry_reports value where value.user_id = (select id from postflight_target)), + 'legacyRectificationCases', (select count(*) from public.birth_time_rectification_cases value where value.user_id = (select id from postflight_target)), + 'v5RectificationCases', (select count(*) from public.birth_time_rectification_v4_cases value where value.user_id = (select id from postflight_target)), + 'v5Jobs', (select count(*) from public.birth_time_rectification_v4_jobs value where value.user_id = (select id from postflight_target)), + 'v5AgentRuns', (select count(*) from public.birth_time_rectification_agent_runs value where value.user_id = (select id from postflight_target)), + 'v5Diagnostics', (select count(*) from public.birth_time_rectification_diagnostics value where value.user_id = (select id from postflight_target)), + 'v5FeatureSnapshots', (select count(*) from public.birth_time_rectification_candidate_feature_snapshots value where value.user_id = (select id from postflight_target)), + 'v5PublicMessages', (select count(*) from public.birth_time_rectification_public_messages value where value.user_id = (select id from postflight_target)), + 'v5PendingEvidence', (select count(*) from public.birth_time_rectification_pending_evidence value where value.user_id = (select id from postflight_target)), + 'identityAccounts', (select count(*) from identity.accounts value where value.user_id = (select id from postflight_target)), + 'identitySessions', (select count(*) from identity.sessions value where value.user_id = (select id from postflight_target)), + 'creditTransactions', (select count(*) from public.credit_transactions value where value.user_id = (select id from postflight_target)), + 'creditCancellations', (select count(*) from public.credit_request_cancellations value where value.user_id = (select id from postflight_target)), + 'consultationRequests', (select count(*) from public.consultation_requests value where value.user_id = (select id from postflight_target)), + 'rectificationBilling', (select count(*) from public.birth_time_rectification_billing value where value.user_id = (select id from postflight_target)), + 'actionReceipts', (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = (select id from postflight_target)) +); + +commit; +SQL