Terraform Backend: Role-Based Access Control – Part 2

Chris / April 6, 2021 in 

A prior post covered how some teams at BTI360 use CloudFormation to manage Terraform’s AWS backend infrastructure, including the state bucket and lock table. Our previous post introduced three permission levels for accessing Terraform state:

  1. Backend: A dedicated role Terraform will use when accessing and modifying state during operations performed by IAM users or CI/CD.
  2. Developer: Permissions needed for manual modifications/intervention by developers. Restricted from permanently deleting state.
  3. Administrative: Has full access to state buckets and objects. Access should be highly restricted.

That same post implemented the backend role. Today, Chris, BTI360 engineer and author of thirstydeveloper.io, will add RBAC to protect the Terraform state and enforce the Developer and Administrative permission levels.

State RBAC

When we discussed using CloudFormation to control the state bucket, we pointed out several hardening opportunities above and beyond what Terragrunt does for you. One of those was using an S3 bucket policy to restrict access to Terraform state. We will use just such a bucket policy to implement our RBAC restrictions.

An S3 bucket policy is an ideal choice for two reasons. First, it directly applies permissions to the resource we want to protect (the state bucket). Second, developers can likely create it themselves, unlike adding permissions to IAM users, something typically controlled by an enterprise team.

We’ll start with a bucket policy that restricts access to just our backend role. Once that’s in place, we’ll add developer and administrative access.

An important note before we begin: if you manage to lock yourself out of an S3 bucket with a bucket policy, you will need access to the AWS account’s root user to recover. AWS always allows the root user access to remove or modify bucket policies of buckets owned by that root user’s account, even if the bucket’s policy does not explicitly grant it.

State Bucket Policy

We’ll add our state bucket policy as a resource to the terraform-bootstrap.cf.yml CloudFormation template we previously created. If you care to jump to the end, here’s a link to the full CloudFormation template. Let’s break the policy down statement by statement.

We’ll start with a statement that requires TLS encryption for any requests accessing Terraform state. This policy Terragrunt creates for you; we preserve it here.

TerraformStateBucketPolicy:
  Type: 'AWS::S3::BucketPolicy'
  DeletionPolicy: Retain
  UpdateReplacePolicy: Retain
  Properties:
    Bucket: !Ref TerraformStateBucket
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Sid: 'AllowTLSRequestsOnly'
          Principal: '*'
          Condition:
            Bool:
              'aws:SecureTransport': false
          Effect: Deny
          Action: '*'
          Resource:
            - !GetAtt "TerraformStateBucket.Arn"
            - !Sub
              - "${Bucket}/*"
              - Bucket: !GetAtt "TerraformStateBucket.Arn"

A second statement denies access to all IAM roles other than our backend role:

- Sid: DenyNonBackendRoles
  Principal: "*"
  Condition:
    StringEquals:
      aws:PrincipalType: AssumedRole
    StringNotLike:
      aws:userId:
        - !Sub
          - "${TerraformBackendRoleId}:*"
          - TerraformBackendRoleId: !GetAtt "TerraformBackendRole.RoleId"
  Effect: Deny
  Action: '*'
  Resource:
    - !GetAtt "TerraformStateBucket.Arn"
    - !Sub
      - "${Bucket}/*"
      - Bucket: !GetAtt "TerraformStateBucket.Arn"

The syntax for restricting access to a specific IAM role can be unintuitive. Specifying the role ARN in the Principal property does not work; this AWS post explains why and demonstrates the combination of StringNotLike and aws:userId we use here.

A third statement grants the backend role access:

- Sid: ResrictBackendRoleToReadWrite
  Principal: "*"
  Condition:
    StringEquals:
      aws:PrincipalType: AssumedRole
    StringLike:
      aws:userId:
        - !Sub
          - "${TerraformBackendRoleId}:*"
          - TerraformBackendRoleId: !GetAtt "TerraformBackendRole.RoleId"
  Effect: Deny
  NotAction:
    - 's3:ListBucket'
    - 's3:GetBucketVersioning'
    - 's3:GetObject'
    - 's3:PutObject'
  Resource:
    - !GetAtt "TerraformStateBucket.Arn"
    - !Sub
      - "${Bucket}/*"
      - Bucket: !GetAtt "TerraformStateBucket.Arn"

And a fourth statement denies access to any principal types other than AWS accounts, IAM users, and IAM roles, as those are the only principal types we are considering today.

- Sid: DenyAllOtherPrincipals
  Principal: "*"
  Condition:
    StringNotEquals:
      aws:PrincipalType:
        - AssumedRole
        - Account
        - User
  Effect: Deny
  Action: '*'
  Resource:
    - !GetAtt "TerraformStateBucket.Arn"
    - !Sub
      - "${Bucket}/*"
      - Bucket: !GetAtt "TerraformStateBucket.Arn"

Our policy so far prevents all IAM roles other than the backend role from accessing Terraform state. We’ll now turn our attention to IAM users, specifically our Developers and Administrators.

Developer and Administrator Users

We previously granted IAM users access to our backend role using an IAM principal tag condition, reprinted below:

TerraformBackendRole:
  Type: 'AWS::IAM::Role'
  Properties:
    AssumeRolePolicyDocument:
      Version: 2012-10-17
      Statement:
        - Effect: Allow
          Principal:
            AWS: !Ref AWS::AccountId
          Action:
            - 'sts:AssumeRole'
          Condition:
            StringEquals:
              aws:PrincipalType: User
            StringLike:
              'aws:PrincipalTag/Terraformer': '*'
    RoleName: TerraformBackend
    Path: /terraform/
    ManagedPolicyArns:
      - !Ref TerraformStateReadWritePolicy

