08/27/2020: Using KeyBase To Generate Secure IAM User Passwords With Terraform
Table of Contents
Introduction
See https://github.com/medined/create-iam-users-with-terraform-and-keybase for the copies of the files discussed here.
GOAL: Provide a cryptographically secure way to distribute first-time passwords to a team. This means the system administrator (me!) should not know their passwords nor have any way to find them out.
Recently I needed to create a few IAM users for a team. I choose to use Terraform to provision them. My first lesson was that while learning Terraform, create a separate directory for each project. For example, with my current knowledge I would not provision server resources and IAM resources in the same Terraform project.
File Descriptions
Since there are several files involved, first I will list them with a description.
No secrets are stored in any of the files below. However, some teams might consider the permission list for each IAM role to be security-relevant. Check with your security people before storing the password files in a code repository though.
File | Description |
---|---|
accounts.txt | This file lists the iam user, keybase account, and group for each team member. |
iam-group-membership.tf | This file does not go into your code repository. It defines the relationship between IAM Groups and the IAM Users in those groups. |
iam-groups.tf | This file defines IAM groups, provides ARNs for existing IAM polices, and attaches groups and policies. This content might be considered sensitive. |
iam-user-*.tf | The iam-user-*.tf files are generated by the user-add.sh script. They should not be added to your code repository. Every user has their own file. To remove a user, just delete their file and re-apply. This file has the IAM user account, their access key, their login profile (i.e. the PGP key), and group membership. There is no secret information in this file except the user name itself. It will generate a local file holding the encrypted base64 password. The KeyBase website can be used to decrypt the password. |
encrypted_password.*.txt | This is the local file that the user-XXXX.tf template produces. |
.gitignore | Lists file that should not be in the code repository. |
main.tf | The heart of the process. It just sets up the AWS provider. |
terraform.tfvars | This file sets some project configuration values like project name and aws region name. Do not add this file to your code repository. |
tfa.sh | This is my script for running terraform apply . It mainly sets up a log file so that all output is captured to help debug issues. It also runs user-add.sh to ensure the user accounts are up-to-date. |
tfd.sh | This is my script for running terraform destroy . Like tfa.sh it mainly sets up a log file. |
user-add.sh | This script creates iam-user-*.tf and iam-group-membership.tf files that provision users and group membership. |
variables.tf | This file describes the variables used in the Terraform files. Only project_name is used. |
Files
accounts.txt
This file should not be in your code repository. Below is an example. Change to fit your needs. Lines that start with a pound sign are ignored. Note that is prefectly find to use one KeyBase account for multiple IAM users. Whoever owns the KeyBase account can decrypt the passwords. This is a great feature for a tester than needs differently permissioned IAM users.
#aws_account,keybase_account,group
admin,medined,administrators
programmer01,medined,developers
programmer02,medined,developers
tester01,medined,console_users
tester02,medined,console_users
encrypted_password.*.txt
The terraform apply
process will create any AWS resources needed and produce a local file containing the encrypted password. Below is a elided example of the files that will be created. If the user pastes the file content into the decryption form on the KeyBase site, they can decrypt the information to get their temporary AWS console password.
-----BEGIN PGP MESSAGE-----
Comment: https://keybase.io/download
Version: Keybase Go 1.0.10 (linux)
wcF...ncA
-----END PGP MESSAGE-----
iam-group-membership.tf
The user-add.sh
script produces this file using information from accounts.txt
. This file should not be part of your code repository.
resource "aws_iam_group_membership" "administrators" {
name = "group-membership-administrators"
users = [
aws_iam_user.admin.name
]
group = aws_iam_group.administrators.name
}
resource "aws_iam_group_membership" "console_users" {
name = "group-membership-console-users"
users = [
aws_iam_user.tester01.name,aws_iam_user.tester02.name
]
group = aws_iam_group.console_users.name
}
resource "aws_iam_group_membership" "developers" {
name = "group-membership-developers"
users = [
aws_iam_user.programmer01.name,aws_iam_user.programmer02.name
]
group = aws_iam_group.developers.name
}
iam-groups.tf
This file has three sections. They are groups, policies, and group policy attachments. Each group and policy combination has its own attachment resource. This provides granular management. Delete an attachment to remove the policy from a group.
Please use some prefix in your group names so that several projects can be managed at the same time. Otherwise, “administrators” in project “abcde” will conflict with project “zyxw”. Only group names need to be namespaced in this way.
resource "aws_iam_group" "administrators" {
name = "abcde_administrators"
path = "/"
}
resource "aws_iam_group" "developers" {
name = "abcde_developers"
path = "/"
}
data "aws_iam_policy" "AdministratorAccess" {
arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
data "aws_iam_policy" "AmazonS3FullAccess" {
arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}
data "aws_iam_policy" "AmazonEC2FullAccess" {
arn = "arn:aws:iam::aws:policy/AmazonEC2FullAccess"
}
## ADMINISTRATORS
resource "aws_iam_group_policy_attachment" "administrator" {
group = aws_iam_group.administrators.name
policy_arn = data.aws_iam_policy.AdministratorAccess.arn
}
## DEVELOPERS
resource "aws_iam_group_policy_attachment" "developers_AmazonS3FullAccess" {
group = aws_iam_group.developers.name
policy_arn = data.aws_iam_policy.AmazonS3FullAccess.arn
}
resource "aws_iam_group_policy_attachment" "developers_AmazonEC2FullAccess" {
group = aws_iam_group.developers.name
policy_arn = data.aws_iam_policy.AmazonEC2FullAccess.arn
}
iam-user-*.tf
The user-add.sh
script produces these files using information from accounts.txt
. These files should not be part of your code repository. Here is an example:
## _ ____ __ __ ___ _ _
## / \ | _ \| \/ |_ _| \ | |
## / _ \ | | | | |\/| || || \| |
## / ___ \| |_| | | | || || |\ |
## /_/ \_\____/|_| |_|___|_| \_|
##
resource "aws_iam_user" "admin" {
name = "admin"
}
resource "aws_iam_access_key" "admin" {
user = aws_iam_user.admin.id
}
resource "aws_iam_user_login_profile" "admin" {
user = aws_iam_user.admin.id
pgp_key = "keybase:medined"
}
resource "local_file" "admin_password" {
sensitive_content = "-----BEGIN PGP MESSAGE-----\nComment: https://keybase.io/download\nVersion: Keybase Go 1.0.10 (linux)\n\n${aws_iam_user_login_profile.admin.encrypted_password}\n-----END PGP MESSAGE-----\n"
filename = "encrypted_password.admin.txt"
file_permission = "0600"
}
main.tf
Define region
and aws_profile_name
in terraform.tfvars
. Otherwise, this is pretty basic.
provider "aws" {
region = var.region
profile = var.aws_profile_name
version = "~> 2.70"
}
terraform.tfvars
Don’t add this file to your code repository. It’s simple for this project but sometimes it will contain sensitive information.
aws_profile_name = "bluejay"
region = "us-east-1"
tfa.sh
## Make sure the user files are up to date.
./user-add.sh
export TF_LOG=TRACE
export TF_LOG_PATH="/tmp/terraform-apply-$(date "+%Y-%m-%d_%H:%M").log"
terraform apply --auto-approve
ls -ltr /tmp/terraform-apply*.log | tail -n 1
tfd.sh
export TF_LOG=TRACE
export TF_LOG_PATH="/tmp/terraform-destroy-$(date "+%Y-%m-%d_%H:%M").log"
terraform destroy --auto-approve
ls -ltr /tmp/terraform-destroy*.log | tail -n 1
user-add.sh
Please install figlet
if you don’t have that tool. Or remove the usage of it from the file below.
This script is used like this:
./user-add <iam_user> <keybase_account> <iam_group>
The group must be in the VALID_IAM_GROUPS. Please adjust as needed.
The script is straightforward. It uses a template to produce the user-XXXX.tf file.
The code is:
## A script this complex should be written in Python so that the list of
## valid IAM groups could be automatically generated from the
## iam-groups.tf file.
## Remove the set of user files. This will ensure that users removed from
## the accounts.txt file are also deleted from AWS.
rm -f iam-group-membership.tf iam-user-*.if
VALID_IAM_GROUPS="administrators,console_users,developers"
for LINE in $(cat accounts.txt)
do
echo "$LINE" | grep --silent "^#"
[[ $? == 0 ]] && continue
IAM_USER=$(echo $LINE | cut -d',' -f1)
KEYBASE_ACCOUNT=$(echo $LINE | cut -d',' -f2)
IAM_GROUP=$(echo $LINE | cut -d',' -f3)
echo $VALID_IAM_GROUPS | grep --silent $IAM_GROUP
if [ $? != 0 ]
then
echo "Invalid IAM Group. Please use one of these: $VALID_IAM_GROUPS"
exit 1
fi
BANNER=$(figlet -f standard $(echo $IAM_USER | tr '[:lower:]' '[:upper:]') | sed -e 's/^/# /')
USER_TF_FILE="iam-user-$IAM_USER.tf"
echo "Processed: $IAM_USER"
cat <<EOF > $USER_TF_FILE
$BANNER
resource "aws_iam_user" "$IAM_USER" {
name = "$IAM_USER"
}
resource "aws_iam_access_key" "$IAM_USER" {
user = aws_iam_user.$IAM_USER.id
}
resource "aws_iam_user_login_profile" "$IAM_USER" {
user = aws_iam_user.$IAM_USER.id
pgp_key = "keybase:$KEYBASE_ACCOUNT"
}
resource "local_file" "${IAM_USER}_password" {
sensitive_content = "-----BEGIN PGP MESSAGE-----\nComment: https://keybase.io/download\nVersion: Keybase Go 1.0.10 (linux)\n\n\${aws_iam_user_login_profile.${IAM_USER}.encrypted_password}\n-----END PGP MESSAGE-----\n"
filename = "encrypted_password.$IAM_USER.txt"
file_permission = "0600"
}
EOF
done
ADMINISTRATORS=""
CONSOLE_USERS=""
DEVELOPERS=""
for LINE in $(cat accounts.txt)
do
echo "$LINE" | grep --silent "^#"
[[ $? == 0 ]] && continue
IAM_USER=$(echo $LINE | cut -d',' -f1)
KEYBASE_ACCOUNT=$(echo $LINE | cut -d',' -f2)
IAM_GROUP=$(echo $LINE | cut -d',' -f3)
if [ $IAM_GROUP == "administrators" ]; then
if [ "$ADMINISTRATORS" == "" ]; then
ADMINISTRATORS="aws_iam_user.$IAM_USER.name"
else
ADMINISTRATORS="$ADMINISTRATORS,aws_iam_user.$IAM_USER.name"
fi
fi
if [ $IAM_GROUP == "console_users" ]; then
if [ "$CONSOLE_USERS" == "" ]; then
CONSOLE_USERS="aws_iam_user.$IAM_USER.name"
else
CONSOLE_USERS="$CONSOLE_USERS,aws_iam_user.$IAM_USER.name"
fi
fi
if [ $IAM_GROUP == "developers" ]; then
if [ "$DEVELOPERS" == "" ]; then
DEVELOPERS="aws_iam_user.$IAM_USER.name"
else
DEVELOPERS="$DEVELOPERS,aws_iam_user.$IAM_USER.name"
fi
fi
done
cat <<EOF > iam-group-membership.tf
resource "aws_iam_group_membership" "administrators" {
name = "group-membership-administrators"
users = [
$ADMINISTRATORS
]
group = aws_iam_group.administrators.name
}
resource "aws_iam_group_membership" "console_users" {
name = "group-membership-console-users"
users = [
$CONSOLE_USERS
]
group = aws_iam_group.console_users.name
}
resource "aws_iam_group_membership" "developers" {
name = "group-membership-developers"
users = [
$DEVELOPERS
]
group = aws_iam_group.developers.name
}
EOF
variables.tf
variable "aws_profile_name" {
description = "Profile name from ~/.aws/credentials"
}
variable "region" {
type = string
description = "Region of the VPC"
}
When you have the project files assembled, here are the steps.
-
Initialize Terraform.
-
Apply Terraform.
-
Example log file.
-
Fix mistakes.
-
Email the encrypted files to users with a procedure they should follow. For example,
* Log into the KeyBase website.
* In the decryption section, paste the contents of the attached file into the form.
* Visit https://AWS_ACCOUNT.signin.aws.amazon.com/console. Use the following information to log into the AWS console.
Account: AWS_ACCOUNT
Username: IAM_USER
Password: <decrypted from above>
You have XXXXXX access. Let me know if you need additional permissions.
Let me know when you have logged in.
08/23/2020: How Can I Get More Detail About Connection Refused Message In Ingress Nginx Controller Pod?
I am seeing the following message in my Ingress Nginx Controller logs. The message has been elided so it is easier to read.
E0823 .../reflector.go:125: Failed to watch *v1.Endpoints: Get "<API URL>":
dial tcp 10.233.0.1:443: connect: connection refused
Here is the full API URL. Notice that the IP address is not internet addressable. The second line removes the host name from the string. We’ll add the controller information later.
API_URL="https://10.233.0.1:443/api/v1/endpoints?allowWatchBookmarks=true&labelSelector=OWNER%21%3DTILLER&resourceVersion=5840136&timeout=6m24s&timeoutSeconds=384&watch=true"
API_URL=$(echo $API_URL | cut -d'/' -f4-)
The E8023
error does not provide much information. I know that 10.233.0.1
corresponds to my controller node. And I know that the Ingress Nginx Controller is running on my worker node.
NOTE This cluster has just one controller and one worker node.
- Set the namespace and service account of the pod.
NAMESPACE=ingress-nginx
SERVICE_ACCOUNT=ingress-nginx
- Get the service account’s token secret’s name. This will look like
ingress-nginx-token-sc7dw
.
SECRET=$(kubectl -n $NAMESPACE get serviceaccount ${SERVICE_ACCOUNT} -o json | jq -Mr '.secrets[].name | select(contains("token"))')
- Get the bearer token from the secret and decode it. This is a 941 character string. It looks like
eyJhbGc...48AzXRQ
.
TOKEN=$(kubectl -n $NAMESPACE get secret ${SECRET} -o json | jq -Mr '.data.token' | base64 -d)
- Make a local copy of the certificate authority file by the secret.
kubectl -n $NAMESPACE get secret ${SECRET} -o json | jq -Mr '.data["ca.crt"]' | base64 -d > /tmp/ca.crt
- Get the hostname of the API server. There are several ways to accomplish this. I choose to use the
cluster-info
command. The only tricky part to this techique is there are non-visible characters around the host name that need to be removed. That’s what thecut
andrev
commands are doing.
APISERVER=$(kubectl cluster-info | head -n 1 | awk '{print $6}' | cut -b16- | rev | cut -b10- | rev)
echo "[$APISERVER]"
- Run the API call that caused the original error.
curl https://$APISERVER:6443/$API_URL --header "Authorization: Bearer $TOKEN" --cacert /tmp/ca.crt
{"type":"ERROR","object":{..."message":"too old resource version: 5840136 (5856237)","reason":"Expired","code":410}}
This was not an expected outcome. Looking closer at the URL being fetched shows:
api/v1/endpoints
I think that should be
openapi/v1/endpoints
Here is the result of that change.
curl https://$APISERVER:6443/openapi/v1/endpoints --header "Authorization: Bearer $TOKEN" --cacert /tmp/ca.crt
{
"paths": [
"/apis",
"/apis/",
"/apis/apiextensions.k8s.io",
"/apis/apiextensions.k8s.io/v1",
"/apis/apiextensions.k8s.io/v1beta1",
"/healthz",
"/healthz/etcd",
"/healthz/log",
"/healthz/ping",
"/healthz/poststarthook/crd-informer-synced",
"/healthz/poststarthook/generic-apiserver-start-informers",
"/healthz/poststarthook/start-apiextensions-controllers",
"/healthz/poststarthook/start-apiextensions-informers",
"/livez",
"/livez/etcd",
"/livez/log",
"/livez/ping",
"/livez/poststarthook/crd-informer-synced",
"/livez/poststarthook/generic-apiserver-start-informers",
"/livez/poststarthook/start-apiextensions-controllers",
"/livez/poststarthook/start-apiextensions-informers",
"/metrics",
"/openapi/v2",
"/readyz",
"/readyz/etcd",
"/readyz/log",
"/readyz/ping",
"/readyz/poststarthook/crd-informer-synced",
"/readyz/poststarthook/generic-apiserver-start-informers",
"/readyz/poststarthook/start-apiextensions-controllers",
"/readyz/poststarthook/start-apiextensions-informers",
"/readyz/shutdown",
"/version"
]
}