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) CheckRuleExistenceServicefollowing thePackages::Protection::CheckRuleExistenceServicepattern:- Frozen
ServiceResponseconstants (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_fromenum (anywhere, ci_only, ci_on_protected_branch_only)
- Frozen
- REST API integration:
check_terraform_state_protection!helper called afterauthorize!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
- Epic: &15118
- MR1 (DB + Model): !225580 (merged)
- POC MR: !224329 (closed)
- Reference pattern:
Packages::Protection::CheckRuleExistenceService
How to set up and validate locally
-
Enable the feature flag:
Feature.enable(:protected_terraform_states) -
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 ) -
tofu applyas maintainer — should succeed -
tofu applyas developer — should fail at lock step (403) -
Change rule to
ci_only,tofu applyas maintainer via PAT — should fail (403) -
tofu state pull— should always succeed (reads are not enforced) -
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 -reconfigureMaintainer applies — should succeed:
tofu apply -auto-approveDeveloper 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 # => 403Read is never blocked:
tofu state pull # => always succeedsFeature flag off — no enforcement:
Feature.disable(:protected_terraform_states)tofu apply -auto-approve # => succeedsStale 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_derisktype (disabled by default) - Zero overhead when feature flag is disabled