November 22, 2023 · 15 min read
This article explains how to set up Terraform to manage user access to a database in AWS. First we'll look at how to manage access directly in AWS, then how to use Terraform to do the same thing.
The practice of access governance has become increasingly important over the last decade. Privacy laws are becoming more stringent. Businesses are running more of their infrastructure on the cloud, distributed worldwide. The moral, reputational, legal, and financial costs of exposing customer data are massive. Using Terraform to configure, encapsulate, limit, and audit access to your resources can help with this challenge.
Terraform has several benefits over manual infrastructure changes. It allows you to:
Whether you want to do it manually, use Terraform, or use Abbey, our access governance platform, this guide will help you. Read through the whole thing, or jump to the relevant section below.
To follow this tutorial, you'll need:
Below are AWS access management concepts that are used throughout this tutorial.
Although AWS provides CloudFormation for configuration, we recommend Terraform in this article because:
Note that versions of Terraform after 1.5 are no longer open source. The company changed its license in August 2023. You may soon want to switch to OpenTofu, an open-source fork of Terraform that is currently working towards a stable release. Currently, OpenTofu is an exact substitute for Terraform, though they will diverge in syntax and features over time.
In this section, we'll create a DynamoDB table called Person with one row, a user called Bob who wants to access it, and a role that the user can assume to access the table.
If you're already comfortable with managing users, roles, and databases in AWS, skip ahead to the section on Terraform.
Requiring the user to assume a role to access the table offers a few advantages over giving a user direct access to a resource:
While the Terraform section of this tutorial is detailed, this initial AWS section excludes obvious actions you need to do, like clicking Done or Next.
Create a database table:
Create a user who has no permissions by default and will request access to read the value from the table:
bob
.P4ssword_
.
Give Bob an access key to use the CLI:
Finally, create a role with permissions to read from the Person table:
reader
.Now our example setup is complete and ready to test.
Bob wants the latest email addresses for all customers and so wants to access the Person table. He emails an AWS administrator at his company and asks for access.
Emailing is the first thing you should change when implementing access governance at your company. Emails are hard to audit and easy to delete. As the administrator, you should ask Bob to log a GitHub issue with his request, or in a ticketing system like Jira or Redmine. When you have given him access, you can mark the issue as closed. (This provides a log of access, something that is required by standards like SOC 2, PCI DSS, and ISO/IEC 27001.)
The administrator (you) logs in to the AWS web console and gives Bob permission to assume the reader
role:
<ACCOUNT-ID>
with your account number (found under your name at the very top right of the window).
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "sts:AssumeRole", "Resource": "arn:aws:iam::<ACCOUNT-ID>:role/reader" } ] }
bobreader
.The administrator then tells Bob he now has permissions to read the database.
Bob logs in to the AWS website, entering the company's account identifier, his name bob
, and the password P4ssword_
. He does the following:
Bob is returned to the tables screen, and he can click the Person table, click Explore table items, and finally see Alice's email address.
When he's done, Bob can click Switch back under his username at the top right.
If Bob doesn't need permanent access to the table, the administrator will want to remove Bob's permissions once he has read the data he needs. There are various ways of doing this:
bobreader
, possibly after emailing Bob to check that he's done. This approach leaves a lot of room for human error.reader
role and deletes bobreader
. This is too much work, since user access is a common request.bobreader
:
"Condition": { "DateLessThan": {"aws:CurrentTime": "2023-11-06T23:59:59Z"}}
If you don't revoke user access when no longer needed, users' permissions will expand indefinitely, leading to potential privacy and confidentiality problems.
In this section, we will repeat what we did in the previous section on AWS, but we'll use Terraform instead. We will create a user called Carol and a role she can assume to read the DynamoDB table.
If you completed the earlier section and have the user Bob, please go to his user in the AWS console and add the new permission AdministratorAccess
. Since you noted his access key, you can use it in the AWS CLI, now that Bob is an administrator. Also, browse to your DynamoDB Person table and delete it, as we will recreate it with Terraform.
If you didn't complete the earlier section, please create a new AWS user with the AdministratorAccess
permission and an access key for them.
We need a temporary place to work on files for this tutorial. Make any folder on your computer, like temp
, and open a terminal inside it.
You're welcome to install AWS and Terraform manually on your machine, following the instructions on their websites, but a faster way is to use Docker. Create a file called Dockerfile
with the following content:
FROM alpine:3.18.4WORKDIR /workspaceRUN apk add aws-cli terraform
Build the container and start it:
docker build -t cloudbox_image .docker run -it --volume .:/workspace --name cloudbox cloudbox_image
You are now inside the Docker container and able to use AWS and Terraform:
aws --versionterraform -v
If you exit the container and wish to start it again later, run:
docker start -ai cloudbox
The --volume .:/workspace
parameter shares your current folder with the container, so both your physical machine and Docker can read and write the same files.
In the Docker terminal, set your administrator access key:
aws configure# enter your user access key# enter your user secret key
To test that your credentials are correct, you can run:
aws s3 ls
Next, we will start on a Terraform configuration file to provision our AWS infrastructure.
Create a file called main.tf
in your shared workspace folder. Add the content below, written in HashiCorp Configuration Language (HCL).
terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 4.16" } } required_version = ">= 1.2.0"}provider "aws" { region = "eu-west-1" }resource "aws_dynamodb_table" "person" { name = "Person" billing_mode = "PROVISIONED" read_capacity = 1 write_capacity = 1 hash_key = "Id" range_key = "Email" attribute { name = "Id" type = "S" } attribute { name = "Email" type = "S" }}
This infrastructure specification does only one thing: It creates a DynamoDB table called Person in the AWS Ireland region. Note that the AWS table name Person
is separate from the Terraform resource name person
. The latter can be whatever you want, and is used to refer to this resource anywhere in the Terraform configuration file.
Although AWS CLI is installed, Terraform still has to download its provider, since we used AWS in the configuration file. Run this command in the Docker terminal:
terraform init
The output will be:
/workspace # terraform initInitializing the backend...Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 4.16"...- Installing hashicorp/aws v4.67.0...- Installed hashicorp/aws v4.67.0 (signed by HashiCorp)Terraform has created a lock file .terraform.lock.hcl to record the providerselections it made above. Include this file in your version control repositoryso that Terraform can guarantee to make the same selections by default whenyou run "terraform init" in the future.Terraform has been successfully initialized!You may now begin working with Terraform. Try running "terraform plan" to seeany changes that are required for your infrastructure. All Terraform commandsshould now work.If you ever set or change modules or backend configuration for Terraform,rerun this command to reinitialize your working directory. If you forget, othercommands will detect it and remind you to do so if necessary.
Terraform downloaded large files to .terraform
. Remember to exclude them from version control if you use .gitignore
.
To check that your configuration file syntax is correct, run:
terraform validate# Success! The configuration is valid.
Now let's create the database. Run:
terraform apply# type yes and push enter
The output should be:
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + createTerraform will perform the following actions: # aws_dynamodb_table.person will be created + resource "aws_dynamodb_table" "person { + arn = (known after apply) + billing_mode = "PROVISIONED" + hash_key = "Id" + id = (known after apply) + name = "Person" + range_key = "Email" + read_capacity = 1 + stream_arn = (known after apply) + stream_label = (known after apply) + stream_view_type = (known after apply) + tags_all = (known after apply) + write_capacity = 1 + attribute { + name = "Email" + type = "S" } + attribute { + name = "Id" + type = "S" } }Plan: 1 to add, 0 to change, 0 to destroy.Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yesaws_dynamodb_table.person: Creating...aws_dynamodb_table.person: Creation complete after 10s [id=Person]Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
You have successfully created a new database table.
Note that Terraform created the file terraform.tfstate
to represent and track your AWS configuration. If any resource, like another database table, exists in AWS but was not created by Terraform, Terraform will not manage it. Terraform does not modify resources that are not in the state file. You can use the import command to include existing AWS infrastructure in your Terraform configuration or use an app like Terraformer.
To check that the table works, let's add a row:
aws dynamodb put-item \ --table-name Person \ --item '{ "Id": {"S": "1"}, "Email": {"S": "alice@example.com"} }'
If you browse to the database in the AWS console and click Explore table items, you can now see the new row.
Add the following code to main.tf
to add a user called Carol and give her a CLI access key.
resource "aws_iam_user" "carol" { name = "carol"}resource "aws_iam_access_key" "carol_key" { user = aws_iam_user.carol.name}
Run terraform apply
.
The output is:
/workspace # terraform applyaws_dynamodb_table.person2: Refreshing state... [id=Person2]Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + createTerraform will perform the following actions: # aws_iam_access_key.carol_key will be created + resource "aws_iam_access_key" "carol_key" { + create_date = (known after apply) + encrypted_secret = (known after apply) + encrypted_ses_smtp_password_v4 = (known after apply) + id = (known after apply) + key_fingerprint = (known after apply) + secret = (sensitive value) + ses_smtp_password_v4 = (sensitive value) + status = "Active" + user = "carol" } # aws_iam_user.carol will be created + resource "aws_iam_user" "carol" { + arn = (known after apply) + force_destroy = false + id = (known after apply) + name = "carol" + path = "/" + tags_all = (known after apply) + unique_id = (known after apply) }Plan: 2 to add, 0 to change, 0 to destroy.Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yesaws_iam_user.carol: Creating...aws_iam_user.carol: Creation complete after 2s [id=carol]aws_iam_access_key.carol_key: Creating...aws_iam_access_key.carol_key: Creation complete after 1s [id=AKIAQSCRAQJDWEEF5AEL]Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Carol's access key and secret are now in the terraform.tfstate
file. Now we can use her keys to log in to AWS using the CLI and see if we can access the Person table. Instead of rerunning aws configure
and changing your default credentials, let's pass Carol's keys into the CLI for one command. In the Docker terminal, run the command below, replacing the keys in single quotes:
AWS_ACCESS_KEY_ID='<Carol's access key>' AWS_SECRET_ACCESS_KEY='<Carol's secret access key>' aws s3 ls
As expected, Carol does not yet have access.
An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied
Add the following code to your main.tf
file to create a role with access to the DynamoDB table that Carol can assume. Update the Principal
with your AWS account number.
resource "aws_iam_role" "dbreader" { name = "dbreader" assume_role_policy = jsonencode({ Version = "2012-10-17", Statement = [ { Action = "sts:AssumeRole", Effect = "Allow", Principal = { "AWS": "arn:aws:iam::<Your Account Number>:root" }, Condition: { } }, ], })}resource "aws_iam_role_policy_attachment" "dbreader_dynamodb_readonly" { role = aws_iam_role.dbreader.name policy_arn = "arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess"}
The first resource creates the role for your account with an expiry date. The second resource is a permission to access DynamoDB, with a link to the AWS resource name
of the Terraform role called dbreader
created above. This is an example of how Terraform configuration files abstract the details of the underlying cloud provider: We're using the config file name of the resource, not the AWS name.
Run terraform apply
.
We now have a role with permission to read the database and a user, Carol. But Carol does not have permissions to assume roles. If she wants to access the Person table, she must ask an AWS administrator at your company for access.
As the administrator, you need to add the code below to the configuration file to give Carol permissions to the role and run terraform apply
again. Update the DateLessThan
value to tomorrow.
data "aws_iam_policy_document" "carol_assume_role_policy" { statement { actions = ["sts:AssumeRole"] resources = [aws_iam_role.dbreader.arn] condition { test = "DateLessThan" variable = "aws:CurrentTime" values = ["2023-11-08T23:59:59Z"] } }}resource "aws_iam_policy" "carol_assume_dbreader_policy" { name = "CarolAssumeDbReaderPolicy" policy = data.aws_iam_policy_document.carol_assume_role_policy.json}resource "aws_iam_user_policy_attachment" "carol_assume_role" { user = aws_iam_user.carol.name policy_arn = aws_iam_policy.carol_assume_dbreader_policy.arn}
Carol can now assume the dbreader
role in the CLI. To see this, run the following command in the terminal, replacing your keys and account number:
AWS_ACCESS_KEY_ID='<Carol's access key>' AWS_SECRET_ACCESS_KEY='<Carol's secret access key>' aws sts assume-role --role-arn "arn:aws:iam::<ACCOUNT_ID>:role/dbreader" --role-session-name "CarolSession"
AWS will return temporary credentials that look like the below:
"Credentials": { "AccessKeyId": "IAQSCRAQJDTNAC", "SecretAccessKey": "ozJXhWZNrpPyvttxqV5HE5gzn", "SessionToken": "2luX2VjEIn//////////wEaCIMEYCIQCuOjdHxeoGsoIQiN+kooZVF+UOyBz8=", "Expiration": "2023-11-08T17:30:31+00:00" }, "AssumedRoleUser": { "AssumedRoleId": "AROAQSCRAQJDQTYW57TM2:CarolSession", "Arn": "arn:aws:sts::08460:assumed-role/dbreader/CarolSession" }}
Run the following command for Carol to access the DynamoDB table, but use the access key and secret key returned in the session credentials above:
AWS_ACCESS_KEY_ID='<Session access key>' AWS_SECRET_ACCESS_KEY='<Session secret access key>' AWS_SESSION_TOKEN='<Session token></Session>' aws dynamodb scan --table-name Person --region eu-west-1
Be sure to remove newlines from your session token, or the command will fail. The output should be:
{ "Items": [ { "Id": { "S": "1" }, "Email": { "S": "alice@example.com" } } ], "Count": 1, "ScannedCount": 1, "ConsumedCapacity": null}
Terraform has successfully given Carol temporary access to read the table for Alice's email address. You can now manage users, resources, and permissions with Terraform.
In our example, the advantages of using Terraform over AWS alone are:
apply
than to use the AWS web console. ChatGPT can provide the correct syntax for any configuration you need.The main disadvantage of Terraform is having to manage your terraform.tfstate
file. Your Terraform state file must be kept safe, but not locally (because every administrator needs to use the latest version) and not in Git because it contains secrets. Include *.tfstate*
in your .gitignore
file.
Managing your Terraform state is a complicated topic. This article is a good starting point. You can split your state file into modules to make it easier to understand large configurations. You should also use GitHub secrets and Terraform variables instead of hard coding secrets into your configuration files. Secrets can be stored in AWS Parameter Store (free) or Secrets Manager (paid and more powerful).
Store your state file in a versioned online service specifically designed for secrets, like AWS S3 or Terraform Cloud. Read more about this here. Once you have a secure location for your state file, you can automate access to it with Atlantis. This free locally hosted app will use the latest version of your state file with Terraform apply when committing Git pull requests for infrastructure. You could also use a more powerful online service with paid features like Spacelift.
The other difficulty in our access example is the manual process required for a user to request database access from an administrator, and the potential for human error when the administrator grants expiring permissions.
Now that you know how to use Terraform, you are probably exhausted at the thought of managing hundreds of configuration file updates for user access requests. Luckily, there are a few services that are a level of abstraction above Terraform. Abbey is one example: A web application users can use to request access to cloud resources and administrators can use to approve requests. Permissions are automatically adjusted in your connected Terraform GitHub account and configured on AWS.
Let's use Abbey to assign a user to a group to see how it works. Once again, following this tutorial won't incur charges, as Abbey is free for the first twenty users. You will need to have Git installed and a GitHub account to follow this section.
In the AWS web console for the IAM service, create a group called readergroup
with the permission AmazonDynamoDBReadOnlyAccess
.
Install Abbey:
abbeytest
.workspace\abbeytest
folder.Note that the Terraform list of starter kit repositories contains both abbey-starter-kit-terraform-cloud
and abbey-starter-kit-aws-iam
. Even though the AWS kits don't mention Terraform in their name, all kits use Terraform. Don't use the Terraform cloud kit, as that is a paid Terraform service.
Add your AWS access keys to the GitHub repository.
Browse to your abbeytest
repository and click the Settings tab.
Click Secrets and variables → Actions.
Click New repository secret.
AWS_ACCESS_KEY_ID
.Add two more secrets in a similar way:
AWS_SECRET_ACCESS_KEY
set to Bob's secret key.ABBEY_TOKEN
set to the API key you created after registering on the Abbey website.Browse to https://app.abbey.io/connections.
Click Create a Connection.
Name it abbeytest
and click Create.
Select Only select repositories, select abbeytest
, and click Install & Authorize.
In the cloned repository, you have a new Terraform configuration file called workspace/abbeytest/main.tf
. Open it and take a look. You can see that Abbey and AWS are present as Terraform providers at the top. The resource "abbey_grant_kit" "IAM_membership" {
section makes up most of the configuration. A grant kit consists of:
access.tf
configuration file.At the bottom, the file contains resources. This could be a database or role. In our case, the resource is a user group.
Let's change this grant starter kit to match the particulars of your AWS account:
provider
to Ireland:
provider "aws" { region = "eu-west-1" }
workflow
step of the resource
, add a policy
section. Here we use the inline format for simplicity:
policies = [ { query = <<-EOT package common import data.abbey.functions allow[msg] { functions.expire_after("5m") msg := "Grant access for 5 minutes." } EOT }]
reviewers
to the email address you used to register on accounts.abbey.io
:
reviewers = { one_of = ["yourname@example.com"] }
output
location
to the abbeytest
GitHub repository URL:
location = "github://yourname/abbeytest/access.tf"
user_1
identity email to your Abbey email address. In this simple case, you're using the same email address for the reviewer (administrator) and the requester. In reality, you would add every employee in your organization as a separate user in this file with their own email address.
abbey_account = "yourname@example.com"
name = "carol"
readergroup
:
data "aws_iam_group" "group1" { # <- don't change this group_name = "readergroup" # <- change this}
main.tf
file and commit and push to GitHub.https://github.com/<yourname>/abbeytest/actions
and see that the Abbey Terraform action ran apply
when you committed.Users in Abbey have their own identity, determined by their email address, separate from any identities they may use in AWS. You can add users to your Abbey account in bulk to save time. In order for Abbey to provide access, there needs to be a link between an Abbey identity (the email a user used to sign up with) and identifiers in other systems, such as an AWS User ID. Those links are defined via the abbey_identity
Terraform resource.
Abbey is now configured to manage access in your AWS account. Let's test this by getting Carol to request access to the database.
You can see the GitHub actions Abbey ran to add Carol to the group in your repository's Actions tab, https://github.com/YourName/abbeytest/actions.
Abbey makes access changes only through GitHub on commits. If you try to run Terraform locally, it will fail because you do not have a state file. Even running terraform init
will fail because your Abbey key is not set:
Initializing the backend...Error refreshing state: HTTP remote state endpoint requires auth
Carol is now part of the readergroup
. Check that she can read the database in the CLI:
AWS_ACCESS_KEY_ID='<Carol's access key>' AWS_SECRET_ACCESS_KEY='<Carol's secret access key>' aws dynamodb scan --table-name Person --region eu-west-1
If you update the abbeytest
repository from GitHub, you'll see a new file called access.tf
in the abbeytest
repository. This is where Abbey maintains your access configuration:
resource "aws_iam_user_group_membership" "user_carol_group_readergroup" { user = "carol" groups = ["readergroup"]}
After five minutes have passed, Abbey will automatically revoke Carol's group membership, as per your policy. This will create a new GitHub commit:
The administrator could also manually remove her access at any time. To do this, in the Abbey Approvals screen, click Revoke.
You'll see that Carol can no longer read the database:
AWS_ACCESS_KEY_ID='<Carol's access key>' AWS_SECRET_ACCESS_KEY='<Carol's secret access key>' aws dynamodb scan --table-name Person --region eu-west-1
And abbeytest/access.tf
will be blank again.
If you've been following along with this tutorial, delete user Bob so that his administrator permissions cannot be exploited.
Abbey has two components:
Users and administrators interact with the app to request, approve, and revoke access.
When you approve access with Abbey, the app commits code to the GitHub repository. This triggers a GitHub commit action to run terraform apply
, using the Terraform state that is kept securely in the Abbey web server or wherever you chose to store it.
For more detail, see this video.
Abbey specializes in linking resources to users. Use it for any and all access management, but leave the resource provisioning in plain Terraform code.
If you're new to Terraform, you might have added your main.tf
file directory to your application Git repository. It's better to make a new repository for it, dedicated to infrastructure management.
You should also make another repository, so that you have one repository for infrastructure configuration and one for access configuration. If you prefer to use only one repository, you can split your Terraform files into separate modules.
Your configuration file main.tf
can also be split into separate files for easier management. Terraform will use all the configuration files it has access to when updating your AWS state. As mentioned above, you can also use the main.tf
file to define the links between your Abbey users and those in AWS. For larger projects that require more separation, you could instead do this in an identities.tf
file in the access_manager
directory.
Abbey Starter Kits default to storing the state file on the Abbey servers. Rather store your state file in a secure location, as discussed earlier.
Here's a simplified overview of this structure:
/project_root│├── /app│├── /infrastructure_manager│ ├── /.terraform│ │ └── /providers│ ├── /databases.tf│ └── /applications.tf│└── /access_manager (Abbey) ├── /access.tf └── /main.tf
Your terraform.tfstate
will not be in any of these folders.
If you ever want to stop using Abbey, you can simply unlink your Abbey account from your GitHub repository and return to managing your users manually with Terraform or AWS alone.
Running terraform state pull
will download your state file from Abbey, like any remote server, so it's easy to try it out and see if it works for you without committing upfront.
Note that for simplicity our examples store state on Abbey itself, but we don't recommend this for production settings. Instead our customers usually "bring their own backend", whether that's Terraform Cloud or another Terraform collaboration tool like Atlantis or Spacelift.
Abbey is the easiest way to add automated access request flows to your existing Terraform resources.
Improve security. Reduce toil. Simplify compliance.