├── .gitignore ├── CODEOWNERS ├── README.md ├── github ├── global_vars.tf ├── init.tf ├── repo-example.tf ├── team-developers.tf └── team-sre.tf ├── global_vars.tf ├── google ├── global_vars.tf ├── group-developers.tf ├── group-engineering.tf ├── group-sre.tf ├── init.tf ├── users-company.tf └── users-contractors.tf ├── iam ├── README.md ├── global_vars.tf ├── group-company-developers.tf ├── group-contractor-developers.tf ├── init.tf ├── users-company.tf └── users-contractors.tf ├── modules ├── github-repository │ ├── main.tf │ ├── team_permissions.tf │ ├── user_permissions.tf │ └── vars.tf ├── github-team │ ├── main.tf │ ├── provider.tf │ └── vars.tf ├── google-group │ ├── main.tf │ └── vars.tf ├── google-workspace-users │ ├── main.tf │ ├── password.tf │ └── vars.tf ├── iam-group │ ├── main.tf │ └── vars.tf └── iam-users │ ├── main.tf │ ├── password.tf │ └── vars.tf └── teams ├── README.md ├── contractors.yaml ├── developers.yaml └── sre.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .terraform* 2 | *.tfstate 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Considering this is for user management, we require a code review from 2 | # someone in the OrgAdmins or SRE team for all code changes 3 | * @company-org/OrgAdmins @company-org/SRE 4 | 5 | # For other infrastructure, depending on the particular politics of your 6 | # organization, you may want to allow approved developers/teams the ability to 7 | # maintain their own infrastructure. 8 | databases/* @company-org/DBAs 9 | 10 | # The teams directory has a lot of flexibility with permissions. 11 | # You could require changes to anything in the directory be approved by a 12 | # dedicated IT or HR team 13 | teams/* @company-org/IT @company-org/HR 14 | 15 | # Or the individual team files could require approval from their respective 16 | # team leads 17 | teams/developers.yaml @LastU 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Background 2 | 3 | Reading a lot of questions (mostly on Reddit) about people trying to learn Terraform, it struck me that there are few examples out there of a fully formed environment which can be used to illustrate what a terraform codebase would look like once it has been more or less built out to completion. This repository is an attempt to demonstrate how Terraform can be used to create unified, holistic infrastruction resources. The goal of this demonstration is to illustrate Terraform directory structure, module development, data structures, and language syntax using semi-real world examples. 4 | 5 | The resources being focused on in this particular demonstration are user accounts but it can really be for anything. 6 | 7 | # Caveats 8 | 9 | The code in this repository should mostly work but it is generally not intended for production use. When possible, you should be using a proper identity provider for SSO. 10 | 11 | I have attempted to document as much as possible for how and why things are structured the way they are. I am sure I am missing something or not documenting some things as clearly as I could. If you see an glaring omissions please let me know or submit a pull request to update the gaps you have noticed. 12 | 13 | The github code assumes that you are using github.com, not github enterprise. The method for managing those resources are slightly different but the concept is essentially the same. 14 | 15 | # Directory structure 16 | 17 | The directory structure of a Terraform code base is extremely flexible but that flexibility can result in messy code. This repository attempts to follow best practices. In general, this is how an ideal terraform codebase will be structured: 18 | 19 | - `modules/` will contain all of your Terraform modules. It is possible to put your modules anywhere but, for ease of future maintenance, it is preferred to centralize them. The goal of a terraform module is to have a collection of more complex resources which work together to provide a single outcome. If you need more specific sub-division of this catch-all style directory, simple sub-directories are preferred. Examples: 20 | - `modules/aws/{iam,rds-database,eks-cluster}/` 21 | - `modules/gcp/{iam,gce-instance,bigquery}/` 22 | - `modules/fastly/` 23 | - `global_vars.tf` contains code which is intended to be shared between environments. In our particular case, we are building user account objects but it can be anything. The code in this file is shared by symlinking it in the directories where it will be needed. 24 | - There should be separate environment-specific directories. What this means to your organization is going to be different for everyone but, in general, `production/`, `staging/`, and `development/` environments are good to have. This is where you will define the resources for each of the respective environments. You build them using the code defined in the `modules/` directory as a framework for the more complex resources. For resources that are too small or are overly environment-specific, you can just put them in these directories without using a module. Think of things like firewall rules. 25 | - You can also have directories for resources intended to be shared between environments. Think of things like `dns/` or `s3_buckets/`. 26 | - `CODEOWNERS` defines who is allowed to approved changes to which files. This is relevant when using protected branches in github. 27 | 28 | With the exception of `modules/`, all of these directories should have their own `init.tf` file which defines unique state files. The two main reasons for this are: 29 | 30 | 1. It limits the possibility of you destroying your entire network by corrupting a global state file. When environments are broken out into their own state files, the "blast radius" of something going horribly wrong is greatly reduced. 31 | 2. It allows for better collaboration between teams. When a plan/apply is being executed, it locks changes to the state file so no other operation can happen until the running process ends. Using separate state files means three different people can be working on `iam/`, `github/`, or `production/` all at the same time without interfering with each other. -------------------------------------------------------------------------------- /github/global_vars.tf: -------------------------------------------------------------------------------- 1 | ../global_vars.tf -------------------------------------------------------------------------------- /github/init.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.4.4" 3 | 4 | required_providers { 5 | // The AWS provider is required for backend state storage/locking 6 | aws = { 7 | source = "hashicorp/aws" 8 | version = ">= 4.63.0" 9 | } 10 | github = { 11 | source = "integrations/github" 12 | version = "~> 5.0" 13 | } 14 | } 15 | 16 | // This stores the terraform state in an S3 bucket 17 | backend "s3" { 18 | bucket = "company-tf-state" 19 | // The path to our state file in the bucket 20 | key = "github/terraform.tfstate" 21 | region = "us-east-1" 22 | // DynamoDB is used to prevent concurrent writes to the state file 23 | dynamodb_table = "terraform-lock" 24 | } 25 | } 26 | 27 | provider "aws" { 28 | region = "us-east-1" 29 | } 30 | 31 | provider "github" { 32 | owner = "your-github-org" 33 | // We use a github app for authentication 34 | app_auth { 35 | id = "12345" 36 | installation_id = "67890" 37 | pem_file = file("/path/to/github-certs.pem") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /github/repo-example.tf: -------------------------------------------------------------------------------- 1 | module "repo-infrastructure" { 2 | repository_name = "Infrastructure" 3 | visibility = "private" 4 | 5 | // Teams and users require different terraform resources be created. We split 6 | // them here mostly for readability but also because either can be omitted 7 | // depending on your use case. 8 | team_permissions = { 9 | read = [ 10 | module.team-developers.team_id, 11 | ] 12 | 13 | triage = [ 14 | ] 15 | 16 | write = [ 17 | ] 18 | 19 | maintain = [ 20 | module.team-sre.team_id, 21 | ] 22 | 23 | admin = [ 24 | ] 25 | } 26 | 27 | user_permissions = { 28 | read = [ 29 | ] 30 | 31 | triage = [ 32 | ] 33 | 34 | write = [ 35 | ] 36 | 37 | maintain = [ 38 | ] 39 | 40 | admin = [ 41 | // Users can be defined using either the terraform resource or a string 42 | // containing their github username. 43 | module.team-sre.github_team_membership.company-admins["some_user@email.com"].username, 44 | "LastU", 45 | ] 46 | } 47 | 48 | source = "../modules/github-repository" 49 | } 50 | -------------------------------------------------------------------------------- /github/team-developers.tf: -------------------------------------------------------------------------------- 1 | module "team-developers" { 2 | team_name = "team-developers" 3 | team_members = local.team.developers 4 | 5 | source = "../modules/github-team" 6 | } 7 | -------------------------------------------------------------------------------- /github/team-sre.tf: -------------------------------------------------------------------------------- 1 | module "team-sre" { 2 | team_name = "team-sre" 3 | team_members = local.team.sre 4 | 5 | source = "../modules/github-team" 6 | } 7 | -------------------------------------------------------------------------------- /global_vars.tf: -------------------------------------------------------------------------------- 1 | // Place global variables in all environments in here. Symlink this file into 2 | // all directories where it will be used. 3 | locals { 4 | // The fileset() function is used to find all team file definitions in the 5 | // appropriate directory. We specify a path of '../teams' even though we are 6 | // in the top level directory of this repository. The reason for this is 7 | // because this file is intended to be symlinked into each of the child 8 | // directories where the team definitions are required. The path relative to 9 | // the child directories is '../teams' 10 | team = { for f in fileset(path.module, "../teams/**.yaml") : 11 | // We use the filename as the team name 12 | trimsuffix(basename(f), ".yaml") => { 13 | for k, v in yamldecode(file(f)) : 14 | // Dynamically insert team_name and email so we can more easily reference 15 | // them later 16 | k => merge(v, { "email" = k }, { "team_name" = trimsuffix(basename(f), ".yaml") }) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /google/global_vars.tf: -------------------------------------------------------------------------------- 1 | ../global_vars.tf -------------------------------------------------------------------------------- /google/group-developers.tf: -------------------------------------------------------------------------------- 1 | module "group-developers" { 2 | group_email = "developers@email.com" 3 | group_name = "Developers" 4 | group_description = "Company Name Developer Team" 5 | group_members = local.team.developers 6 | 7 | source = "../modules/google-group" 8 | } 9 | -------------------------------------------------------------------------------- /google/group-engineering.tf: -------------------------------------------------------------------------------- 1 | module "group-engineering" { 2 | group_email = "engineering@email.com" 3 | group_name = "Engineering" 4 | group_description = "Company Name Engineering Department" 5 | // Multiple teams can be combined easily with merge() to account for 6 | // department-wide groups. 7 | group_members = merge( 8 | local.team.developers, 9 | local.team.sre, 10 | ) 11 | 12 | source = "../modules/google-group" 13 | } 14 | -------------------------------------------------------------------------------- /google/group-sre.tf: -------------------------------------------------------------------------------- 1 | module "group-sre" { 2 | group_email = "sre@email.com" 3 | group_name = "SRE" 4 | group_description = "Company Name SRE Team" 5 | group_members = local.team.sre 6 | 7 | source = "../modules/google-group" 8 | } 9 | -------------------------------------------------------------------------------- /google/init.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.4.0" 3 | 4 | required_providers { 5 | // The AWS provider is required for backend state storage/locking 6 | aws = { 7 | source = "hashicorp/aws" 8 | version = ">= 4.9.0" 9 | } 10 | googleworkspace = { 11 | source = "hashicorp/googleworkspace" 12 | version = ">= 0.7.0" 13 | } 14 | } 15 | 16 | // This stores the terraform state in an S3 bucket 17 | backend "s3" { 18 | bucket = "company-tf-state" 19 | // The path to our state file in the bucket 20 | key = "github/terraform.tfstate" 21 | region = "us-east-1" 22 | // DynamoDB is used to prevent concurrent writes to the state file 23 | dynamodb_table = "terraform-lock" 24 | } 25 | } 26 | 27 | provider "aws" { 28 | region = "us-east-1" 29 | } 30 | 31 | // Using a service account to authenticate to the Google Workspace API 32 | provider "googleworkspace" { 33 | // The credentials file is generated for the specific service account you 34 | // will be using. 35 | credentials = "/path/to/credentials.json" 36 | customer_id = "1234567890" 37 | oauth_scopes = [ 38 | "https://www.googleapis.com/auth/admin.directory.user", 39 | "https://www.googleapis.com/auth/admin.directory.userschema", 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /google/users-company.tf: -------------------------------------------------------------------------------- 1 | module "users-company" { 2 | 3 | // We use merge() when specifying multiple teams. This will create a flat map 4 | // of all users. Creating a flat map makes it easier to iterate over the user 5 | // objects in the module. 6 | teams = merge( 7 | // Only create user accounts for teams who should have access 8 | local.team.sre, 9 | local.team.developers, 10 | ) 11 | 12 | source = "../modules/google-workspace-users" 13 | } 14 | -------------------------------------------------------------------------------- /google/users-contractors.tf: -------------------------------------------------------------------------------- 1 | module "users-contractors" { 2 | // We use merge() when specifying multiple teams. This will create a flat map 3 | // of all users. Creating a flat map makes it easier to iterate over the user 4 | // objects in the module. 5 | teams = merge( 6 | // Easily separate contractors from employees 7 | local.team.contractors, 8 | ) 9 | 10 | source = "../modules/google-workspace-users" 11 | } 12 | -------------------------------------------------------------------------------- /iam/README.md: -------------------------------------------------------------------------------- 1 | # Recommendations 2 | 3 | - Prefix your resource definitions with the object type that they are. The reason for this is because it makes it easier to parse and it's easier to delegate permissions via the CODEOWNERS file. For example: 4 | - `users-W.tf` 5 | - `group-X.tf` 6 | - `policy-Y.tf` 7 | - `role-Z.tf` 8 | - Keep employee and contractor definitions as separate as possible. This is more of a recommendation as it relates to security policies in your organization. 9 | - Try not to grant permissions to individual users. Use team definitions instead. It quickly turns into a management nightmare to do it otherwise. -------------------------------------------------------------------------------- /iam/global_vars.tf: -------------------------------------------------------------------------------- 1 | ../global_vars.tf -------------------------------------------------------------------------------- /iam/group-company-developers.tf: -------------------------------------------------------------------------------- 1 | module "group-developers" { 2 | group_name = "CompanyDevelopers" 3 | 4 | // We use merge() when specifying multiple teams. This will create a flat map 5 | // of all users. Creating a flat map makes it easier to iterate over the user 6 | // objects in the module. 7 | teams = merge( 8 | local.team.developers, 9 | ) 10 | 11 | policy_arns = [ 12 | aws_iam_policy.company-developers.arn, 13 | ] 14 | 15 | // Don't attempt to create this group until the users have been created 16 | dependency = module.users-company.iam_users_list 17 | source = "../modules/iam-group" 18 | } 19 | -------------------------------------------------------------------------------- /iam/group-contractor-developers.tf: -------------------------------------------------------------------------------- 1 | module "group-contractor-developers" { 2 | group_name = "ContractorDevelopers" 3 | 4 | // We use merge() when specifying multiple teams. This will create a flat map 5 | // of all users. Creating a flat map makes it easier to iterate over the user 6 | // objects in the module. 7 | teams = merge( 8 | local.team.contractors, 9 | ) 10 | 11 | policy_arns = [ 12 | aws_iam_policy.contractor-developers.arn, 13 | ] 14 | 15 | // Don't attempt to create this group until the users have been created 16 | dependency = module.users-contractors.iam_users_list 17 | source = "../modules/iam-group" 18 | } 19 | -------------------------------------------------------------------------------- /iam/init.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.4.0" 3 | 4 | required_providers { 5 | aws = { 6 | source = "hashicorp/aws" 7 | version = ">= 4.9.0" 8 | } 9 | } 10 | 11 | // This stores the terraform state in an S3 bucket 12 | backend "s3" { 13 | bucket = "company-tf-state" 14 | // The path to our state file in the bucket 15 | key = "iam/terraform.tfstate" 16 | region = "us-east-1" 17 | // DynamoDB is used to prevent concurrent writes to the state file 18 | dynamodb_table = "terraform-lock" 19 | } 20 | } 21 | 22 | provider "aws" { 23 | region = "us-east-1" 24 | } 25 | -------------------------------------------------------------------------------- /iam/users-company.tf: -------------------------------------------------------------------------------- 1 | module "users-company" { 2 | 3 | // We use merge() when specifying multiple teams. This will create a flat map 4 | // of all users. Creating a flat map makes it easier to iterate over the user 5 | // objects in the module. 6 | teams = merge( 7 | // Only create user accounts for teams who should have access 8 | local.team.sre, 9 | local.team.developers, 10 | ) 11 | 12 | source = "../modules/iam-users" 13 | } 14 | -------------------------------------------------------------------------------- /iam/users-contractors.tf: -------------------------------------------------------------------------------- 1 | module "users-contractors" { 2 | // We use merge() when specifying multiple teams. This will create a flat map 3 | // of all users. Creating a flat map makes it easier to iterate over the user 4 | // objects in the module. 5 | teams = merge( 6 | // Easily separate contractors from employees 7 | local.team.contractors, 8 | ) 9 | 10 | source = "../modules/iam-users" 11 | } 12 | -------------------------------------------------------------------------------- /modules/github-repository/main.tf: -------------------------------------------------------------------------------- 1 | resource "github_repository" "company-name" { 2 | name = var.repository_name 3 | visibility = var.visibility 4 | has_issues = var.has_issues 5 | has_discussions = var.has_discussions 6 | has_wiki = var.has_wiki 7 | delete_branch_on_merge = var.delete_branch_on_merge 8 | auto_init = var.auto_init 9 | vulnerability_alerts = var.vulnerability_alerts 10 | } 11 | -------------------------------------------------------------------------------- /modules/github-repository/team_permissions.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | // We need to prepare the data for the for_each loop below. The result is a 3 | // list of objects with the following format: 4 | // [ 5 | // { role_name = "read", team_name = "sre" }, 6 | // { role_name = "write", team_name = "developers" }, 7 | // ] 8 | team_permissions = flatten([ 9 | for role, teamlist in var.team_permissions : [ 10 | for team in teamlist : { 11 | role_name = role 12 | team_name = team 13 | } 14 | ] 15 | ]) 16 | } 17 | 18 | resource "github_team_repository" "company-name" { 19 | // This builds the terraform object ID for each team permission. The result 20 | // is an object with the following format: 21 | // module.repo-name.github_team_repository.company-name["ROLE_NAME.TEAM_NAME"] 22 | // The only real purpose for this is to make it easier to recognize the 23 | // object ID when looking at the terraform state file. 24 | for_each = { 25 | for permission in local.team_permissions : "${permission.role_name}.${permission.team_name}" => permission 26 | } 27 | permission = each.value.role_name 28 | team_id = each.value.team_name 29 | repository = github_repository.company-name.name 30 | } 31 | -------------------------------------------------------------------------------- /modules/github-repository/user_permissions.tf: -------------------------------------------------------------------------------- 1 | // This creates permissions for individual users on a repository. The preferred 2 | // way to grant access is via teams, but sometimes you need to grant access to 3 | // a single user. This accounts for those exceptions. 4 | locals { 5 | // We need to prepare the data for the for_each loop below. The result is a 6 | // list of objects with the following format: 7 | // [ 8 | // { role_name = "read", user_name = "user1" }, 9 | // { role_name = "write", user_name = "user2" }, 10 | // ] 11 | user_permissions = flatten([ 12 | for role, userlist in var.user_permissions : [ 13 | for user in userlist : { 14 | role_name = role 15 | user_name = user 16 | } 17 | ] 18 | ]) 19 | } 20 | 21 | resource "github_repository_collaborator" "company-name" { 22 | // This builds the terraform object ID for each user permission. The result 23 | // is an object with the following format: 24 | // module.repo-name.github_repository_collaborator.company-name["ROLE_NAME.USER_NAME"] 25 | // The only real purpose for this is to make it easier to recognize the 26 | // object ID when looking at the terraform state file. 27 | for_each = { 28 | for permission in local.user_permissions : "${permission.role_name}.${permission.user_name}" => permission 29 | } 30 | permission = each.value.role_name 31 | username = each.value.user_name 32 | repository = github_repository.company-name.name 33 | } 34 | -------------------------------------------------------------------------------- /modules/github-repository/vars.tf: -------------------------------------------------------------------------------- 1 | variable "repository_name" { 2 | type = string 3 | } 4 | 5 | variable "visibility" { 6 | type = string 7 | default = "private" 8 | } 9 | 10 | variable "has_issues" { 11 | type = bool 12 | default = true 13 | } 14 | 15 | variable "has_discussions" { 16 | type = bool 17 | default = true 18 | } 19 | 20 | variable "has_wiki" { 21 | type = bool 22 | default = true 23 | } 24 | 25 | variable "delete_branch_on_merge" { 26 | type = bool 27 | default = true 28 | } 29 | 30 | variable "auto_init" { 31 | type = bool 32 | default = true 33 | } 34 | 35 | variable "vulnerability_alerts" { 36 | type = bool 37 | default = true 38 | } 39 | 40 | variable "user_permissions" { 41 | description = "A map of user permissions." 42 | default = {} 43 | } 44 | 45 | variable "team_permissions" { 46 | description = "A map of team permissions." 47 | default = {} 48 | } 49 | -------------------------------------------------------------------------------- /modules/github-team/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | // Specify teams which are granted admin access to the org. The SRE team is 3 | // designated as github admins in this instance. 4 | // The fileset() OR matching pattern is: 5 | // {team_1,team_2,...,team_n}.yaml 6 | admin_groups = fileset(path.root, "../../teams/{sre}.yaml") 7 | // Create a list of admin usernames 8 | admin_users = flatten([ 9 | // Iterate over each file in the admin_groups list 10 | for f in local.admin_groups : [ 11 | // Iterate over each user in the file 12 | for u in yamldecode(file(f)) : [ 13 | u["github"]["username"], 14 | ] 15 | ] 16 | ]) 17 | } 18 | 19 | resource "github_team" "company-name" { 20 | name = var.team_name 21 | privacy = "closed" 22 | } 23 | 24 | // When a user is added to the org, an invitation is sent to the user's email. 25 | // The user account must already exist. 26 | resource "github_membership" "company-name" { 27 | for_each = var.team_members 28 | username = each.value.github.username 29 | // Set to admin if the username is in the admin_users list defined above. 30 | // Otherwise, set to member. 31 | role = contains(local.admin_users, each.value.github.username) ? "admin" : "member" 32 | } 33 | 34 | // The following two resources adds the users to thier appropriate team 35 | resource "github_team_membership" "company-admins" { 36 | // Iterate over all users in the team_members map but only include those 37 | // whose names are in the admin_users list 38 | for_each = { 39 | for k, v in var.team_members : k => v if contains(local.admin_users, v.github.username) 40 | } 41 | team_id = github_team.company-name.id 42 | username = each.value.github.username 43 | 44 | // Roles in a team have no effect on org admins so we ignore 45 | lifecycle { 46 | ignore_changes = [ 47 | role, 48 | ] 49 | } 50 | } 51 | 52 | // We track role changes for all other users 53 | resource "github_team_membership" "company-non-admins" { 54 | // Iterate over all users in the team_members map but exclude those whose 55 | // names are not in the admin_users list 56 | for_each = { 57 | for k, v in var.team_members : k => v if !contains(local.admin_users, v.github.username) 58 | } 59 | team_id = github_team.company-name.id 60 | username = each.value.github.username 61 | // If team_lead is set to true in the teams file, grant the user maintainer 62 | // permissions over the group. 63 | role = try(each.value.team_lead, false) ? "maintainer" : "member" 64 | } 65 | 66 | // This output is used to grant access to repositories. 67 | output "team_id" { 68 | value = github_team.company-name.id 69 | } 70 | -------------------------------------------------------------------------------- /modules/github-team/provider.tf: -------------------------------------------------------------------------------- 1 | // Github requires the provider configuration to be included in the module as 2 | // well as the root directory 3 | terraform { 4 | required_version = ">= 1.4.4" 5 | 6 | required_providers { 7 | github = { 8 | source = "integrations/github" 9 | version = "~> 5.0" 10 | } 11 | } 12 | } 13 | 14 | provider "github" { 15 | owner = "your-github-org" 16 | // We use a github app for authentication 17 | app_auth { 18 | id = "12345" 19 | installation_id = "67890" 20 | pem_file = file("/path/to/github-certs.pem") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /modules/github-team/vars.tf: -------------------------------------------------------------------------------- 1 | variable "team_name" { 2 | type = string 3 | } 4 | 5 | variable "team_members" { 6 | } 7 | -------------------------------------------------------------------------------- /modules/google-group/main.tf: -------------------------------------------------------------------------------- 1 | resource "googleworkspace_group" "company-name" { 2 | email = var.group_email 3 | name = var.group_name 4 | description = var.group_description 5 | aliases = var.group_aliases 6 | } 7 | 8 | resource "googleworkspace_group_member" "company-name" { 9 | // Iterate over the user maps 10 | for_each = var.teams 11 | // The ID of the group created above 12 | group_id = googleworkspace_group.company-name.id 13 | email = each.value.email 14 | 15 | // If team_lead == true, then role = MANAGER, else role = MEMBER 16 | role = each.value.team_lead == true ? "MANAGER" : "MEMBER" 17 | } 18 | -------------------------------------------------------------------------------- /modules/google-group/vars.tf: -------------------------------------------------------------------------------- 1 | variable "group_members" { 2 | description = "A map of users to be created" 3 | } 4 | 5 | variable "group_email" { 6 | description = "Address of the group being created" 7 | type = string 8 | } 9 | 10 | variable "group_name" { 11 | description = "The display name of the group" 12 | type = string 13 | default = null 14 | } 15 | 16 | variable "group_description" { 17 | description = "A description of the group" 18 | type = string 19 | default = null 20 | } 21 | 22 | variable "group_aliases" { 23 | description = "A list of aliases for the group" 24 | type = list(string) 25 | default = [] 26 | } 27 | -------------------------------------------------------------------------------- /modules/google-workspace-users/main.tf: -------------------------------------------------------------------------------- 1 | resource "googleworkspace_user" "company-name" { 2 | for_each = var.teams 3 | 4 | primary_email = each.value.email 5 | password = random_password.user-credentials[each.value.email].result 6 | hash_function = "crypt" 7 | change_password_at_next_login = true 8 | 9 | name { 10 | given_name = each.value.given_name 11 | family_name = each.value.family_name 12 | } 13 | 14 | // Default to an empty list if no aliases are specified 15 | aliases = try(each.value.google.aliases, []) 16 | 17 | organizations { 18 | department = each.value.team_name 19 | type = "work" 20 | } 21 | 22 | // We only care about these values at creation time so we ignore ongoing 23 | // changes. 24 | lifecycle { 25 | ignore_changes = [ 26 | password, 27 | change_password_at_next_login, 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /modules/google-workspace-users/password.tf: -------------------------------------------------------------------------------- 1 | resource "random_password" "user-credentials" { 2 | // We use for_each to create a password for each user and to be able to 3 | // access it's value with this terraform object ID: 4 | // random_password.user-credentials["some_user@email.com"].result 5 | for_each = var.teams 6 | length = 16 7 | special = false 8 | 9 | // We only care about these values at creation time. We ignore ongoing 10 | // changes. 11 | lifecycle { 12 | ignore_changes = [ 13 | length, 14 | special, 15 | ] 16 | } 17 | } 18 | 19 | // The following two resources are used to store the user credentials in AWS 20 | // Secrets Manager. If another secret storage provider is available, you can 21 | // still use this method by accessing the above randomly generated password 22 | // with the following terraform object ID format: 23 | // random_password.user-credentials["some_user@email.com"].result 24 | resource "aws_secretsmanager_secret" "user-credentials" { 25 | for_each = var.teams 26 | // Occasionally, provisioning new user accounts is done by an IT or HR team. 27 | // By programmatically prefixing the secret with "google_user_credentials_" 28 | // it allows us to create an IAM policy which grants access to only the 29 | // secrets containing this prefix to the non-technical teams doing the 30 | // account creations. 31 | name = "google_user_credentials_${each.value.email}" 32 | } 33 | 34 | resource "aws_secretsmanager_secret_version" "user-credentials" { 35 | for_each = var.teams 36 | secret_id = aws_secretsmanager_secret.user-credentials[each.value.email].id 37 | secret_string = jsonencode({ 38 | email = each.value.email 39 | password = random_password.user-credentials[each.value.email].result 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /modules/google-workspace-users/vars.tf: -------------------------------------------------------------------------------- 1 | variable "teams" { 2 | description = "Map of user objects" 3 | } 4 | -------------------------------------------------------------------------------- /modules/iam-group/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_group" "company-group" { 2 | name = var.group_name 3 | // We pass the username list to this module to ensure the users are created 4 | // before the groups. 5 | depends_on = [var.dependency] 6 | } 7 | 8 | resource "aws_iam_group_policy_attachment" "company-policy-attachment" { 9 | // Iterate over the list of policy ARNs. 10 | count = length(var.policy_arns) 11 | group = var.group_name 12 | policy_arn = var.policy_arns[count.index] 13 | } 14 | 15 | resource "aws_iam_group_membership" "company-group-membership" { 16 | name = "${var.group_name}-group-membership" 17 | // Create a list of usernames from the map of teams, skipping users if they 18 | // do not have an IAM username defined. 19 | users = flatten([for k, v in var.teams : try(v["iam"]["username"], [])]) 20 | group = var.group_name 21 | } 22 | -------------------------------------------------------------------------------- /modules/iam-group/vars.tf: -------------------------------------------------------------------------------- 1 | variable "group_name" { 2 | description = "Name of the group" 3 | type = string 4 | } 5 | 6 | variable "policy_arns" { 7 | description = "List of policy ARNs to attach to the group" 8 | type = list(any) 9 | default = [] 10 | } 11 | 12 | variable "teams" { 13 | description = "Teams object" 14 | } 15 | 16 | variable "dependency" { 17 | description = "Dependency on other resources" 18 | type = list(any) 19 | default = [] 20 | } 21 | -------------------------------------------------------------------------------- /modules/iam-users/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_user" "users" { 2 | // Only create user accounts if a username is defined in the team file 3 | for_each = { for k, v in var.teams : k => v if try(v.iam.username, null) != null } 4 | name = each.value.iam.username 5 | 6 | tags = { 7 | // The email and team_name values were dynamically inserted into the user 8 | // map in the global_vars.tf file. This is an example of how it becomes 9 | // easier to access those values. 10 | email = each.value.email 11 | team = each.value.team_name 12 | } 13 | } 14 | 15 | output "iam_users_list" { 16 | // This output isn't used for anything other than establishing a dependency 17 | // with an IAM group definition. 18 | value = [aws_iam_user.users.*] 19 | } 20 | -------------------------------------------------------------------------------- /modules/iam-users/password.tf: -------------------------------------------------------------------------------- 1 | resource "random_password" "user-credentials" { 2 | // We use for_each to create a password for each user and to be able to 3 | // access it's value with this terraform object ID: 4 | // random_password.user-credentials["some_user@email.com"].result 5 | for_each = var.teams 6 | length = 16 7 | special = false 8 | 9 | // We only care about these values at creation time. We ignore ongoing 10 | // changes. 11 | lifecycle { 12 | ignore_changes = [ 13 | length, 14 | special, 15 | ] 16 | } 17 | } 18 | 19 | // The following two resources are used to store the user credentials in AWS 20 | // Secrets Manager. If another secret storage provider is available, you can 21 | // still use this method by accessing the above randomly generated password 22 | // with the following terraform object ID format: 23 | // random_password.user-credentials["some_user@email.com"].result 24 | resource "aws_secretsmanager_secret" "user-credentials" { 25 | for_each = var.teams 26 | // Occasionally, provisioning new user accounts is done by an IT or HR team. 27 | // By programmatically prefixing the secret with "aws_user_credentials_" 28 | // it allows us to create an IAM policy which grants access to only the 29 | // secrets containing this prefix to the non-technical teams doing the 30 | // account creations. 31 | name = "aws_user_credentials_${each.value.email}" 32 | } 33 | 34 | resource "aws_secretsmanager_secret_version" "user-credentials" { 35 | for_each = var.teams 36 | secret_id = aws_secretsmanager_secret.user-credentials[each.value.email].id 37 | secret_string = jsonencode({ 38 | email = each.value.email 39 | password = random_password.user-credentials[each.value.email].result 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /modules/iam-users/vars.tf: -------------------------------------------------------------------------------- 1 | variable "teams" { 2 | description = "Map of user objects" 3 | } 4 | -------------------------------------------------------------------------------- /teams/README.md: -------------------------------------------------------------------------------- 1 | # Background 2 | 3 | The `teams/` directory contains yaml definitions of the teams whose user accounts we want to track in code. There are a few reasons why we would want to do that. 4 | 5 | 1. When this code is tracked in git, we have a record of when changes were made, by who, and who approved those changes to go into production. This provides an automatic audit trail if any questions arise in the future. 6 | 2. When defining the user details in a centralized manner, we can easily add and delete them from all teams, groups, policies, etc. 7 | 3. It mandates consistency throughout the organization. 8 | 4. Because the team definitions are written in yaml, we can easily add additional attributes for individual users that can be consumed by any terraform code we might add in the future. 9 | 10 | # What happens when I add a user 11 | 12 | The `teams/` directory is the central focus point of this project. From here we control all aspects of a user's permissions. By adding a user to the `sre.yaml` file, these definitions will automatically be created: 13 | 14 | 1. A google workspace/gmail account will be created for them in the company's domain. 15 | 2. A temporary password will be generated for that email account and stored in AWS Secrets Manager. They will be prompted to change it on first login. 16 | 3. They will be added to the `sre@email.com` Google group/mailing list. 17 | 4. An AWS IAM user account will be created for that user. 18 | 5. The AWS account password will be created and stored in AWS Secrets Manager. 19 | 6. They will be added to the AWS IAM group that is associated with their team. 20 | 7. All appropriate AWS permissions will be delegated to that user. 21 | 8. An invitation will be sent to their github user to join the company organization. 22 | 9. After accepting the invitation, they will be added to their appropriate team. 23 | 10. Access will be granted to all of the org repositories they are able to participate in. 24 | 11. Permissions to each of those repositories will be configured automatically. 25 | 26 | # Why we use yaml 27 | 28 | 1. We can easily add additional attributes for a user without breaking code structure 29 | 2. It can be easier to read than Terraform code 30 | 3. It supports comments and json does not 31 | 4. It's easy to parse with simple tools 32 | 5. If we provide access to less technical teams for them to manage users, they will understand yaml more easily than Terraform HCL 33 | 34 | # How these attributes get into Terraform 35 | 36 | The [`global_vars.tf`](../global_vars.tf) file looks into this directory for all files ending with `.yaml`. It assembles that code into a single `team` object with this structure: 37 | 38 | ``` 39 | { 40 | sre = { 41 | "some_user@email.com" = { 42 | given_name = "Some User" 43 | family_name = "Some User" 44 | email = "some_user@email.com" 45 | team_name = "sre" 46 | team_lead = true 47 | iam = { 48 | username = "suser" 49 | } 50 | github = { 51 | username = "someuser" 52 | } 53 | google = { 54 | aliases = [ 55 | "suser", 56 | ] 57 | } 58 | } 59 | "another_user@email.com" = { 60 | given_name = "Another User" 61 | family_name = "Another User" 62 | email = "another_user@email.com" 63 | team_name = "sre" 64 | iam = { 65 | username = "otheruser" 66 | } 67 | github = { 68 | username = "AnotherUser45" 69 | } 70 | } 71 | } 72 | developers = { 73 | "last_one@email.com" = { 74 | given_name = "Last One" 75 | family_name = "Last One" 76 | email = "last_one@email.com" 77 | team_name = "developers" 78 | team_lead = true 79 | github = { 80 | username = "LastU" 81 | } 82 | } 83 | } 84 | } 85 | ``` 86 | 87 | ### Notes on `team_name`: 88 | 89 | The reason we include the team name once as the top level key and as a value of `team_name` in the user object is to make it easier to parse in the code itself. 90 | 91 | For example, using this structure, we can reference the SRE team using `local.team.sre` (the top level key). 92 | 93 | When we pass that to a module, the only thing that the module sees is the user objects contained inside the `sre` body, not the `sre` key itself. We need to include it here so that the module knows what team we are talking about while iterating. 94 | 95 | To access the details for each of the user objects in this body, we would use a `for_each` statement and simply ask for the value of the appropriate keys. 96 | 97 | ``` 98 | for_each = var.team 99 | team_name = value.each.team_name 100 | ``` 101 | 102 | ### Notes on `email`: 103 | 104 | The email address is the top level key of each user object. It is easy enough to reference it in a loop like this: 105 | 106 | ``` 107 | for_each = var.team 108 | email = each.key 109 | ``` 110 | 111 | This is fine and it works but, for readability, it is easier to simply inject the `email` key into the user object so it can be referenced like any other attribute associated with this user. 112 | 113 | ``` 114 | for_each = var.team 115 | email = each.value.email 116 | ``` -------------------------------------------------------------------------------- /teams/contractors.yaml: -------------------------------------------------------------------------------- 1 | example_one@email.com: 2 | given_name: "Example" 3 | family_name: "One" 4 | iam: 5 | username: "eone-contractor" 6 | github: 7 | username: "ExOne" 8 | -------------------------------------------------------------------------------- /teams/developers.yaml: -------------------------------------------------------------------------------- 1 | # By excluding an IAM configuration, this user will not have an IAM account 2 | # created, even if the team is designated to have accounts created. 3 | last_one@email.com: 4 | given_name: "Last" 5 | family_name: "One" 6 | team_lead: true 7 | github: 8 | username: "LastU" 9 | -------------------------------------------------------------------------------- /teams/sre.yaml: -------------------------------------------------------------------------------- 1 | some_user@email.com: 2 | given_name: "Some" 3 | family_name: "User" 4 | team_lead: true 5 | iam: 6 | username: "suser" 7 | github: 8 | username: "someuser" 9 | google: 10 | aliases: 11 | - "suser" 12 | 13 | another_user@email.com: 14 | given_name: "Another" 15 | family_name: "User" 16 | iam: 17 | username: "otheruser" 18 | github: 19 | username: "AnotherUser45" 20 | --------------------------------------------------------------------------------