Add support for group-based IAM to resource management modules (#229)

* group_iam support for organization

* group_iam support for folder

* fix typo in variable description

* add group_iam to project module

* update project module README
This commit is contained in:
Ludovico Magnocavallo 2021-04-11 14:48:16 +02:00 committed by GitHub
parent 7154e2cee6
commit f8413cc98e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 206 additions and 96 deletions

View File

@ -1,6 +1,6 @@
# Google Cloud Folder Module
This module allows the creation and management of folders together with their individual IAM bindings and organization policies.
This module allows the creation and management of folders, including support for IAM bindings, organization policies, and hierarchical firewall rules.
## Examples
@ -11,11 +11,14 @@ module "folder" {
source = "./modules/folder"
parent = "organizations/1234567890"
name = "Folder name"
group_iam = {
"cloud-owners@example.org" = ["roles/owner", "roles/projectCreator"]
}
iam = {
"roles/owner" = ["group:users@example.com"]
"roles/owner" = ["user:one@example.com"]
}
}
# tftest:modules=1:resources=2
# tftest:modules=1:resources=3
```
### Organization policies
@ -158,7 +161,6 @@ module "folder2" {
# tftest:modules=2:resources=6
```
<!-- BEGIN TFDOC -->
## Variables
@ -168,7 +170,8 @@ module "folder2" {
| *firewall_policies* | Hierarchical firewall policies to *create* in this folder. | <code title="map&#40;map&#40;object&#40;&#123;&#10;description &#61; string&#10;direction &#61; string&#10;action &#61; string&#10;priority &#61; number&#10;ranges &#61; list&#40;string&#41;&#10;ports &#61; map&#40;list&#40;string&#41;&#41;&#10;target_service_accounts &#61; list&#40;string&#41;&#10;target_resources &#61; list&#40;string&#41;&#10;logging &#61; bool&#10;&#125;&#41;&#41;&#41;">map(map(object({...})))</code> | | <code title="">{}</code> |
| *firewall_policy_attachments* | List of hierarchical firewall policy IDs to *attach* to this folder. | <code title="map&#40;string&#41;">map(string)</code> | | <code title="">{}</code> |
| *folder_create* | Create folder. When set to false, uses id to reference an existing folder. | <code title="">bool</code> | | <code title="">true</code> |
| *iam* | IAM bindings in {ROLE => [MEMBERS]} format. | <code title="map&#40;set&#40;string&#41;&#41;">map(set(string))</code> | | <code title="">{}</code> |
| *group_iam* | Authoritative IAM binding for organization groups, in {GROUP_EMAIL => [ROLES]} format. Group emails need to be static. Can be used in combination with the `iam` variable. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *iam* | IAM bindings in {ROLE => [MEMBERS]} format. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *id* | Folder ID in case you use folder_create=false | <code title="">string</code> | | <code title="">null</code> |
| *logging_exclusions* | Logging exclusions for this folder in the form {NAME -> FILTER}. | <code title="map&#40;string&#41;">map(string)</code> | | <code title="">{}</code> |
| *logging_sinks* | Logging sinks to create for this folder. | <code title="map&#40;object&#40;&#123;&#10;destination &#61; string&#10;type &#61; string&#10;filter &#61; string&#10;iam &#61; bool&#10;include_children &#61; bool&#10;exclusions &#61; map&#40;string&#41;&#10;&#125;&#41;&#41;">map(object({...}))</code> | | <code title="">{}</code> |

View File

@ -21,6 +21,19 @@ locals {
merge(rule, { policy = policy, name = rule_name })
]
])
group_iam_roles = distinct(flatten(values(var.group_iam)))
group_iam = {
for r in local.group_iam_roles : r => [
for k, v in var.group_iam : "group:${k}" if try(index(v, r), null) != null
]
}
iam = {
for role in distinct(concat(keys(var.iam), keys(local.group_iam))) :
role => concat(
try(var.iam[role], []),
try(local.group_iam[role], [])
)
}
rules_map = {
for rule in local.extended_rules :
"${rule.policy}-${rule.name}" => rule
@ -59,7 +72,7 @@ resource "google_folder" "folder" {
}
resource "google_folder_iam_binding" "authoritative" {
for_each = var.iam
for_each = local.iam
folder = local.folder.name
role = each.key
members = each.value

View File

@ -14,9 +14,75 @@
* limitations under the License.
*/
variable "contacts" {
description = "List of essential contacts for this resource. Must be in the form EMAIL -> [NOTIFICATION_TYPES]. Valid notification types are ALL, SUSPENSION, SECURITY, TECHNICAL, BILLING, LEGAL, PRODUCT_UPDATES"
type = map(list(string))
default = {}
}
variable "firewall_policies" {
description = "Hierarchical firewall policies to *create* in this folder."
type = map(map(object({
description = string
direction = string
action = string
priority = number
ranges = list(string)
ports = map(list(string))
target_service_accounts = list(string)
target_resources = list(string)
logging = bool
})))
default = {}
}
variable "firewall_policy_attachments" {
description = "List of hierarchical firewall policy IDs to *attach* to this folder."
type = map(string)
default = {}
}
variable "folder_create" {
description = "Create folder. When set to false, uses id to reference an existing folder."
type = bool
default = true
}
variable "group_iam" {
description = "Authoritative IAM binding for organization groups, in {GROUP_EMAIL => [ROLES]} format. Group emails need to be static. Can be used in combination with the `iam` variable."
type = map(list(string))
default = {}
}
variable "iam" {
description = "IAM bindings in {ROLE => [MEMBERS]} format."
type = map(set(string))
type = map(list(string))
default = {}
}
variable "id" {
description = "Folder ID in case you use folder_create=false"
type = string
default = null
}
variable "logging_sinks" {
description = "Logging sinks to create for this folder."
type = map(object({
destination = string
type = string
filter = string
iam = bool
include_children = bool
# TODO exclusions also support description and disabled
exclusions = map(string)
}))
default = {}
}
variable "logging_exclusions" {
description = "Logging exclusions for this folder in the form {NAME -> FILTER}."
type = map(string)
default = {}
}
@ -52,63 +118,3 @@ variable "policy_list" {
}))
default = {}
}
variable "firewall_policies" {
description = "Hierarchical firewall policies to *create* in this folder."
type = map(map(object({
description = string
direction = string
action = string
priority = number
ranges = list(string)
ports = map(list(string))
target_service_accounts = list(string)
target_resources = list(string)
logging = bool
})))
default = {}
}
variable "firewall_policy_attachments" {
description = "List of hierarchical firewall policy IDs to *attach* to this folder."
type = map(string)
default = {}
}
variable "logging_sinks" {
description = "Logging sinks to create for this folder."
type = map(object({
destination = string
type = string
filter = string
iam = bool
include_children = bool
# TODO exclusions also support description and disabled
exclusions = map(string)
}))
default = {}
}
variable "logging_exclusions" {
description = "Logging exclusions for this folder in the form {NAME -> FILTER}."
type = map(string)
default = {}
}
variable "folder_create" {
description = "Create folder. When set to false, uses id to reference an existing folder."
type = bool
default = true
}
variable "id" {
description = "Folder ID in case you use folder_create=false"
type = string
default = null
}
variable "contacts" {
description = "List of essential contacts for this resource. Must be in the form EMAIL -> [NOTIFICATION_TYPES]. Valid notification types are ALL, SUSPENSION, SECURITY, TECHNICAL, BILLING, LEGAL, PRODUCT_UPDATES"
type = map(list(string))
default = {}
}

