From 729d8ed285e02ed5375c28cd7f3c01f085b1bd21 Mon Sep 17 00:00:00 2001 From: Matt Gowie Date: Thu, 1 May 2025 15:36:57 -0600 Subject: [PATCH 1/9] Initial commit --- .github/renovate.json5 | 2 +- .github/workflows/test.yaml | 2 +- CHANGELOG.md | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 275d017..3b5016b 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -61,4 +61,4 @@ "groupName": "tf" } ] -} +} \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1574792..056a582 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -25,4 +25,4 @@ jobs: with: tf_type: ${{ matrix.tf }} aws_role_arn: ${{ vars.TF_TEST_AWS_ROLE_ARN }} - github_token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 030d4ef..16e1a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,5 @@ # Changelog -## [0.7.0](https://github.com/masterpointio/terraform-module-template/compare/v0.6.0...v0.7.0) (2025-05-07) - ### Features From 0606e624607f09357f3d06b9c5a67272ca536d1b Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Fri, 2 May 2025 17:14:50 -0600 Subject: [PATCH 2/9] feat(INT-53): add user and group (#1) - The goal of this PR is setup the tf module, add a basic README, setup testing, and get user/group basics working - add `googleworkspace`.`user` with tests for email and password - add `googleworkspace`.`group` - with tests for email - [INT-53](https://www.notion.so/masterpoint/Managing-GSuite-Users-via-IaC-1d0859758a568029b956f2ab8c9a2651) - **New Features** - Introduced automation for managing Google Workspace users and groups via new input variables. - Added support for configuring user and group attributes, including validation for emails, passwords, and hash functions. - **Bug Fixes** - Improved input validation to ensure correct email formats and password constraints. - **Documentation** - Updated README to reflect the new module name, purpose, usage instructions, and provider requirements. - Removed outdated changelog content. - **Tests** - Added comprehensive tests for user and group variable validation, including email, password, and hash function checks. - **Chores** - Updated provider version requirements for compatibility and stability. - Removed obsolete outputs and variables. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 7 +- README.md | 82 +++++++++---- examples/complete/main.tf | 13 ++- examples/complete/outputs.tf | 1 - examples/complete/providers.tf | 17 +++ examples/complete/variables.tf | 1 - examples/complete/versions.tf | 10 ++ main.tf | 44 ++++++- outputs.tf | 5 +- tests/variables_groups.tftest.hcl | 43 +++++++ tests/variables_users.tftest.hcl | 183 ++++++++++++++++++++++++++++++ variables.tf | 104 ++++++++++++++++- versions.tf | 6 +- 13 files changed, 478 insertions(+), 38 deletions(-) delete mode 100644 examples/complete/outputs.tf create mode 100644 examples/complete/providers.tf delete mode 100644 examples/complete/variables.tf create mode 100644 examples/complete/versions.tf create mode 100644 tests/variables_groups.tftest.hcl create mode 100644 tests/variables_users.tftest.hcl diff --git a/.gitignore b/.gitignore index 9636abe..8bb7960 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,12 @@ # Local .terraform directories **/.terraform/* +# Example terraform lock files +examples/**/*.terraform.lock.hcl + # Ignore the root .terraform.lock.hcl file (Child modules don't want this) .terraform.lock.hcl -!examples/**/.terraform.lock.hcl +# !examples/**/.terraform.lock.hcl # IDE/Editor settings **/.idea @@ -44,3 +47,5 @@ backend.tf.json **/*.bak **/*.*swp **/.DS_Store + + diff --git a/README.md b/README.md index 3542e60..024f58d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Banner][banner-image]](https://masterpoint.io/) -# terraform-module-template +# terraform-googleworkspace-users-groups-automation [![Release][release-badge]][latest-release] @@ -8,17 +8,60 @@ ## Purpose and Functionality -This repository serves as a template for creating Terraform modules, providing a standardized structure and essential files for efficient module development. It's designed to ensure consistency and our best practices across Terraform projects. +This is a [child-module](https://opentofu.org/docs/language/modules/#child-modules) for managing Google Workspace users, groups, and roles. ## Usage -### Prerequisites (optional) +### Step-by-Step Instructions -TODO +There are 2 provider authentication routes available, +1 - authenticate a service account via API keys +2 - authenticate using API keys and impersonate a real User with Super Admin privileges. -### Step-by-Step Instructions +We recommend impersonating a Super Admin, which allows you to grant Admin privileges to users (service Accounts cannot do this). + +Follow the provider [authentication setup instructions](https://github.com/hashicorp/terraform-provider-googleworkspace/blob/main/docs/index.md#google-workspace-provider). + + + +Once you've finished the setup process, your provider block should look like this, + +```hcl +provider "googleworkspace" { + # use 'my_customer', which is an alias that Google's API recognizes to reference your account's customerId. + # For example - Custom Schemas on the user object will fail if the customer_id is set to your actual customer_id. + # For more details see: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get + customer_id = "my_customer" -TODO + credentials = "/path/to/credentials/my-google-project-credentials-1234567890.json" + impersonated_user_email = "my_impersonated_user_email@my_domain.com" + + oauth_scopes = [ + "https://www.googleapis.com/auth/admin.directory.group", + "https://www.googleapis.com/auth/admin.directory.user", + "https://www.googleapis.com/auth/admin.directory.userschema", + "https://www.googleapis.com/auth/apps.groups.settings", + "https://www.googleapis.com/auth/iam", + ] +} +``` + +## Example + +```hcl +module "googleworkspace" { + source = "git::https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation.git" + + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + password = "example-password" + } + } +} +``` @@ -28,13 +71,13 @@ TODO | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.0 | -| [random](#requirement\_random) | >= 3.0 | +| [googleworkspace](#requirement\_googleworkspace) | >= 0.7.0 | ## Providers | Name | Version | |------|---------| -| [random](#provider\_random) | >= 3.0 | +| [googleworkspace](#provider\_googleworkspace) | >= 0.7.0 | ## Modules @@ -46,7 +89,8 @@ TODO | Name | Type | |------|------| -| [random_pet.template](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/pet) | resource | +| [googleworkspace_group.groups](https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group) | resource | +| [googleworkspace_user.users](https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/user) | resource | ## Inputs @@ -59,24 +103,23 @@ TODO | [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | no | +| [groups](#input\_groups) | List of groups |
map(object({
name : string,
description : optional(string),
email : string,
timeouts : optional(object({
create : optional(string),
update : optional(string),
}), {
create = null
update = null
}),
}))
| `{}` | no | | [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | | [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | | [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | | [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | | [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | -| [length](#input\_length) | The length of the random name | `number` | `2` | no | | [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | | [namespace](#input\_namespace) | ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique | `string` | `null` | no | | [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | | [stage](#input\_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | | [tenant](#input\_tenant) | ID element \_(Rarely used, not included by default)\_. A customer identifier, indicating who this instance of a resource is for | `string` | `null` | no | +| [users](#input\_users) | List of users |
map(object({
# addresses
aliases : optional(list(string), []),
archived : optional(bool, false),
change_password_at_next_login : optional(bool),
# custom_schemas
# emails
# external_ids
family_name : string,
given_name : string,
groups : optional(list(object({
group_id : string, # The value can be the group's email address, group alias, or the unique group ID.
delivery_settings : optional(string, "ALL_MAIL"),
role : optional(string, "MEMBER"),
type : optional(string, "USER"),
}))),
# ims
include_in_global_address_list : optional(bool),
ip_allowlist : optional(bool),
is_admin : optional(bool),
# keywords
# languages
# locations
org_unit_path : optional(string),
# organizations
# phones
# posix_accounts
primary_email : string,
recovery_email : optional(string),
recovery_phone : optional(string),
# relations
# ssh_public_keys
suspended : optional(bool),
# timeouts
# websites

# User attributes with unique constraints

# password and hash_function
# If a hashFunction is specified, the password must be a valid hash key.
# If it's not specified, the password should be in clear text and between
# 8–100 ASCII characters.
# https://developers.google.com/workspace/admin/directory/v1/guides/manage-users
hash_function : optional(string),
password : optional(string),
}))
| `{}` | no | ## Outputs -| Name | Description | -|------|-------------| -| [random\_pet\_name](#output\_random\_pet\_name) | The generated random pet name | +No outputs. @@ -138,11 +181,8 @@ Copyright © 2016-2025 [Masterpoint Consulting LLC](https://masterpoint.io/) [newsletter-url]: https://newsletter.masterpoint.io/ [youtube-badge]: https://img.shields.io/badge/YouTube-Subscribe-D191BF?style=for-the-badge&logo=youtube&logoColor=white [youtube-url]: https://www.youtube.com/channel/UCeeDaO2NREVlPy9Plqx-9JQ - - - -[release-badge]: https://img.shields.io/github/v/release/masterpointio/terraform-module-template?color=0E383A&label=Release&style=for-the-badge&logo=github&logoColor=white -[latest-release]: https://github.com/masterpointio/terraform-module-template/releases/latest -[contributors-image]: https://contrib.rocks/image?repo=masterpointio/terraform-module-template -[contributors-url]: https://github.com/masterpointio/terraform-module-template/graphs/contributors -[issues-url]: https://github.com/masterpointio/terraform-module-template/issues +[release-badge]: https://img.shields.io/github/v/release/masterpointio/terraform-googleworkspace-users-groups-automation?color=0E383A&label=Release&style=for-the-badge&logo=github&logoColor=white +[latest-release]: https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation/releases/latest +[contributors-image]: https://contrib.rocks/image?repo=masterpointio/terraform-googleworkspace-users-groups-automation +[contributors-url]: https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation/graphs/contributors +[issues-url]: https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation/issues diff --git a/examples/complete/main.tf b/examples/complete/main.tf index f9d23f1..eaf5710 100644 --- a/examples/complete/main.tf +++ b/examples/complete/main.tf @@ -1 +1,12 @@ -# complete.tf +module "googleworkspace" { + source = "../../" + + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + password = "insecure-password-for-example" # trunk-ignore(checkov/CKV_SECRET_6) + } + } +} diff --git a/examples/complete/outputs.tf b/examples/complete/outputs.tf deleted file mode 100644 index f9d23f1..0000000 --- a/examples/complete/outputs.tf +++ /dev/null @@ -1 +0,0 @@ -# complete.tf diff --git a/examples/complete/providers.tf b/examples/complete/providers.tf new file mode 100644 index 0000000..75cf2ed --- /dev/null +++ b/examples/complete/providers.tf @@ -0,0 +1,17 @@ +provider "googleworkspace" { + # use the 'my_customer' string, which is an alias that Google's API recognizes to reference your account's customerId. + # Custom Schemas on the user object will fail if the customer_id is set to your actual customer_id. + # For more details see: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get + customer_id = "my_customer" + + credentials = "/Users/my_user/Downloads/my-google-project-credentials-1234567890.json" + impersonated_user_email = "my_impersonated_user_email@my_domain.com" + + oauth_scopes = [ + "https://www.googleapis.com/auth/admin.directory.group", + "https://www.googleapis.com/auth/admin.directory.user", + "https://www.googleapis.com/auth/admin.directory.userschema", + "https://www.googleapis.com/auth/apps.groups.settings", + "https://www.googleapis.com/auth/iam", + ] +} diff --git a/examples/complete/variables.tf b/examples/complete/variables.tf deleted file mode 100644 index f9d23f1..0000000 --- a/examples/complete/variables.tf +++ /dev/null @@ -1 +0,0 @@ -# complete.tf diff --git a/examples/complete/versions.tf b/examples/complete/versions.tf new file mode 100644 index 0000000..4cd87c3 --- /dev/null +++ b/examples/complete/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + googleworkspace = { + source = "hashicorp/googleworkspace" + version = "0.7.0" + } + } +} diff --git a/main.tf b/main.tf index 74e76fd..c4faf0d 100644 --- a/main.tf +++ b/main.tf @@ -1,3 +1,43 @@ -resource "random_pet" "template" { - length = var.length +resource "googleworkspace_user" "users" { + # https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/user + for_each = var.users + + aliases = each.value.aliases + change_password_at_next_login = each.value.change_password_at_next_login + hash_function = each.value.hash_function + include_in_global_address_list = each.value.include_in_global_address_list + ip_allowlist = each.value.ip_allowlist + is_admin = each.value.is_admin + name { + family_name = each.value.family_name + given_name = each.value.given_name + } + org_unit_path = each.value.org_unit_path + password = each.value.password == null ? null : each.value.password + primary_email = each.value.primary_email + recovery_email = each.value.recovery_email + recovery_phone = each.value.recovery_phone + suspended = each.value.suspended + + lifecycle { + ignore_changes = [ + password, + recovery_email, + recovery_phone, + suspended, + ] + } +} + +resource "googleworkspace_group" "groups" { + for_each = var.groups + + email = each.value.email + description = each.value.description + name = each.value.name + + timeouts { + create = each.value.timeouts.create + update = each.value.timeouts.update + } } diff --git a/outputs.tf b/outputs.tf index c44df14..8b13789 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,4 +1 @@ -output "random_pet_name" { - description = "The generated random pet name" - value = random_pet.template.id -} + diff --git a/tests/variables_groups.tftest.hcl b/tests/variables_groups.tftest.hcl new file mode 100644 index 0000000..1ca8e1f --- /dev/null +++ b/tests/variables_groups.tftest.hcl @@ -0,0 +1,43 @@ +mock_provider "googleworkspace" { + alias = "mock" +} + +# ----------------------------------------------------------------------------- +# --- validate email address +# ----------------------------------------------------------------------------- + +run "email_success" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + groups = { + "team1@example.com" = { + email = "team1@example.com" + name = "Team 1" + } + } + } +} + +run "email_invalid_missing_domain" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + groups = { + "invalid.email@example." = { + email = "invalid.email@example." + name = "Team 1" + }, + } + } + + expect_failures = [var.groups] +} diff --git a/tests/variables_users.tftest.hcl b/tests/variables_users.tftest.hcl new file mode 100644 index 0000000..411f6c6 --- /dev/null +++ b/tests/variables_users.tftest.hcl @@ -0,0 +1,183 @@ +mock_provider "googleworkspace" { + alias = "mock" +} + +# ----------------------------------------------------------------------------- +# --- validate email address +# ----------------------------------------------------------------------------- + +run "email_success" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + } + } + } +} + +run "email_invalid_missing_at_symbol" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "invalid.email" = { + primary_email = "invalid.email" + family_name = "Last" + given_name = "First" + }, + } + } + + expect_failures = [var.users] +} + + +# ----------------------------------------------------------------------------- +# --- validate password +# ----------------------------------------------------------------------------- + +run "password_success" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + password = "password" + hash_function = "MD5" + } + } + } +} + +run "password_too_short" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "first.last" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + password = "short" + hash_function = "MD5" + }, + } + } + + expect_failures = [var.users] +} + + +run "password_too_long" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + password = "------------------------------------------ more than 100 characters ------------------------------------------ " + hash_function = "MD5" + }, + } + } + + expect_failures = [var.users] +} + +# ----------------------------------------------------------------------------- +# --- validate hash function +# ----------------------------------------------------------------------------- + +run "hash_function_md5_success" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + password = "password123" + hash_function = "MD5" + } + } + } +} + +run "hash_function_invalid" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + password = "password123" + hash_function = "INVALID-HASH" # Invalid hash function + } + } + } + + expect_failures = [var.users] +} + +run "hash_function_can_be_null_with_password_set" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + password = "password123" + hash_function = null + } + } + } + + expect_failures = [var.users] +} diff --git a/variables.tf b/variables.tf index 6348a57..8a82192 100644 --- a/variables.tf +++ b/variables.tf @@ -1,5 +1,101 @@ -variable "length" { - description = "The length of the random name" - type = number - default = 2 +variable "users" { + # Optional values are set by provider defaults (except with array values) + # https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/user + + description = "List of users" + type = map(object({ + # addresses + aliases : optional(list(string), []), + archived : optional(bool, false), + change_password_at_next_login : optional(bool), + # custom_schemas + # emails + # external_ids + family_name : string, + given_name : string, + groups : optional(list(object({ + group_id : string, # The value can be the group's email address, group alias, or the unique group ID. + delivery_settings : optional(string, "ALL_MAIL"), + role : optional(string, "MEMBER"), + type : optional(string, "USER"), + }))), + # ims + include_in_global_address_list : optional(bool), + ip_allowlist : optional(bool), + is_admin : optional(bool), + # keywords + # languages + # locations + org_unit_path : optional(string), + # organizations + # phones + # posix_accounts + primary_email : string, + recovery_email : optional(string), + recovery_phone : optional(string), + # relations + # ssh_public_keys + suspended : optional(bool), + # timeouts + # websites + + # User attributes with unique constraints + + # password and hash_function + # If a hashFunction is specified, the password must be a valid hash key. + # If it's not specified, the password should be in clear text and between + # 8–100 ASCII characters. + # https://developers.google.com/workspace/admin/directory/v1/guides/manage-users + hash_function : optional(string), + password : optional(string), + })) + default = {} + validation { + condition = alltrue(flatten([ + for user in var.users : [can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", user.primary_email))] + ])) + error_message = "Invalid primary email address" + } + + validation { + condition = alltrue(flatten([ + for user in var.users : [ + user.password == null ? true : length(user.password) >= 8 && length(user.password) <= 100 + ] + ])) + error_message = "Password must be between 8 and 100 characters when provided" + } + + validation { + condition = alltrue([ + for user in var.users : + user.password == null || (user.hash_function == "SHA-1" || user.hash_function == "MD5" || user.hash_function == "crypt") + ]) + error_message = "hash_function must be either 'SHA-1', 'MD5', or 'crypt' when password is provided" + } +} + +variable "groups" { + # https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group + description = "List of groups" + type = map(object({ + name : string, + description : optional(string), + email : string, + timeouts : optional(object({ + create : optional(string), + update : optional(string), + }), { + create = null + update = null + }), + })) + default = {} + + validation { + condition = alltrue(flatten([ + for group in var.groups : [can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", group.email))] + ])) + error_message = "Invalid group email address" + } } diff --git a/versions.tf b/versions.tf index 0cf661c..483b638 100644 --- a/versions.tf +++ b/versions.tf @@ -2,9 +2,9 @@ terraform { required_version = ">= 1.0" required_providers { - random = { - source = "hashicorp/random" - version = ">= 3.0" + googleworkspace = { + source = "hashicorp/googleworkspace" + version = ">= 0.7.0" } } } From 64cbc7c00f8bdb9a497954d6f58d721dc645b6a5 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Tue, 6 May 2025 12:02:32 -0600 Subject: [PATCH 3/9] feat(INT-53): add user to group membership (#3) ## what - add group settings, `googleworkspace_group_settings` - enable users to be members of groups, `googleworkspace_group_member` ## why ## references - [INT-53](https://www.notion.so/masterpoint/Managing-GSuite-Users-via-IaC-1d0859758a568029b956f2ab8c9a2651) ## Summary by CodeRabbit - **New Features** - Introduced comprehensive support for managing Google Workspace users, groups, group settings, and group memberships via new input variables and resources. - Added detailed input validation for user and group attributes, including email formats, password requirements, and group roles. - Provided example configurations and provider setup for Google Workspace automation. - **Documentation** - Updated README with complete usage instructions, authentication methods, input variable schemas, and example usage. - Replaced all template references with Google Workspace-specific documentation. - **Tests** - Added extensive test cases for user and group variable validation, covering email, password, hash function, and group role scenarios. - **Chores** - Updated provider requirements to use the Google Workspace provider. - Cleaned up and reorganized example files and removed obsolete outputs and changelog content. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 4 + README.md | 10 +- examples/complete/main.tf | 46 +++++++++ examples/complete/providers.tf | 6 +- main.tf | 83 ++++++++++++++- tests/variables_groups.tftest.hcl | 46 +++++++++ tests/variables_users.tftest.hcl | 163 +++++++++++++++++++++++++++++- variables.tf | 77 ++++++++++++-- 8 files changed, 418 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 8bb7960..1f4593a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ examples/**/*.terraform.lock.hcl .terraform.lock.hcl # !examples/**/.terraform.lock.hcl +# Ignore the live-providers.tf file +examples/**/live-provider.tf + # IDE/Editor settings **/.idea **/*.iml @@ -49,3 +52,4 @@ backend.tf.json **/.DS_Store + diff --git a/README.md b/README.md index 024f58d..4336389 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,10 @@ module "googleworkspace" { | Name | Type | |------|------| -| [googleworkspace_group.groups](https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group) | resource | -| [googleworkspace_user.users](https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/user) | resource | +| [googleworkspace_group.defaults](https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group) | resource | +| [googleworkspace_group_member.user_to_groups](https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group_member) | resource | +| [googleworkspace_group_settings.defaults](https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group_settings) | resource | +| [googleworkspace_user.defaults](https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/user) | resource | ## Inputs @@ -103,7 +105,7 @@ module "googleworkspace" { | [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | no | -| [groups](#input\_groups) | List of groups |
map(object({
name : string,
description : optional(string),
email : string,
timeouts : optional(object({
create : optional(string),
update : optional(string),
}), {
create = null
update = null
}),
}))
| `{}` | no | +| [groups](#input\_groups) | List of groups |
map(object({
name : string,
description : optional(string),
email : string,
timeouts : optional(object({
create : optional(string),
update : optional(string),
}), {
create = null
update = null
}),
# https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group_settings
settings : optional(object({
allow_external_members : optional(bool),
allow_web_posting : optional(bool),
archive_only : optional(bool),
custom_footer_text : optional(string),
custom_reply_to : optional(string),
default_message_deny_notification_text : optional(string),
enable_collaborative_inbox : optional(bool),
include_custom_footer : optional(bool),
include_in_global_address_list : optional(bool),
is_archived : optional(bool),
members_can_post_as_the_group : optional(bool),
message_moderation_level : optional(string),
primary_language : optional(string),
reply_to : optional(string),
send_message_deny_notification : optional(bool),
spam_moderation_level : optional(string),
who_can_assist_content : optional(string),
who_can_contact_owner : optional(string),
who_can_discover_group : optional(string),
who_can_join : optional(string),
who_can_leave_group : optional(string),
who_can_moderate_content : optional(string),
who_can_moderate_members : optional(string),
who_can_post_message : optional(string),
who_can_view_group : optional(string),
who_can_view_membership : optional(string),
}), {}),
}))
| `{}` | no | | [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | | [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | | [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | @@ -115,7 +117,7 @@ module "googleworkspace" { | [stage](#input\_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | | [tenant](#input\_tenant) | ID element \_(Rarely used, not included by default)\_. A customer identifier, indicating who this instance of a resource is for | `string` | `null` | no | -| [users](#input\_users) | List of users |
map(object({
# addresses
aliases : optional(list(string), []),
archived : optional(bool, false),
change_password_at_next_login : optional(bool),
# custom_schemas
# emails
# external_ids
family_name : string,
given_name : string,
groups : optional(list(object({
group_id : string, # The value can be the group's email address, group alias, or the unique group ID.
delivery_settings : optional(string, "ALL_MAIL"),
role : optional(string, "MEMBER"),
type : optional(string, "USER"),
}))),
# ims
include_in_global_address_list : optional(bool),
ip_allowlist : optional(bool),
is_admin : optional(bool),
# keywords
# languages
# locations
org_unit_path : optional(string),
# organizations
# phones
# posix_accounts
primary_email : string,
recovery_email : optional(string),
recovery_phone : optional(string),
# relations
# ssh_public_keys
suspended : optional(bool),
# timeouts
# websites

# User attributes with unique constraints

# password and hash_function
# If a hashFunction is specified, the password must be a valid hash key.
# If it's not specified, the password should be in clear text and between
# 8–100 ASCII characters.
# https://developers.google.com/workspace/admin/directory/v1/guides/manage-users
hash_function : optional(string),
password : optional(string),
}))
| `{}` | no | +| [users](#input\_users) | List of users |
map(object({
# addresses
aliases : optional(list(string), []),
archived : optional(bool, false),
change_password_at_next_login : optional(bool),
# custom_schemas
# emails
# external_ids
family_name : string,
given_name : string,
groups : optional(map(object({
role : optional(string, "MEMBER"),
delivery_settings : optional(string, "ALL_MAIL"),
type : optional(string, "USER"),
})), {}),
# ims
include_in_global_address_list : optional(bool),
ip_allowlist : optional(bool),
is_admin : optional(bool),
# keywords
# languages
# locations
org_unit_path : optional(string),
# organizations
# phones
# posix_accounts
primary_email : string,
recovery_email : optional(string),
recovery_phone : optional(string),
# relations
# ssh_public_keys
suspended : optional(bool),
# timeouts
# websites

# User attributes with unique constraints

# password and hash_function
# If a hashFunction is specified, the password must be a valid hash key.
# If it's not specified, the password should be in clear text and between
# 8–100 ASCII characters.
# https://developers.google.com/workspace/admin/directory/v1/guides/manage-users
hash_function : optional(string),
password : optional(string),
}))
| `{}` | no | ## Outputs diff --git a/examples/complete/main.tf b/examples/complete/main.tf index eaf5710..da52d69 100644 --- a/examples/complete/main.tf +++ b/examples/complete/main.tf @@ -1,3 +1,20 @@ +locals { + default_group_settings = { + allow_external_members = false + allow_web_posting = true + archive_only = false + custom_roles_enabled_for_settings_to_be_merged = false + enable_collaborative_inbox = false + is_archived = false + primary_language = "en_US" + who_can_join = "ALL_IN_DOMAIN_CAN_JOIN" + who_can_assist_content = "OWNERS_AND_MANAGERS" + who_can_view_group = "ALL_IN_DOMAIN_CAN_VIEW" + who_can_view_membership = "ALL_IN_DOMAIN_CAN_VIEW" + } +} + + module "googleworkspace" { source = "../../" @@ -7,6 +24,35 @@ module "googleworkspace" { family_name = "Last" given_name = "First" password = "insecure-password-for-example" # trunk-ignore(checkov/CKV_SECRET_6) + groups = { + "platform" = { + role = "MEMBER" + } + } + } + } + + groups = { + "support" = { + name = "Support" + email = "support@example.com" + settings = merge(local.default_group_settings, { + enable_collaborative_inbox = true, + }) + }, + "platform" = { + name = "Platform" + email = "platform@example.com" + settings = merge(local.default_group_settings, {}) + }, + "engineers" = { + name = "Engineering" + email = "engineering@example.com" + settings = merge(local.default_group_settings, { + who_can_join = "INVITED_CAN_JOIN", + who_can_view_group = "ALL_MEMBERS_CAN_VIEW", + who_can_view_membership = "ALL_MEMBERS_CAN_VIEW", + }) } } } diff --git a/examples/complete/providers.tf b/examples/complete/providers.tf index 75cf2ed..e8204da 100644 --- a/examples/complete/providers.tf +++ b/examples/complete/providers.tf @@ -1,7 +1,7 @@ provider "googleworkspace" { - # use the 'my_customer' string, which is an alias that Google's API recognizes to reference your account's customerId. - # Custom Schemas on the user object will fail if the customer_id is set to your actual customer_id. - # For more details see: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get + # # use the 'my_customer' string, which is an alias that Google's API recognizes to reference your account's customerId. + # # Custom Schemas on the user object will fail if the customer_id is set to your actual customer_id. + # # For more details see: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get customer_id = "my_customer" credentials = "/Users/my_user/Downloads/my-google-project-credentials-1234567890.json" diff --git a/main.tf b/main.tf index c4faf0d..1e8d7b1 100644 --- a/main.tf +++ b/main.tf @@ -1,4 +1,27 @@ -resource "googleworkspace_user" "users" { +locals { + group_settings = { + for k, v in var.groups : k => merge(v.settings, { email = v.email }) + } + + _user_with_groups = { + for user_key, user in var.users : user_key => user + if lookup(user, "groups", null) != null + } + + # flatten the group_members_objects (map of maps) into a single list of objects + group_members = { + for obj in flatten([ + for user_key, user in local._user_with_groups : [ + for group_key, group in user.groups : merge(group, { + user_primary_email = user.primary_email, + group_email = googleworkspace_group.defaults[group_key].email + }) + ] + ]) : "${obj.group_email}/${obj.user_primary_email}" => obj + } +} + +resource "googleworkspace_user" "defaults" { # https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/user for_each = var.users @@ -21,15 +44,18 @@ resource "googleworkspace_user" "users" { lifecycle { ignore_changes = [ + languages, password, recovery_email, recovery_phone, suspended, ] } + + depends_on = [googleworkspace_group.defaults] } -resource "googleworkspace_group" "groups" { +resource "googleworkspace_group" "defaults" { for_each = var.groups email = each.value.email @@ -41,3 +67,56 @@ resource "googleworkspace_group" "groups" { update = each.value.timeouts.update } } + +resource "googleworkspace_group_settings" "defaults" { + # https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group_settings + for_each = local.group_settings + + allow_external_members = each.value.allow_external_members + allow_web_posting = each.value.allow_web_posting + archive_only = each.value.archive_only + custom_footer_text = each.value.custom_footer_text + custom_reply_to = each.value.custom_reply_to + default_message_deny_notification_text = each.value.default_message_deny_notification_text + email = each.value.email + enable_collaborative_inbox = each.value.enable_collaborative_inbox + include_custom_footer = each.value.include_custom_footer + include_in_global_address_list = each.value.include_in_global_address_list + is_archived = each.value.is_archived + members_can_post_as_the_group = each.value.members_can_post_as_the_group + message_moderation_level = each.value.message_moderation_level + primary_language = each.value.primary_language + reply_to = each.value.reply_to + send_message_deny_notification = each.value.send_message_deny_notification + spam_moderation_level = each.value.spam_moderation_level + who_can_assist_content = each.value.who_can_assist_content + who_can_contact_owner = each.value.who_can_contact_owner + who_can_discover_group = each.value.who_can_discover_group + who_can_join = each.value.who_can_join + who_can_leave_group = each.value.who_can_leave_group + who_can_moderate_content = each.value.who_can_moderate_content + who_can_moderate_members = each.value.who_can_moderate_members + who_can_post_message = each.value.who_can_post_message + who_can_view_group = each.value.who_can_view_group + who_can_view_membership = each.value.who_can_view_membership + + depends_on = [googleworkspace_group.defaults] +} + +resource "googleworkspace_group_member" "user_to_groups" { + # https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group_member + for_each = local.group_members + + group_id = each.value.group_email + email = each.value.user_primary_email + role = upper(each.value.role) + type = upper(each.value.type) + + lifecycle { + ignore_changes = [ + delivery_settings, # ignore user changes to delivery settings + ] + } + + depends_on = [googleworkspace_group.defaults, googleworkspace_user.defaults] +} diff --git a/tests/variables_groups.tftest.hcl b/tests/variables_groups.tftest.hcl index 1ca8e1f..6ee4f8c 100644 --- a/tests/variables_groups.tftest.hcl +++ b/tests/variables_groups.tftest.hcl @@ -41,3 +41,49 @@ run "email_invalid_missing_domain" { expect_failures = [var.groups] } + +# ----------------------------------------------------------------------------- +# --- validate group settings +# ----------------------------------------------------------------------------- + +run "group_settings_specific_values" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + groups = { + "settings-test-group@example.com" = { + email = "settings-test-group@example.com" + name = "Settings Test Group" + settings = { + allow_external_members = false + who_can_join = "INVITED_CAN_JOIN" + enable_collaborative_inbox = true + } + } + } + } + # We expect this plan to succeed as the structure is valid. +} + +run "group_settings_no_settings_block" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + groups = { + "no-settings-test-group@example.com" = { + email = "no-settings-test-group@example.com" + name = "No Settings Test Group" + # Settings block is completely omitted, should use default {} and provider defaults + } + } + } + # We expect this plan to succeed. +} diff --git a/tests/variables_users.tftest.hcl b/tests/variables_users.tftest.hcl index 411f6c6..9c61427 100644 --- a/tests/variables_users.tftest.hcl +++ b/tests/variables_users.tftest.hcl @@ -91,7 +91,6 @@ run "password_too_short" { expect_failures = [var.users] } - run "password_too_long" { command = plan @@ -152,7 +151,7 @@ run "hash_function_invalid" { family_name = "Last" given_name = "First" password = "password123" - hash_function = "INVALID-HASH" # Invalid hash function + hash_function = "INVALID-HASH" # intentionally invalid hash function } } } @@ -178,6 +177,166 @@ run "hash_function_can_be_null_with_password_set" { } } } +} + + +# ----------------------------------------------------------------------------- +# --- validate groups +# ----------------------------------------------------------------------------- + +run "groups_member_role_success" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + groups = { + "team" = { + role = "MEMBER" + } + } + } + } + groups = { + "team" = { + name = "Team" + email = "team@example.com" + } + } + } +} + +run "groups_member_role_invalid" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "first.last@example.com" = { + primary_email = "first.last@example.com" + family_name = "Last" + given_name = "First" + groups = { + "team" = { + role = "INVALID-ROLE" + } + } + } + } + groups = { + "team" = { + name = "Team" + email = "team@example.com" + } + } + } + + expect_failures = [var.users] +} + +# ----------------------------------------------------------------------------- +# --- validate group member type +# ----------------------------------------------------------------------------- + +run "group_member_type_user_success" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "user.type@example.com" = { + primary_email = "user.type@example.com" + family_name = "Type" + given_name = "User" + groups = { + "test-group" = { + role = "MEMBER" + type = "USER" + } + } + } + } + groups = { + "test-group" = { + name = "Test Group" + email = "test-group@example.com" + } + } + } +} + +run "group_member_type_default_success" { + # Type defaults to USER if omitted or null + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "default.type@example.com" = { + primary_email = "default.type@example.com" + family_name = "Type" + given_name = "Default" + groups = { + "test-group" = { + role = "MEMBER" + # type is omitted, should default and pass validation + } + } + } + } + groups = { + "test-group" = { + name = "Test Group" + email = "test-group@example.com" + } + } + } +} + +run "group_member_type_invalid" { + command = plan + + providers = { + googleworkspace = googleworkspace.mock + } + + variables { + users = { + "invalid.type@example.com" = { + primary_email = "invalid.type@example.com" + family_name = "Type" + given_name = "Invalid" + groups = { + "test-group" = { + role = "MEMBER" + type = "INVALID-TYPE" + } + } + } + } + groups = { + "test-group" = { + name = "Test Group" + email = "test-group@example.com" + } + } + } expect_failures = [var.users] } diff --git a/variables.tf b/variables.tf index 8a82192..4fa9b94 100644 --- a/variables.tf +++ b/variables.tf @@ -13,12 +13,11 @@ variable "users" { # external_ids family_name : string, given_name : string, - groups : optional(list(object({ - group_id : string, # The value can be the group's email address, group alias, or the unique group ID. - delivery_settings : optional(string, "ALL_MAIL"), + groups : optional(map(object({ role : optional(string, "MEMBER"), + delivery_settings : optional(string, "ALL_MAIL"), type : optional(string, "USER"), - }))), + })), {}), # ims include_in_global_address_list : optional(bool), ip_allowlist : optional(bool), @@ -50,6 +49,8 @@ variable "users" { password : optional(string), })) default = {} + + # validate primary email address validation { condition = alltrue(flatten([ for user in var.users : [can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", user.primary_email))] @@ -57,6 +58,7 @@ variable "users" { error_message = "Invalid primary email address" } + # validate password length validation { condition = alltrue(flatten([ for user in var.users : [ @@ -66,12 +68,46 @@ variable "users" { error_message = "Password must be between 8 and 100 characters when provided" } + # validate hash function validation { condition = alltrue([ for user in var.users : - user.password == null || (user.hash_function == "SHA-1" || user.hash_function == "MD5" || user.hash_function == "crypt") + user.password == null || ( + user.password != null && ( + user.hash_function == "SHA-1" || + user.hash_function == "MD5" || + user.hash_function == "crypt" || + user.hash_function == null + ) + ) ]) - error_message = "hash_function must be either 'SHA-1', 'MD5', or 'crypt' when password is provided" + error_message = "hash_function must be either 'SHA-1', 'MD5', 'crypt', or null when password is provided" + } + + # validate group role + validation { + condition = alltrue(flatten([ + for user in var.users : [ + for group in values(try(user.groups, {})) : ( + upper(group.role) == "MEMBER" || upper(group.role) == "OWNER" || upper(group.role) == "MANAGER" + ) + ] + ])) + error_message = "group role must be either 'member', 'owner', or 'manager'" + } + + # validate group member type + validation { + condition = alltrue(flatten([ + for user in var.users : [ + for group in values(try(user.groups, {})) : ( + # # Check if type is null (default) or one of the allowed values + # group.type == null ? true : (upper(group.type) == "USER" || upper(group.type) == "GROUP" || upper(group.type) == "CUSTOMER") + group.type == null || contains(["USER", "GROUP", "CUSTOMER"], upper(group.type)) + ) + ] + ])) + error_message = "group type must be either 'USER', 'GROUP', or 'CUSTOMER'" } } @@ -89,6 +125,35 @@ variable "groups" { create = null update = null }), + # https://registry.terraform.io/providers/hashicorp/googleworkspace/latest/docs/resources/group_settings + settings : optional(object({ + allow_external_members : optional(bool), + allow_web_posting : optional(bool), + archive_only : optional(bool), + custom_footer_text : optional(string), + custom_reply_to : optional(string), + default_message_deny_notification_text : optional(string), + enable_collaborative_inbox : optional(bool), + include_custom_footer : optional(bool), + include_in_global_address_list : optional(bool), + is_archived : optional(bool), + members_can_post_as_the_group : optional(bool), + message_moderation_level : optional(string), + primary_language : optional(string), + reply_to : optional(string), + send_message_deny_notification : optional(bool), + spam_moderation_level : optional(string), + who_can_assist_content : optional(string), + who_can_contact_owner : optional(string), + who_can_discover_group : optional(string), + who_can_join : optional(string), + who_can_leave_group : optional(string), + who_can_moderate_content : optional(string), + who_can_moderate_members : optional(string), + who_can_post_message : optional(string), + who_can_view_group : optional(string), + who_can_view_membership : optional(string), + }), {}), })) default = {} From 212f46004d0f864511ad67b6163cb4724e22a0c1 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Wed, 7 May 2025 15:43:20 -0600 Subject: [PATCH 4/9] feat(INT-53): add example of importing existing users+groups (#4) ## what - New Example: added `examples/import-existing-org` showing the nuances of importing `group_settings` and user to member group relationships. ## why - Provider better examples ## references ## Summary by CodeRabbit - **Documentation** - Updated the README example to demonstrate managing both users and groups, including group membership roles. - **New Features** - Added comprehensive example configurations for importing existing Google Workspace users and groups using YAML and Terraform. - Introduced sample YAML files for defining users and groups with reusable templates and settings. - Provided Terraform files for provider setup, version constraints, and import logic for existing organization data. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 17 ++++++- examples/import-existing-org/groups.yaml | 21 ++++++++ examples/import-existing-org/imports.tf | 61 +++++++++++++++++++++++ examples/import-existing-org/main.tf | 16 ++++++ examples/import-existing-org/providers.tf | 17 +++++++ examples/import-existing-org/users.yaml | 11 ++++ examples/import-existing-org/versions.tf | 10 ++++ 7 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 examples/import-existing-org/groups.yaml create mode 100644 examples/import-existing-org/imports.tf create mode 100644 examples/import-existing-org/main.tf create mode 100644 examples/import-existing-org/providers.tf create mode 100644 examples/import-existing-org/users.yaml create mode 100644 examples/import-existing-org/versions.tf diff --git a/README.md b/README.md index 4336389..582add5 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ provider "googleworkspace" { ## Example ```hcl -module "googleworkspace" { +module "googleworkspace_users_groups" { source = "git::https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation.git" users = { @@ -58,6 +58,21 @@ module "googleworkspace" { family_name = "Last" given_name = "First" password = "example-password" + groups = { + "platform" = { + role = "member" + } + } + } + } + + groups = { + "platform" = { + name = "Platform" + email = "platform@example.com" + settings = { + who_can_join = "ALL_IN_DOMAIN_CAN_JOIN" + } } } } diff --git a/examples/import-existing-org/groups.yaml b/examples/import-existing-org/groups.yaml new file mode 100644 index 0000000..d5e122c --- /dev/null +++ b/examples/import-existing-org/groups.yaml @@ -0,0 +1,21 @@ +--- +_default_active_settings: &default_active_settings + allow_external_members: false + allow_web_posting: true + archive_only: false + custom_roles_enabled_for_settings_to_be_merged: false + enable_collaborative_inbox: false + is_archived: false + primary_language: en_US + who_can_join: ALL_IN_DOMAIN_CAN_JOIN + who_can_assist_content: OWNERS_AND_MANAGERS + who_can_view_group: ALL_IN_DOMAIN_CAN_VIEW + who_can_view_membership: ALL_IN_DOMAIN_CAN_VIEW + +team: + email: engineering@example.com + name: Engineering Team + description: Engineering Team that all technical employees are members of by default + is_admin: false + settings: + <<: *default_active_settings diff --git a/examples/import-existing-org/imports.tf b/examples/import-existing-org/imports.tf new file mode 100644 index 0000000..be55ce8 --- /dev/null +++ b/examples/import-existing-org/imports.tf @@ -0,0 +1,61 @@ +locals { + # Locals specific to imports + + # While the terraform-googleworkspace-users-groups-autogen module nests + # group settings in the group object, we need to access the group + # settings to import them into the provider's tf "group_settings" resource + group_settings = { for k, v in local._all_groups : k => merge(v.settings, { email = v.email }) if !startswith(k, "_") } + + # _user_with_groups is an intermediate object that is used to flatten + # the group_members_objects (map of maps) into a single list of objects + _user_with_groups = { + for user_key, user in local.users : user_key => user + if lookup(user, "groups", null) != null + } + + # flatten the group_members_objects (map of maps) into a single list of objects + group_members = { + for obj in flatten([ + for user_key, user in local._user_with_groups : [ + for group_key, group in user.groups : merge(group, { + user_primary_email = local.users[user_key].primary_email, + group_email = local.groups[group_key].email, + key = "${local.groups[group_key].email}/${local.users[user_key].primary_email}" + id = "groups/${local.groups[group_key].email}/members/${local.users[user_key].primary_email}" + }) + ] + ]) : obj.key => obj + } +} + +import { + for_each = local.users + to = module.googleworkspace_users_groups.googleworkspace_user.defaults[each.value.primary_email] + id = each.value.primary_email +} + +import { + for_each = local.groups + to = module.googleworkspace_users_groups.googleworkspace_group.defaults[each.key] + id = each.value.email +} + +import { + for_each = local.group_settings + to = module.googleworkspace_users_groups.googleworkspace_group_settings.defaults[each.key] + id = each.value.email +} + +import { + for_each = local.group_members + to = module.googleworkspace_users_groups.googleworkspace_group_member.user_to_groups[each.key] + + # The import id can take two formats, + # - "groups/{group_email}/members/{user_email}" + # - "groups/{group_id}/members/{user_id}", where group_id and user_id are + # large integers. + # + # We've chosen to use the "group_email" and "user_email" format to make the + # code and resources easier to work with. + id = each.value.id +} diff --git a/examples/import-existing-org/main.tf b/examples/import-existing-org/main.tf new file mode 100644 index 0000000..9c2fd28 --- /dev/null +++ b/examples/import-existing-org/main.tf @@ -0,0 +1,16 @@ +locals { + _all_groups = yamldecode(file("groups.yaml")) + _all_users = yamldecode(file("users.yaml")) + + # skip objects that start with "_", which we use as default or prototype objects + groups = { for k, v in local._all_groups : k => v if !startswith(k, "_") } + users = { for k, v in local._all_users : k => v if !startswith(k, "_") } +} + +module "googleworkspace_users_groups" { + source = "../../" + + users = local.users + groups = local.groups +} + diff --git a/examples/import-existing-org/providers.tf b/examples/import-existing-org/providers.tf new file mode 100644 index 0000000..75cf2ed --- /dev/null +++ b/examples/import-existing-org/providers.tf @@ -0,0 +1,17 @@ +provider "googleworkspace" { + # use the 'my_customer' string, which is an alias that Google's API recognizes to reference your account's customerId. + # Custom Schemas on the user object will fail if the customer_id is set to your actual customer_id. + # For more details see: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get + customer_id = "my_customer" + + credentials = "/Users/my_user/Downloads/my-google-project-credentials-1234567890.json" + impersonated_user_email = "my_impersonated_user_email@my_domain.com" + + oauth_scopes = [ + "https://www.googleapis.com/auth/admin.directory.group", + "https://www.googleapis.com/auth/admin.directory.user", + "https://www.googleapis.com/auth/admin.directory.userschema", + "https://www.googleapis.com/auth/apps.groups.settings", + "https://www.googleapis.com/auth/iam", + ] +} diff --git a/examples/import-existing-org/users.yaml b/examples/import-existing-org/users.yaml new file mode 100644 index 0000000..2e39526 --- /dev/null +++ b/examples/import-existing-org/users.yaml @@ -0,0 +1,11 @@ +--- +_default_user: &default_user + is_admin: false + groups: + team: { role: member } + +first.last@example.com: + <<: *default_user + primary_email: first.last@example.com + family_name: Last + given_name: First diff --git a/examples/import-existing-org/versions.tf b/examples/import-existing-org/versions.tf new file mode 100644 index 0000000..4cd87c3 --- /dev/null +++ b/examples/import-existing-org/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + googleworkspace = { + source = "hashicorp/googleworkspace" + version = "0.7.0" + } + } +} From 862d87897ec53cd027c39391a350f203eaf1c422 Mon Sep 17 00:00:00 2001 From: WestonPlatter Date: Mon, 12 May 2025 11:24:14 -0600 Subject: [PATCH 5/9] fix: update links to repo and pull in edits from https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation/pull/5 --- .github/workflows/test.yaml | 2 +- README.md | 35 +++++++++++++++++------------------ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 056a582..1574792 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -25,4 +25,4 @@ jobs: with: tf_type: ${{ matrix.tf }} aws_role_arn: ${{ vars.TF_TEST_AWS_ROLE_ARN }} - github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 582add5..283e60b 100644 --- a/README.md +++ b/README.md @@ -8,29 +8,28 @@ ## Purpose and Functionality -This is a [child-module](https://opentofu.org/docs/language/modules/#child-modules) for managing Google Workspace users, groups, and roles. +Use this [child module](https://opentofu.org/docs/language/modules/#child-modules) to manage Google Workspace users, groups, and roles. + +If you want to use this module with an existing Google Workspace, see the [import-existing-org](examples/import-existing-org) example, which demonstrates how to import your existing Google users and groups. ## Usage ### Step-by-Step Instructions -There are 2 provider authentication routes available, -1 - authenticate a service account via API keys -2 - authenticate using API keys and impersonate a real User with Super Admin privileges. - -We recommend impersonating a Super Admin, which allows you to grant Admin privileges to users (service Accounts cannot do this). +There are two provider authentication methods available: -Follow the provider [authentication setup instructions](https://github.com/hashicorp/terraform-provider-googleworkspace/blob/main/docs/index.md#google-workspace-provider). +1. Authenticate a Google Cloud service account via API keys. +2. Authenticate a Google Cloud service account via API keys and impersonate a real user with Super Admin privileges. - +We recommend method (2), impersonating a Super Admin, as this allows you to grant Admin privileges to users (service accounts cannot do this). To set this up, follow the [Domain-Wide Delegation authentication instructions](https://github.com/hashicorp/terraform-provider-googleworkspace/blob/main/docs/index.md#using-domain-wide-delegation). -Once you've finished the setup process, your provider block should look like this, +Once you've completed the setup process, your provider block should look like this: ```hcl provider "googleworkspace" { - # use 'my_customer', which is an alias that Google's API recognizes to reference your account's customerId. - # For example - Custom Schemas on the user object will fail if the customer_id is set to your actual customer_id. - # For more details see: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get + # Use 'my_customer', which is an alias recognized by Google's API to reference your account's customerId. + # For example, custom schemas on the user object will fail if the customer_id is set to your actual customer_id. + # For more details, see: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get customer_id = "my_customer" credentials = "/path/to/credentials/my-google-project-credentials-1234567890.json" @@ -50,7 +49,7 @@ provider "googleworkspace" { ```hcl module "googleworkspace_users_groups" { - source = "git::https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation.git" + source = "git::https://github.com/masterpointio/terraform-users-groups-automation-googleworkspace.git" users = { "first.last@example.com" = { @@ -198,8 +197,8 @@ Copyright © 2016-2025 [Masterpoint Consulting LLC](https://masterpoint.io/) [newsletter-url]: https://newsletter.masterpoint.io/ [youtube-badge]: https://img.shields.io/badge/YouTube-Subscribe-D191BF?style=for-the-badge&logo=youtube&logoColor=white [youtube-url]: https://www.youtube.com/channel/UCeeDaO2NREVlPy9Plqx-9JQ -[release-badge]: https://img.shields.io/github/v/release/masterpointio/terraform-googleworkspace-users-groups-automation?color=0E383A&label=Release&style=for-the-badge&logo=github&logoColor=white -[latest-release]: https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation/releases/latest -[contributors-image]: https://contrib.rocks/image?repo=masterpointio/terraform-googleworkspace-users-groups-automation -[contributors-url]: https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation/graphs/contributors -[issues-url]: https://github.com/masterpointio/terraform-googleworkspace-users-groups-automation/issues +[release-badge]: https://img.shields.io/github/v/release/masterpointio/terraform-users-groups-automation-googleworkspace?color=0E383A&label=Release&style=for-the-badge&logo=github&logoColor=white +[latest-release]: https://github.com/masterpointio/terraform-users-groups-automation-googleworkspace/releases/latest +[contributors-image]: https://contrib.rocks/image?repo=masterpointio/terraform-users-groups-automation-googleworkspace +[contributors-url]: https://github.com/masterpointio/terraform-users-groups-automation-googleworkspace/graphs/contributors +[issues-url]: https://github.com/masterpointio/terraform-users-groups-automation-googleworkspace/issues From bdfacbe53b72639c7145a8099281a2e7cf733a1d Mon Sep 17 00:00:00 2001 From: WestonPlatter Date: Mon, 12 May 2025 11:34:59 -0600 Subject: [PATCH 6/9] use cursor + gh mcp to update based on feedback --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 283e60b..9e807d2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## Purpose and Functionality -Use this [child module](https://opentofu.org/docs/language/modules/#child-modules) to manage Google Workspace users, groups, and roles. +Use this [child module](https://www.terraform.io/docs/language/modules/#child-modules) to manage Google Workspace users, groups, and roles. If you want to use this module with an existing Google Workspace, see the [import-existing-org](examples/import-existing-org) example, which demonstrates how to import your existing Google users and groups. @@ -18,8 +18,8 @@ If you want to use this module with an existing Google Workspace, see the [impor There are two provider authentication methods available: -1. Authenticate a Google Cloud service account via API keys. -2. Authenticate a Google Cloud service account via API keys and impersonate a real user with Super Admin privileges. +1. Authenticate using a Google Cloud service account key file. +2. Authenticate using a Google Cloud service account key file and impersonate a real user with Super Admin privileges. We recommend method (2), impersonating a Super Admin, as this allows you to grant Admin privileges to users (service accounts cannot do this). To set this up, follow the [Domain-Wide Delegation authentication instructions](https://github.com/hashicorp/terraform-provider-googleworkspace/blob/main/docs/index.md#using-domain-wide-delegation). @@ -27,9 +27,8 @@ Once you've completed the setup process, your provider block should look like th ```hcl provider "googleworkspace" { - # Use 'my_customer', which is an alias recognized by Google's API to reference your account's customerId. - # For example, custom schemas on the user object will fail if the customer_id is set to your actual customer_id. - # For more details, see: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get + # Use 'my_customer' as an alias for your account's customerId to ensure compatibility with Google's API + # For details: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get customer_id = "my_customer" credentials = "/path/to/credentials/my-google-project-credentials-1234567890.json" From 577cc361955ae44c042213cf6994237707412020 Mon Sep 17 00:00:00 2001 From: WestonPlatter Date: Mon, 12 May 2025 11:38:38 -0600 Subject: [PATCH 7/9] docs: fix link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9e807d2..cd32cc9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## Purpose and Functionality -Use this [child module](https://www.terraform.io/docs/language/modules/#child-modules) to manage Google Workspace users, groups, and roles. +Use this [child module](https://opentofu.org/docs/language/modules/#child-modules) to manage Google Workspace users, groups, and roles. If you want to use this module with an existing Google Workspace, see the [import-existing-org](examples/import-existing-org) example, which demonstrates how to import your existing Google users and groups. From 6fafa33842d756b89c7d8afbc0e4ba56e121b953 Mon Sep 17 00:00:00 2001 From: WestonPlatter Date: Mon, 12 May 2025 11:39:18 -0600 Subject: [PATCH 8/9] docs: provide a why --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cd32cc9..89ff831 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Once you've completed the setup process, your provider block should look like th ```hcl provider "googleworkspace" { # Use 'my_customer' as an alias for your account's customerId to ensure compatibility with Google's API - # For details: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get + # For example, custom schemas on the user object will fail if the customer_id is set to your actual customer_id + # For more details: https://developers.google.com/workspace/admin/directory/reference/rest/v1/schemas/get customer_id = "my_customer" credentials = "/path/to/credentials/my-google-project-credentials-1234567890.json" From 7b291cf2b96be656c2368607183e93113ebfda6a Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Mon, 12 May 2025 11:55:18 -0600 Subject: [PATCH 9/9] docs: fix title to match repo name --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 89ff831..e1f53bf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Banner][banner-image]](https://masterpoint.io/) -# terraform-googleworkspace-users-groups-automation +# terraform-users-groups-automation-googleworkspace [![Release][release-badge]][latest-release]