Anyone running Terraform or Terragrunt commands, such as our Developers and Administrators, will need the Terraformer principal tag on their IAM user to assume the backend role to modify state. The backend role grants sufficient access for Developers and Administrators to run plans and applies, but other advanced operations require delete access to the state. The most common is state migration (e.g., renaming a state file). We will want to grant Developers and Administrators direct access to the state bucket to support such operations.

Since both types of IAM users will have the Terraformer principal tag, we can use that tag’s value to distinguish Developer and Administrative access. Let’s see how.

User Bucket Policy Statements

Our next bucket policy statement uses the aws:PrincipalType and aws:PrincipalTag condition keys to deny access to any IAM users lacking the Terraformer principal tag:

- Sid: DenyNonTerraformerUsers
  Principal: "*"
  Condition:
    StringEquals:
      aws:PrincipalType: User
    StringNotLike:
      'aws:PrincipalTag/Terraformer': '*'
  Effect: Deny
  Action: '*'
  Resource:
    - !GetAtt "TerraformStateBucket.Arn"
    - !Sub
      - "${Bucket}/*"
      - Bucket: !GetAtt "TerraformStateBucket.Arn"

Next, we begin differentiating between developer and administrative access to the state by inspecting the value of the Terraformer tag:

- Sid: RestrictTerraformNonAdmins
  Principal: "*"
  Condition:
    StringEquals:
      aws:PrincipalType: User
    StringLike:
      'aws:PrincipalTag/Terraformer': '*'
    StringNotEquals:
      'aws:PrincipalTag/Terraformer': 'Admin'
  Effect: Deny
  NotAction:
    - 's3:List*'
    - 's3:Get*'
    - 's3:Describe*'
    - 's3:PutObject'
    - 's3:DeleteObject'
  Resource:
    - !GetAtt "TerraformStateBucket.Arn"
    - !Sub
      - "${Bucket}/*"
      - Bucket: !GetAtt "TerraformStateBucket.Arn"

If the IAM user has the Terraformer tag, but its value is not Admin, we grant developer-level access to that user. We use IAM’s NotAction to whitelist the permitted actions. The bucket policy does not contain any permissions for users who have the Terraformer tag set to Admin. The lack of permissions means such users will have whatever access the IAM policy attached to their IAM user grants, presumably full access to S3.

Notably, developer-level access permits s3:DeleteObject but not s3:DeleteObjectVersion. Since our state bucket is versioned (see our previous post), granting s3:DeleteObject is not inherently dangerous because all it does is add a delete marker to the object; you can always restore the version before the delete marker. Granting developers the ability to add delete markers aids state migration, so we do so here.1

Finally, none of the permissions in the policy grant either adding or modifying the bucket policy, which means that aside from the root user, only Admin Terraformers can do so, assuming those admins have the requisite permissions on their IAM user.

And there we have it: a bucket policy restricting Terraform state access to only our backend role and IAM users with the Terraformer tag, distinguishing between administrative and developer-level access for the latter. All that’s left is to deploy the bucket policy.

Deploying the Bucket Policy

First, so you don’t lock yourself out, change the Terraformer tag on your IAM user to have a value of Admin and ensure you have full access to S3.

Second, if Terragrunt created the state bucket for you, it may already have a bucket policy attached to it. Delete it using the following CLI command, replacing the bucket name as appropriate:

aws s3api delete-bucket-policy --bucket bti360-terraform-state

Third, deploy your template as a CloudFormation stack either with the CloudFormation management console or using the AWS CLI. Here’s a sample command for the latter:

aws cloudformation deploy \
  --template-file terraform-bootstrap.cf.yml
  --stack-name terraform-bootstrap \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
    StateBucketName=bti360-terraform-state \
    StateLogBucketName=bti360-terraform-state-logs \
    LockTableName=bti360-terraform-state-locks

You’re all set.

Conclusion

In the last two entries, we implemented role-based access control for Terraform state with three basic permission-levels. We hope your team finds this a useful starting point for protecting your Terraform state. If you care to see a fully worked example incorporating the RBAC concepts introduced today, check out Chris’ terraform-skeleton series on thirstydeveloper.io.

Footnotes

  1. Since users cannot delete object versions, they cannot restore a state file by deleting its delete marker since it is itself an object version.As discussed in the AWS docs here and here, users can still restore state files by copying a previous version to become the latest. See thirstydeveloper.io for an example of how.

 


Interested in Solving Challenging Problems? Work Here!

Are you a software engineer, interested in joining a software company that invests in its teammates and promotes a strong engineering culture? Then you’re in the right place! Check out our current Career Opportunities. We’re always looking for like-minded engineers to join the BTI360 family.


Related Posts:

Previous

Terraform Backend: Role-Based Access Control – Part 1

Next

BTI360 Tech Leadership Academy

Close Form

Enjoy our Blog?

Then stay up-to-date with our latest posts delivered right to your inbox.

  • This field is for validation purposes and should be left unchanged.

Or catch us on social media

Stay in Touch

Whether we’re honing our craft, hanging out with our team, or volunteering in the community, we invite you to keep tabs on us.

  • This field is for validation purposes and should be left unchanged.