View File

@ -13,7 +13,12 @@ This module allows managing several organization properties:
module "org" {
source = "./modules/organization"
organization_id = "organizations/1234567890"
iam = { "roles/projectCreator" = ["group:cloud-admins@example.org"] }
group_iam = {
"cloud-owners@example.org" = ["roles/owner", "roles/projectCreator"]
}
iam = {
"roles/projectCreator" = ["group:cloud-admins@example.org"]
}
policy_boolean = {
"constraints/compute.disableGuestAttributesAccess" = true
"constraints/compute.skipDefaultNetworkCreation" = true
@ -27,10 +32,21 @@ module "org" {
}
}
}
# tftest:modules=1:resources=4
# tftest:modules=1:resources=5
```
## IAM
There are several mutually exclusive ways of managing IAM in this module
- non-authoritative via the `iam_additive` and `iam_additive_members` variables, where bindings created outside this module will coexist with those managed here
- authoritative via the `group_iam` and `iam` variables, where bindings created outside this module (eg in the console) will be removed at each `terraform apply` cycle if the same role is also managed here
- authoritative policy via the `iam_bindings_authoritative` variable, where any binding created outside this module (eg in the console) will be removed at each `terraform apply` cycle regardless of the role
Some care must be takend with the `groups_iam` variable (and in some situations with the additive variables) to ensure that variable keys are static values, so that Terraform is able to compute the dependency graph.
## Hierarchical firewall rules
```hcl
module "org" {
source = "./modules/organization"
@ -60,6 +76,7 @@ module "org" {
```
## Logging Sinks
```hcl
module "gcs" {
source = "./modules/gcs"
@ -134,7 +151,6 @@ module "org" {
# tftest:modules=5:resources=11
```
<!-- BEGIN TFDOC -->
## Variables
@ -145,6 +161,7 @@ module "org" {
| *custom_roles* | Map of role name => list of permissions to create in this project. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *firewall_policies* | Hierarchical firewall policies to *create* in the organization. | <code title="map&#40;map&#40;object&#40;&#123;&#10;description &#61; string&#10;direction &#61; string&#10;action &#61; string&#10;priority &#61; number&#10;ranges &#61; list&#40;string&#41;&#10;ports &#61; map&#40;list&#40;string&#41;&#41;&#10;target_service_accounts &#61; list&#40;string&#41;&#10;target_resources &#61; list&#40;string&#41;&#10;logging &#61; bool&#10;&#125;&#41;&#41;&#41;">map(map(object({...})))</code> | | <code title="">{}</code> |
| *firewall_policy_attachments* | List of hierarchical firewall policy IDs to *attach* to the organization | <code title="map&#40;string&#41;">map(string)</code> | | <code title="">{}</code> |
| *group_iam* | Authoritative IAM binding for organization groups, in {GROUP_EMAIL => [ROLES]} format. Group emails need to be static. Can be used in combination with the `iam` variable. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *iam* | IAM bindings, in {ROLE => [MEMBERS]} format. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *iam_additive* | Non authoritative IAM bindings, in {ROLE => [MEMBERS]} format. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *iam_additive_members* | IAM additive bindings in {MEMBERS => [ROLE]} format. This might break if members are dynamic values. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |

View File

@ -16,6 +16,19 @@
locals {
organization_id_numeric = split("/", var.organization_id)[1]
group_iam_roles = distinct(flatten(values(var.group_iam)))
group_iam = {
for r in local.group_iam_roles : r => [
for k, v in var.group_iam : "group:${k}" if try(index(v, r), null) != null
]
}
iam = {
for role in distinct(concat(keys(var.iam), keys(local.group_iam))) :
role => concat(
try(var.iam[role], []),
try(local.group_iam[role], [])
)
}
iam_additive_pairs = flatten([
for role, members in var.iam_additive : [
for member in members : { role = role, member = member }
@ -67,7 +80,7 @@ resource "google_organization_iam_custom_role" "roles" {
}
resource "google_organization_iam_binding" "authoritative" {
for_each = var.iam
for_each = local.iam
org_id = local.organization_id_numeric
role = each.key
members = each.value

View File

@ -20,6 +20,12 @@ variable "custom_roles" {
default = {}
}
variable "group_iam" {
description = "Authoritative IAM binding for organization groups, in {GROUP_EMAIL => [ROLES]} format. Group emails need to be static. Can be used in combination with the `iam` variable."
type = map(list(string))
default = {}
}
variable "iam" {
description = "IAM bindings, in {ROLE => [MEMBERS]} format."
type = map(list(string))
@ -49,12 +55,6 @@ variable "iam_audit_config" {
# }
}
variable "iam_bindings_authoritative" {
description = "IAM authoritative bindings, in {ROLE => [MEMBERS]} format. Roles and members not explicitly listed will be cleared. Bindings should also be authoritative when using authoritative audit config. Use with caution."
type = map(list(string))
default = null
}
variable "iam_audit_config_authoritative" {
description = "IAM Authoritative service audit logging configuration. Service as key, map of log permission (eg DATA_READ) and excluded members as value for each service. Audit config should also be authoritative when using authoritative bindings. Use with caution."
type = map(map(list(string)))
@ -66,6 +66,12 @@ variable "iam_audit_config_authoritative" {
# }
}
variable "iam_bindings_authoritative" {
description = "IAM authoritative bindings, in {ROLE => [MEMBERS]} format. Roles and members not explicitly listed will be cleared. Bindings should also be authoritative when using authoritative audit config. Use with caution."
type = map(list(string))
default = null
}
variable "organization_id" {
description = "Organization id in organizations/nnnnnn format."
type = string

View File

@ -160,7 +160,8 @@ module "project-host" {
| *billing_account* | Billing account id. | <code title="">string</code> | | <code title="">null</code> |
| *contacts* | List of essential contacts for this resource. Must be in the form EMAIL -> [NOTIFICATION_TYPES]. Valid notification types are ALL, SUSPENSION, SECURITY, TECHNICAL, BILLING, LEGAL, PRODUCT_UPDATES | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *custom_roles* | Map of role name => list of permissions to create in this project. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *iam* | IAM bindings in {ROLE => [MEMBERS]} format. | <code title="map&#40;set&#40;string&#41;&#41;">map(set(string))</code> | | <code title="">{}</code> |
| *group_iam* | Authoritative IAM binding for organization groups, in {GROUP_EMAIL => [ROLES]} format. Group emails need to be static. Can be used in combination with the `iam` variable. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *iam* | IAM bindings in {ROLE => [MEMBERS]} format. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *iam_additive* | IAM additive bindings in {ROLE => [MEMBERS]} format. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *iam_additive_members* | IAM additive bindings in {MEMBERS => [ROLE]} format. This might break if members are dynamic values. | <code title="map&#40;list&#40;string&#41;&#41;">map(list(string))</code> | | <code title="">{}</code> |
| *labels* | Resource labels. | <code title="map&#40;string&#41;">map(string)</code> | | <code title="">{}</code> |

View File

@ -15,6 +15,19 @@
*/
locals {
group_iam_roles = distinct(flatten(values(var.group_iam)))
group_iam = {
for r in local.group_iam_roles : r => [
for k, v in var.group_iam : "group:${k}" if try(index(v, r), null) != null
]
}
iam = {
for role in distinct(concat(keys(var.iam), keys(local.group_iam))) :
role => concat(
try(var.iam[role], []),
try(local.group_iam[role], [])
)
}
iam_additive_pairs = flatten([
for role, members in var.iam_additive : [
for member in members : { role = role, member = member }
@ -110,7 +123,7 @@ resource "google_project_service" "project_services" {
# - additive (non-authoritative) roles might fail due to dynamic values
resource "google_project_iam_binding" "authoritative" {
for_each = var.iam
for_each = local.iam
project = local.project.project_id
role = each.key
members = each.value
@ -329,7 +342,7 @@ resource "google_essential_contacts_contact" "contact" {
resource "google_access_context_manager_service_perimeter_resource" "service-perimeter-resource-standard" {
count = var.service_perimeter_standard != null ? 1 : 0
# If used, remember to uncomment 'lifecycle' block in the
# If used, remember to uncomment 'lifecycle' block in the
# modules/vpc-sc/google_access_context_manager_service_perimeter resource.
perimeter_name = var.service_perimeter_standard
resource = "projects/${local.project.number}"
@ -338,7 +351,7 @@ resource "google_access_context_manager_service_perimeter_resource" "service-per
resource "google_access_context_manager_service_perimeter_resource" "service-perimeter-resource-bridges" {
for_each = toset(var.service_perimeter_bridges != null ? var.service_perimeter_bridges : [])
# If used, remember to uncomment 'lifecycle' block in the
# If used, remember to uncomment 'lifecycle' block in the
# modules/vpc-sc/google_access_context_manager_service_perimeter resource.
perimeter_name = each.value
resource = "projects/${local.project.number}"

View File

@ -32,9 +32,15 @@ variable "custom_roles" {
default = {}
}
variable "group_iam" {
description = "Authoritative IAM binding for organization groups, in {GROUP_EMAIL => [ROLES]} format. Group emails need to be static. Can be used in combination with the `iam` variable."
type = map(list(string))
default = {}
}
variable "iam" {
description = "IAM bindings in {ROLE => [MEMBERS]} format."
type = map(set(string))
type = map(list(string))
default = {}
}
@ -196,7 +202,7 @@ variable "contacts" {
variable "service_perimeter_standard" {
description = "Name of VPC-SC Standard perimeter to add project into. Specify the name in the form of 'accessPolicies/ACCESS_POLICY_NAME/servicePerimeters/PERIMETER_NAME'."
type = string
default = null
default = null
}

View File

@ -18,6 +18,7 @@ module "test" {
source = "../../../../modules/organization"
organization_id = "organizations/1234567890"
custom_roles = var.custom_roles
group_iam = var.group_iam
iam = var.iam
iam_additive = var.iam_additive
iam_additive_members = var.iam_additive_members

View File

@ -19,6 +19,11 @@ variable "custom_roles" {
default = {}
}
variable "group_iam" {
type = map(list(string))
default = {}
}
variable "iam" {
type = map(list(string))
default = {}

View File

@ -30,6 +30,32 @@ def test_audit_config(plan_runner):
assert log_types == set(['DATA_READ', 'DATA_WRITE'])
def test_iam(plan_runner):
"Test IAM."
group_iam = (
'{'
'"owners@example.org" = ["roles/owner", "roles/resourcemanager.folderAdmin"],'
'"viewers@example.org" = ["roles/viewer"]'
'}'
)
iam = (
'{'
'"roles/owner" = ["user:one@example.org", "user:two@example.org"],'
'"roles/browser" = ["domain:example.org"]'
'}'
)
_, resources = plan_runner(FIXTURES_DIR, group_iam=group_iam, iam=iam)
roles = sorted([(r['values']['role'], sorted(r['values']['members']))
for r in resources if r['type'] == 'google_organization_iam_binding'])
assert roles == [
('roles/browser', ['domain:example.org']),
('roles/owner', ['group:owners@example.org', 'user:one@example.org',
'user:two@example.org']),
('roles/resourcemanager.folderAdmin', ['group:owners@example.org']),
('roles/viewer', ['group:viewers@example.org']),
]
def test_iam_additive_members(plan_runner):
"Test IAM additive members."
iam = (
@ -126,7 +152,7 @@ def test_firweall_policy(plan_runner):
assert len(resources) == 4
policies = [r for r in resources
if r['type'] == 'google_compute_organization_security_policy']
if r['type'] == 'google_compute_organization_security_policy']
assert len(policies) == 1
rules = [r for r in resources
@ -146,16 +172,16 @@ def test_firweall_policy(plan_runner):
rule_values.append((name, index, action, direction, priority, config))
assert sorted(rule_values) == sorted([
('rule', 'policy1-allow-ingress', 'allow', 'INGRESS', 100,[
{
'dest_ip_ranges': None,
'layer4_config': [{'ip_protocol': 'tcp', 'ports': ['22']}],
'src_ip_ranges': ['10.0.0.0/8']
}]),
('rule', 'policy1-deny-egress', 'deny', 'EGRESS', 200, [
{
'dest_ip_ranges': ['192.168.0.0/24'],
'layer4_config': [{'ip_protocol': 'tcp', 'ports': ['443']}],
'src_ip_ranges': None
}])
('rule', 'policy1-allow-ingress', 'allow', 'INGRESS', 100, [
{
'dest_ip_ranges': None,
'layer4_config': [{'ip_protocol': 'tcp', 'ports': ['22']}],
'src_ip_ranges': ['10.0.0.0/8']
}]),
('rule', 'policy1-deny-egress', 'deny', 'EGRESS', 200, [
{
'dest_ip_ranges': ['192.168.0.0/24'],
'layer4_config': [{'ip_protocol': 'tcp', 'ports': ['443']}],
'src_ip_ranges': None
}])
])