Protected Terraform States: Add enforcement for state protection rules [PART 2]

What does this MR do and why?

Add enforcement of Terraform state protection rules at the REST API layer. When a protection rule exists for a state name, mutable operations (write, delete, lock, unlock) are gated by the user's access level and the request source.

This is MR 2 of 7 for the protected Terraform states feature. It builds on MR1 (DB + Model) !225580 to add the enforcement logic.

What's included:

  • Feature flag protected_terraform_states (gitlab_com_derisk, disabled by default)
  • CheckRuleExistenceService following the Packages::Protection::CheckRuleExistenceService pattern:
    • Frozen ServiceResponse constants (zero allocations per request)
    • Feature flag check first (early return, no DB query when disabled)
    • Admin bypass via can_admin_all_resources?
    • Role check: user access level vs minimum_access_level_for_write
    • Source check: allowed_from enum (anywhere, ci_only, ci_on_protected_branch_only)
  • REST API integration: check_terraform_state_protection! helper called after authorize! in 4 mutable endpoints:
    • POST /state/:name (write state)
    • DELETE /state/:name (delete state)
    • POST /state/:name/lock (lock)
    • DELETE /state/:name/lock (unlock)
    • GET (read) intentionally NOT protected
  • Returns 403 with descriptive error message when a protection rule blocks the request
  • 15 service spec examples + shared examples in API spec covering enforcement, admin bypass, feature flag toggle

Performance: Zero overhead when feature flag is off. One indexed DB query per mutable API call when enabled.

References

How to set up and validate locally

  1. Enable the feature flag:

    Feature.enable(:protected_terraform_states)
  2. Create a protection rule via Rails console:

    project = Project.find(<PROJECT_ID>)
    project.terraform_state_protection_rules.create!(
      state_name: 'production',
      minimum_access_level_for_write: :maintainer,
      allowed_from: :anywhere
    )
  3. tofu apply as maintainer — should succeed

  4. tofu apply as developer — should fail at lock step (403)

  5. Change rule to ci_only, tofu apply as maintainer via PAT — should fail (403)

  6. tofu state pull — should always succeed (reads are not enforced)

  7. Disable the feature flag — all operations should work as before

Note: OpenTofu reports 403 errors as "HTTP remote state endpoint invalid auth" during lock acquisition.

Test kit (OpenTofu + Docker Compose)

Download and extract the attached demo-terraform-project-mr2.tar.gz.

cp .env.example .env
# Edit .env: set PROJECT_ID and GITLAB_PAT (api scope)

Set up environment variables and init:

source .env
export TF_HTTP_ADDRESS="${GITLAB_URL}/api/v4/projects/${PROJECT_ID}/terraform/state/${TF_STATE_NAME}"
export TF_HTTP_LOCK_ADDRESS="${TF_HTTP_ADDRESS}/lock"
export TF_HTTP_UNLOCK_ADDRESS="${TF_HTTP_ADDRESS}/lock"
export TF_HTTP_LOCK_METHOD=POST
export TF_HTTP_UNLOCK_METHOD=DELETE
export TF_HTTP_USERNAME="${GITLAB_USERNAME}"
export TF_HTTP_PASSWORD="${GITLAB_PAT}"
tofu init -reconfigure

Maintainer applies — should succeed:

tofu apply -auto-approve

Developer applies — should get 403:

Update .env with developer credentials, then re-source, re-export, and re-init:

tofu init -reconfigure
tofu apply -auto-approve    # => Error: "HTTP remote state endpoint invalid auth"

Source restriction (ci_only) — maintainer PAT should get 403:

project.terraform_state_protection_rules.find_by_state_name('production')
  .update!(allowed_from: :ci_only)
# Re-source + re-export with maintainer credentials
tofu init -reconfigure
tofu apply -auto-approve    # => 403

Read is never blocked:

tofu state pull    # => always succeeds

Feature flag off — no enforcement:

Feature.disable(:protected_terraform_states)
tofu apply -auto-approve    # => succeeds

Stale lock? If a previous failed apply left the state locked:

tofu force-unlock <LOCK_ID>

MR acceptance checklist

  • Tests added for this feature
  • Conforms to the code review guidelines
  • Conforms to the style guides
  • Feature flag added with gitlab_com_derisk type (disabled by default)
  • Zero overhead when feature flag is disabled
Edited by Gerardo Navarro

Merge request reports

Loading