diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 98116b640..bfc5acff8 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -342,6 +342,14 @@ gh workflow run database-migration.yml \ -f direction=down \ -f steps=2 +# Rollback 1 migration on AWS prod (requires typed confirmation) +gh workflow run database-migration.yml \ + -f cloud=aws \ + -f environment=prod \ + -f direction=down \ + -f steps=1 \ + -f confirm=rollback-prod + # Apply to all clouds gh workflow run database-migration.yml \ -f cloud=all \ @@ -351,10 +359,11 @@ gh workflow run database-migration.yml \ ### Safety Features -- **Validation** - Checks migration files exist -- **Production Warnings** - Warns on prod down migrations -- **Audit Trail** - Records all migrations -- **Step Control** - Apply specific number of migrations +- **Validation** - Checks migration files exist before running +- **Explicit steps required** - `direction=down` requires an explicit positive `steps` value; `steps=0` (the default, which would run `down -all` and drop the entire schema) is rejected +- **Production confirmation** - `direction=down` on `environment=prod` additionally requires typing `rollback-prod` in the `confirm` input; omitting or mistyping it blocks the run +- **Defense in depth** - each migrate job independently re-validates the positive-steps constraint, so a future validate regression cannot reach `down -all` +- **Audit Trail** - Records all migrations in the step summary --- diff --git a/.github/workflows/database-migration.yml b/.github/workflows/database-migration.yml index 0a6463e9d..29b39b479 100644 --- a/.github/workflows/database-migration.yml +++ b/.github/workflows/database-migration.yml @@ -47,10 +47,15 @@ on: options: [up, down] default: up steps: - description: 'Number of migrations to apply/rollback (0 = all)' + description: 'Number of migrations to apply/rollback (0 = all; for direction=down an explicit positive value is required)' required: false type: number default: 0 + confirm: + description: 'Type "rollback-prod" to confirm a down migration on prod (ignored otherwise)' + required: false + type: string + default: '' workflow_call: inputs: cloud: @@ -83,6 +88,11 @@ jobs: - name: Safety checks id: check + env: + ENVIRONMENT: ${{ inputs.environment }} + DIRECTION: ${{ inputs.direction }} + STEPS: ${{ inputs.steps }} + CONFIRM: ${{ inputs.confirm }} run: | IS_SAFE=true @@ -92,11 +102,25 @@ jobs: IS_SAFE=false fi - # Warn on production down migrations - if [[ "${{ inputs.environment }}" == "prod" ]] && [[ "${{ inputs.direction }}" == "down" ]]; then - echo "⚠️ WARNING: Attempting to rollback migrations on PRODUCTION" - echo "This operation is destructive and may cause data loss!" - # For production, we don't auto-fail, but require manual confirmation + # Down migrations must specify an explicit positive step count. + # steps=0 (the default) would run 'migrate down -all' and drop the + # entire schema, so it is rejected here for direction=down. + if [[ "$DIRECTION" == "down" ]]; then + if ! [[ "$STEPS" =~ ^[1-9][0-9]*$ ]]; then + echo "❌ direction=down requires an explicit positive 'steps' value (got '${STEPS:-}')." + echo "Rolling back ALL migrations at once is not supported by this workflow." + IS_SAFE=false + fi + + # Production down migrations additionally require typed confirmation. + if [[ "$ENVIRONMENT" == "prod" ]]; then + echo "⚠️ WARNING: Attempting to rollback migrations on PRODUCTION" + echo "This operation is destructive and may cause data loss!" + if [[ "$CONFIRM" != "rollback-prod" ]]; then + echo "❌ Production rollback requires typing 'rollback-prod' in the 'confirm' input." + IS_SAFE=false + fi + fi fi # Check migration files @@ -116,6 +140,11 @@ jobs: echo "is_safe=$IS_SAFE" >> $GITHUB_OUTPUT + if [[ "$IS_SAFE" != "true" ]]; then + echo "Validation failed; refusing to run migrations." + exit 1 + fi + - name: Display migration plan run: | echo "## Database Migration Plan" >> $GITHUB_STEP_SUMMARY @@ -148,7 +177,7 @@ jobs: persist-credentials: false - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod @@ -188,13 +217,13 @@ jobs: migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" up ${{ inputs.steps }} fi else - if [[ "${{ inputs.steps }}" == "0" ]]; then - echo "Rolling back all migrations..." - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down -all - else - echo "Rolling back ${{ inputs.steps }} migration(s)..." - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down ${{ inputs.steps }} + # Defense in depth: validate already rejects this, but never run 'down -all'. + if ! [[ "${{ inputs.steps }}" =~ ^[1-9][0-9]*$ ]]; then + echo "❌ Refusing to roll back without an explicit positive 'steps' value." + exit 1 fi + echo "Rolling back ${{ inputs.steps }} migration(s)..." + migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down ${{ inputs.steps }} fi - name: Get migration version @@ -224,7 +253,7 @@ jobs: persist-credentials: false - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod @@ -265,11 +294,12 @@ jobs: migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" up ${{ inputs.steps }} fi else - if [[ "${{ inputs.steps }}" == "0" ]]; then - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down -all - else - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down ${{ inputs.steps }} + # Defense in depth: validate already rejects this, but never run 'down -all'. + if ! [[ "${{ inputs.steps }}" =~ ^[1-9][0-9]*$ ]]; then + echo "❌ Refusing to roll back without an explicit positive 'steps' value." + exit 1 fi + migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down ${{ inputs.steps }} fi # Run Azure migrations @@ -290,7 +320,7 @@ jobs: persist-credentials: false - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod @@ -329,11 +359,12 @@ jobs: migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" up ${{ inputs.steps }} fi else - if [[ "${{ inputs.steps }}" == "0" ]]; then - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down -all - else - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down ${{ inputs.steps }} + # Defense in depth: validate already rejects this, but never run 'down -all'. + if ! [[ "${{ inputs.steps }}" =~ ^[1-9][0-9]*$ ]]; then + echo "❌ Refusing to roll back without an explicit positive 'steps' value." + exit 1 fi + migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down ${{ inputs.steps }} fi # Summary