Leverage project-level workload identity in FAST CI/CD (#3535)

* Leverage project-level WIF in FAST CI/CD

* add new context namespace, improve outputs, fix tests and inventories

* make YAML linter happy

* README
This commit is contained in:
Ludovico Magnocavallo
2025-11-18 11:49:44 +01:00
committed by GitHub
parent 0ff2e8c56b
commit 8c29512890
25 changed files with 498 additions and 636 deletions

View File

@@ -28,6 +28,8 @@
- [Context-based replacement in the folders factory](#context-based-replacement-in-the-folders-factory)
- [Project factory](#project-factory)
- [CI/CD configuration](#cicd-configuration)
- [Read-write and read-only impersonation](#read-write-and-read-only-impersonation)
- [Customized IAM principal sets](#customized-iam-principal-sets)
- [Okta](#okta)
- [Leveraging classic FAST Stages](#leveraging-classic-fast-stages)
- [VPC Service Controls](#vpc-service-controls)
@@ -296,6 +298,7 @@ This is a simple reference table of available interpolation namespaces, refer to
- `$tag_values:my_value`
- `$vpc_host_projects:my_project`
- `$vpc_sc_perimeters:my_perimeter`
- `$workload_identity_providers:my_project/my_pool/my_provider`
### Factory data
@@ -319,7 +322,7 @@ The default paths point to the dataset in the `data` folder which deploys a FAST
folder-level factory to define the resource management hierarchy and individual folder attributes (IAM, org policies, tag bindings, etc.); also supports defining folder-level IaC resources
- **projects** (`datasets/classic/projects`) \
folder-level factory to define projects and their attributes (projejct factory)
- **cicd** (`datasets/classic/cicd.yaml`) \
- **cicd** (`datasets/classic/cicd-workflows.yaml`) \
file-level factory to define CI/CD configurations for this and subsequent stages
### Defaults configuration
@@ -502,7 +505,7 @@ values:
# [...]
```
An exception to the namespaced-based context replacements is in IAM conditions, where Terraform limitations force use of native string templating, as in the example below.
An exception to the namespaced-based context replacements is in IAM conditions and organization policies, where Terraform limitations force use of native string templating, as in the example below.
```yaml
iam_bindings:
@@ -575,44 +578,80 @@ The provided project configurations also create several key resources for the st
### CI/CD configuration
CI/CD support is implemented in a similar way to classic/legacy FAST, except for being driven by a factory that points to a single file.
CI/CD support is implemented via two different sets of connfigurations:
This allows defining a single Workload Identity provider that will be used to exchange external tokens for the pipelines, and one or more workflows that can interpolate internal (from the project factory) or external (user defined) attributes.
- [Workload Identity](https://docs.cloud.google.com/iam/docs/workload-identity-federation) providers are defined in project configurations
- CI/CD service accounts and templated workflow generation are defined in a dedicated configuration (`var.factories_config.cicd_workflows`).
This is the default file which implements a workflow for this stage. To enable it, pass the file path to the `factories_config.cicd` variable.
The default approach is to define a Workload Identity provider in the `iac-0` project, or in an additional project dedicated to this task. This is achieved by adding a `workload_identity_pools` block to the project configuration, like in the following example.
```yaml
workload_identity_federation:
pool_name: iac-0
project: $project_ids:iac-0
providers:
github:
# the condition is optional but recommended, use your GitHub org name
attribute_condition: attribute.repository_owner=="my_org"
issuer: github
# custom_settings:
# issuer_uri:
# audiences: []
# jwks_json_path:
workflows:
org_setup:
template: github
workload_identity_provider:
id: $wif_providers:github
audiences: []
repository:
name: org-setup
branch: main
output_files:
storage_bucket: $storage_buckets:iac-0/iac-outputs
providers:
apply: $output_files:providers/0-org-setup
plan: $output_files:providers/0-org-setup-ro
files:
- tfvars/0-boostrap.auto.tfvars.json
service_accounts:
apply: $iam_principals:service_accounts/iac-0/iac-org-cicd-rw
plan: $iam_principals:service_accounts/iac-0/iac-org-cicd-ro
# projects/iac-0.yaml
workload_identity_pools:
default:
display_name: Default pool for CI/CD.
providers:
github-default:
display_name: GitHub (example org).
attribute_condition: attribute.repository_owner=="example"
identity_provider:
oidc:
template: github
gitlab-default:
display_name: Gitlab (example org).
attribute_condition: attribute.namespace_path=="example"
identity_provider:
oidc:
template: gitlab
```
The above configuration can be easily extended to support multiple pools and providers, and is not limited to OpenId Connect but can also leverage other provider types. Check the project module or project schema for the full interface.
Once one or more providers have been defined they can be referenced in the CI/CD cofniguration file. The following example defines a workflow configuration for this stage.
```yaml
# cicd-workflows.yaml
org-setup:
provider_files:
apply: 0-org-setup-providers.tf
plan: 0-org-setup-providers-ro.tf
repository:
name: example/0-org-setup
type: github
apply_branches:
- master
- fast-dev
service_accounts:
apply: $iam_principals:service_accounts/iac-0/iac-org-cicd-rw
plan: $iam_principals:service_accounts/iac-0/iac-org-cicd-ro
tfvars_files:
- 0-org-setup.auto.tfvars
workload_identity:
provider: $workload_identity_providers:iac-0/default/github-default
iam_principalsets:
template: github
```
The configuration prepares a sample workflow file for the target repository, and configures IAM on the service accounts referenced in the configuration, so that repository tokens can impersonate them via the Workload Identity provider.
#### Read-write and read-only impersonation
The access pattern implemented above allows impersonation from any branch to the read-only (`-ro`) service account, and impersonation from explicitly mentioned branches to the read-write (`rw`) service account. This ensures that PR-related actions run with limited privileges, and higher level privileges are only used for merges after PR checks and approvals. If a more relaxed approach where any branch can access the read-write service account, simply omit the `repository.apply_branches` block.
#### Customized IAM principal sets
The format of the IAM principalsets used to grant impersonation permissions to the exchanged token can either leverage internally defined templates via the `workload_identity.iam_principalsets.template` attribute, or be explicitly defined so that fine-tuning is possible, or different sets of principals are allowed. The following example is the explicit format equivalent to the template used above.
```yaml
org-setup:
# identical lines omitted
workload_identity:
provider: $workload_identity_providers:iac-0/default/github-default
iam_principalsets:
apply: principalSet://iam.googleapis.com/%s/attribute.fast_sub/repo:%s:ref:refs/heads/%s
plan: principalSet://iam.googleapis.com/%s/attribute.repository/%s
```
#### Okta
@@ -654,7 +693,7 @@ workflows:
plan: $iam_principals:service_accounts/iac-0/iac-org-cicd-ro
```
Finally you will need to modify the following org policies and IAM permissions in `datasets/classic/organization/org-policies/iam.yaml` file:
Finally you will need to modify the following org policies and IAM permissions in `datasets/classic/organization/org-policies/iam.yaml` file:
- Under `org_polices` add your Okta provider URL :
@@ -669,11 +708,11 @@ org_policies:
- https://app.terraform.io
- https://<REPLACE_WITH_ORG_NAME>.okta.com/oauth2/default // Modify this
```
This configuration adds Okta to the list of allowed Workload Identity providers in your GCP organization.
- Under `iac-org-cicd-ro` and `iac-org-cicd-rw` service accounts add `roles/iam.workloadIdentityUser` to each of them:
```yaml
iac-org-cicd-ro:
display_name: IaC service account for org setup CI/CD (read-only).
@@ -695,6 +734,7 @@ This configuration adds Okta to the list of allowed Workload Identity providers
roles/iam.workloadIdentityUser:
- principalSet://iam.googleapis.com/projects/<REPLACE_WITH_IAC_PROJECT_NUMBER>/locations/global/workloadIdentityPools/iac-0/* // Modify this
```
This allows identities from the Workload Identity Pool to impersonate both IaC service accounts.
</details>
@@ -732,24 +772,24 @@ Define values for the `var.environments` variable in a tfvars file.
| name | description | modules | resources |
|---|---|---|---|
| [billing.tf](./billing.tf) | None | <code>billing-account</code> | |
| [cicd.tf](./cicd.tf) | None | <code>iam-service-account</code> | <code>google_storage_bucket_object</code> · <code>local_file</code> |
| [cicd-workflows-preconditions.tf](./cicd-workflows-preconditions.tf) | None | | <code>terraform_data</code> |
| [cicd-workflows.tf](./cicd-workflows.tf) | None | <code>iam-service-account</code> | <code>google_storage_bucket_object</code> · <code>local_file</code> |
| [factory.tf](./factory.tf) | None | <code>project-factory</code> | |
| [identity-providers-defs.tf](./identity-providers-defs.tf) | None | | |
| [imports.tf](./imports.tf) | None | | |
| [main.tf](./main.tf) | Module-level locals and resources. | | <code>terraform_data</code> |
| [organization.tf](./organization.tf) | None | <code>organization</code> | |
| [output-files.tf](./output-files.tf) | None | | <code>google_storage_bucket_object</code> · <code>local_file</code> |
| [outputs.tf](./outputs.tf) | Module outputs. | | |
| [variables.tf](./variables.tf) | Module variables. | | |
| [wif-definitions.tf](./wif-definitions.tf) | Workload Identity provider definitions. | | |
| [wif-providers.tf](./wif-providers.tf) | None | | <code>google_iam_workload_identity_pool</code> · <code>google_iam_workload_identity_pool_provider</code> |
## Variables
| name | description | type | required | default |
|---|---|:---:|:---:|:---:|
| [context](variables.tf#L17) | Context-specific interpolations. | <code title="object&#40;&#123;&#10; custom_roles &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; email_addresses &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; folder_ids &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; iam_principals &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; locations &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; kms_keys &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; notification_channels &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; project_ids &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; service_account_ids &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; tag_keys &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; tag_values &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; vpc_host_projects &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; vpc_sc_perimeters &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10;&#125;&#41;">object&#40;&#123;&#8230;&#125;&#41;</code> | | <code>&#123;&#125;</code> |
| [factories_config](variables.tf#L38) | Configuration for the resource factories or external data. | <code title="object&#40;&#123;&#10; billing_accounts &#61; optional&#40;string, &#34;datasets&#47;classic&#47;billing-accounts&#34;&#41;&#10; cicd &#61; optional&#40;string&#41;&#10; defaults &#61; optional&#40;string, &#34;datasets&#47;classic&#47;defaults.yaml&#34;&#41;&#10; folders &#61; optional&#40;string, &#34;datasets&#47;classic&#47;folders&#34;&#41;&#10; organization &#61; optional&#40;string, &#34;datasets&#47;classic&#47;organization&#34;&#41;&#10; project_templates &#61; optional&#40;string, &#34;datasets&#47;classic&#47;templates&#34;&#41;&#10; projects &#61; optional&#40;string, &#34;datasets&#47;classic&#47;projects&#34;&#41;&#10;&#125;&#41;">object&#40;&#123;&#8230;&#125;&#41;</code> | | <code>&#123;&#125;</code> |
| [org_policies_imports](variables.tf#L53) | List of org policies to import. These need to also be defined in data files. | <code>list&#40;string&#41;</code> | | <code>&#91;&#93;</code> |
| [context](variables.tf#L17) | Context-specific interpolations. | <code title="object&#40;&#123;&#10; custom_roles &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; email_addresses &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; folder_ids &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; iam_principals &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; locations &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; kms_keys &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; notification_channels &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; project_ids &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; service_account_ids &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; tag_keys &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; tag_values &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; vpc_host_projects &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; vpc_sc_perimeters &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10; workload_identity_providers &#61; optional&#40;map&#40;string&#41;, &#123;&#125;&#41;&#10;&#125;&#41;">object&#40;&#123;&#8230;&#125;&#41;</code> | | <code>&#123;&#125;</code> |
| [factories_config](variables.tf#L39) | Configuration for the resource factories or external data. | <code title="object&#40;&#123;&#10; billing_accounts &#61; optional&#40;string, &#34;datasets&#47;classic&#47;billing-accounts&#34;&#41;&#10; cicd_workflows &#61; optional&#40;string&#41;&#10; defaults &#61; optional&#40;string, &#34;datasets&#47;classic&#47;defaults.yaml&#34;&#41;&#10; folders &#61; optional&#40;string, &#34;datasets&#47;classic&#47;folders&#34;&#41;&#10; organization &#61; optional&#40;string, &#34;datasets&#47;classic&#47;organization&#34;&#41;&#10; project_templates &#61; optional&#40;string, &#34;datasets&#47;classic&#47;templates&#34;&#41;&#10; projects &#61; optional&#40;string, &#34;datasets&#47;classic&#47;projects&#34;&#41;&#10;&#125;&#41;">object&#40;&#123;&#8230;&#125;&#41;</code> | | <code>&#123;&#125;</code> |
| [org_policies_imports](variables.tf#L54) | List of org policies to import. These need to also be defined in data files. | <code>list&#40;string&#41;</code> | | <code>&#91;&#93;</code> |
## Outputs

View File

@@ -26,7 +26,7 @@ on:
env:
FAST_SERVICE_ACCOUNT: ${service_accounts.apply}
FAST_SERVICE_ACCOUNT_PLAN: ${service_accounts.plan}
FAST_WIF_PROVIDER: ${workload_identity.pool_id}
FAST_WIF_PROVIDER: ${workload_identity_provider}
SSH_AUTH_SOCK: /tmp/ssh_agent.sock
TF_PROVIDERS_FILE: ${provider_files.apply}
TF_PROVIDERS_FILE_PLAN: ${provider_files.plan}

View File

@@ -0,0 +1,59 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
resource "terraform_data" "precondition-cicd" {
lifecycle {
precondition {
condition = alltrue([
for k, v in local.cicd_workflows :
v.repository.name != null && v.repository.type != null
])
error_message = "Incomplete repository configuration in CI/CD workflows."
}
precondition {
condition = alltrue([
for k, v in local.cicd_workflows :
v.provider_files.apply != null && v.provider_files.plan != null
])
error_message = "Incomplete provider files configuration in CI/CD workflows."
}
precondition {
condition = alltrue([
for k, v in local.cicd_workflows :
v.service_accounts.apply != null && v.service_accounts.plan != null
])
error_message = "Incomplete service account configuration in CI/CD workflows."
}
precondition {
condition = alltrue([
for k, v in local.cicd_workflows : (
v.workload_identity.provider != null &&
v.workload_identity.iam_principalsets.plan != null
)
])
error_message = "Incomplete workload identity configuration in CI/CD workflows."
}
precondition {
condition = alltrue([
for k, v in local.cicd_workflows : (
length(v.repository.apply_branches) == 0 ||
v.workload_identity.iam_principalsets.apply != null
)
])
error_message = "Missing apply principalset in CI/CD workflows."
}
}
}

View File

@@ -16,57 +16,69 @@
locals {
# raw configuration (the wif files are also users of this local)
cicd = try(yamldecode(file(local.paths.cicd)), {})
_cicd_workflows = try(yamldecode(file(local.paths.cicd_workflows)), {})
# dereferencing maps
cicd_ctx_sa = {
for k, v in merge(local.ctx.iam_principals, module.factory.iam_principals) :
"$iam_principals:${k}" => v
}
cicd_ctx_wif = try({
"$wif_pools:${local.wif_pool_name}" = google_iam_workload_identity_pool.default[0].name
}, {})
# normalize workflow configurations
cicd_ctx_wif = {
for k, v in merge(
local.ctx.workload_identity_providers, local.workload_identity_providers
) : "$workload_identity_providers:${k}" => v
}
# normalize workflow configurations, correctness is checked via preconditions
cicd_workflows = {
for k, v in lookup(local.cicd, "workflows", {}) : k => merge(v, {
iam_principal_templates = {
branch = local.wif_defs[v.repository.type].principal_branch
repo = local.wif_defs[v.repository.type].principal_repo
for k, v in local._cicd_workflows : k => {
provider_files = {
apply = try(v.provider_files.apply, null)
plan = try(v.provider_files.plan, null)
}
repository = merge(v.repository, {
repository = {
apply_branches = try(v.repository.apply_branches, [])
})
service_accounts = {
apply = trimprefix(try(
local.cicd_ctx_sa[v.service_accounts.apply], v.service_accounts.apply
), "serviceAccount:")
plan = trimprefix(try(
local.cicd_ctx_sa[v.service_accounts.plan], v.service_accounts.plan
), "serviceAccount:")
name = try(v.repository.name, null)
type = try(v.repository.type, null)
}
workload_identity = {
audiences = try(v.workload_identity.audiences, [])
pool_id = try(
local.cicd_ctx_wif[v.workload_identity.pool_id],
v.workload_identity.pool_id
service_accounts = {
apply = try(
trimprefix(
local.cicd_ctx_sa[v.service_accounts.apply], "serviceAccount:"
),
v.service_accounts.apply,
null
)
plan = try(
trimprefix(
local.cicd_ctx_sa[v.service_accounts.plan], "serviceAccount:"
),
v.service_accounts.plan,
null
)
}
})
if(
try(local.wif_defs[v.repository.type], null) != null &&
try(v.provider_files.apply, null) != null &&
try(v.provider_files.plan, null) != null &&
try(v.repository.name, null) != null &&
try(v.service_accounts.apply, null) != null &&
try(v.service_accounts.plan, null) != null &&
try(v.workload_identity.pool_id, null) != null
)
tfvars_files = try(v.tfvars_files, [])
workload_identity = {
provider = try(
local.cicd_ctx_wif[v.workload_identity.provider],
v.workload_identity.provider,
null
)
iam_principalsets = try(
local.wif_iam_templates[v.workload_identity.iam_principalsets.template],
{
apply = try(v.workload_identity.iam_principalsets.apply)
plan = try(v.workload_identity.iam_principalsets.plan)
}
)
}
}
}
# generate workflow files contents
cicd_workflows_contents = {
for k, v in local.cicd_workflows : k => templatefile(
"assets/workflow-${v.repository.type}.yaml", merge(v, {
outputs_bucket = local.of_outputs_bucket
stage_name = k
outputs_bucket = local.of_outputs_bucket
stage_name = k
workload_identity_provider = v.workload_identity.provider
})
)
}
@@ -84,15 +96,15 @@ module "cicd-sa-apply" {
length(each.value.repository.apply_branches) == 0
? [
format(
each.value.iam_principal_templates.repo,
each.value.workload_identity.pool_id,
each.value.workload_identity.iam_principalsets.plan,
each.value.workload_identity.provider,
each.value.repository.name
)
]
: [
for v in each.value.repository.apply_branches : format(
each.value.iam_principal_templates.branch,
each.value.workload_identity.pool_id,
each.value.workload_identity.iam_principalsets.apply,
each.value.workload_identity.provider,
each.value.repository.name,
v
)
@@ -111,8 +123,8 @@ module "cicd-sa-plan" {
iam = {
"roles/iam.workloadIdentityUser" = [
format(
each.value.iam_principal_templates.repo,
each.value.workload_identity.pool_id,
each.value.workload_identity.iam_principalsets.plan,
each.value.workload_identity.provider,
each.value.repository.name
)
]

View File

@@ -0,0 +1,41 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
locals {
wif_iam_templates = {
# https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
github = {
apply = "principalSet://iam.googleapis.com/%s/attribute.fast_sub/repo:%s:ref:refs/heads/%s"
plan = "principalSet://iam.googleapis.com/%s/attribute.repository/%s"
}
# https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html#token-payload
gitlab = {
apply = "principalSet://iam.googleapis.com/%s/attribute.sub/project_path:%s:ref_type:branch:ref:%s"
plan = "principalSet://iam.googleapis.com/%s/attribute.repository/%s"
}
# https://developer.hashicorp.com/terraform/cloud-docs/workspaces/dynamic-provider-credentials/workload-identity-tokens#token-structure
terraform = {
apply = "principalSet://iam.googleapis.com/%s/attribute.terraform_workspace_id/%s"
plan = "principalSet://iam.googleapis.com/%s/attribute.terraform_project_id/%s"
}
# https://developer.okta.com/docs/api/openapi/okta-oauth/guides/overview/
okta = {
apply = "principalSet://iam.googleapis.com/%s/attribute.sub/project_path:%s:ref_type:branch:ref:%s"
plan = "principalSet://iam.googleapis.com/%s/attribute.repository/%s"
member = "principalSet://iam.googleapis.com/%s/*"
}
}
}

View File

@@ -56,6 +56,12 @@ locals {
defaults = try(local._defaults.projects.defaults, {})
overrides = try(local._defaults.projects.overrides, {})
}
workload_identity_providers = merge([
for k, v in module.factory.projects : {
for wk, wv in v.workload_identity_providers :
"${k}/${wk}" => wv.pool
}
]...)
}
# TODO: streamine location replacements

View File

@@ -90,12 +90,11 @@ locals {
project_ids = local.of_ctx.project_ids,
project_numbers = module.factory.project_numbers
# project_numbers = module.factory.project_numbers
service_accounts = module.factory.service_account_emails
storage_buckets = module.factory.storage_buckets
tag_values = local.of_ctx.tag_values
workforce_identity_providers = try(
module.organization[0].workforce_identity_provider_names, {}
)
service_accounts = module.factory.service_account_emails
storage_buckets = module.factory.storage_buckets
tag_values = local.of_ctx.tag_values
workload_identity_providers = local.workload_identity_providers
workforce_identity_providers = module.organization[0].workforce_identity_providers
}
}
of_universe_domain = try(

View File

@@ -0,0 +1,134 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CI/CD Configuration",
"type": "object",
"additionalProperties": false,
"patternProperties": {
"^[a-z-][a-z0-9-]+$": {
"type": "object",
"additionalProperties": false,
"required": [
"provider_files",
"repository",
"service_accounts",
"workload_identity"
],
"properties": {
"provider_files": {
"type": "object",
"additionalProperties": false,
"required": [
"apply",
"plan"
],
"properties": {
"apply": {
"type": "string"
},
"plan": {
"type": "string"
}
}
},
"repository": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"github",
"gitlab"
]
},
"apply_branches": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"service_accounts": {
"type": "object",
"additionalProperties": false,
"required": [
"apply",
"plan"
],
"properties": {
"apply": {
"type": "string"
},
"plan": {
"type": "string"
}
}
},
"tfvars_files": {
"type": "array",
"items": {
"type": "string"
}
},
"workload_identity": {
"type": "object",
"additionalProperties": false,
"required": [
"provider",
"iam_principalsets"
],
"properties": {
"provider": {
"type": "string"
},
"iam_principalsets": {
"type": "object",
"oneOf": [
{
"additionalProperties": false,
"required": [
"template"
],
"properties": {
"template": {
"type": "string",
"enum": [
"github",
"gitlab",
"okta",
"terraform"
]
}
}
},
{
"additionalProperties": false,
"required": [
"apply",
"plan"
],
"properties": {
"apply": {
"type": "string"
},
"plan": {
"type": "string"
}
}
}
]
}
}
}
}
}
}
}

View File

@@ -0,0 +1,35 @@
# CI/CD Configuration
<!-- markdownlint-disable MD036 -->
## Properties
*additional properties: false*
- **`^[a-z-][a-z0-9-]+$`**: *object*
<br>*additional properties: false*
- ⁺**provider_files**: *object*
<br>*additional properties: false*
- ⁺**apply**: *string*
- ⁺**plan**: *string*
- ⁺**repository**: *object*
<br>*additional properties: false*
- ⁺**name**: *string*
- ⁺**type**: *string*
<br>*enum: ['github', 'gitlab']*
- **apply_branches**: *array*
- items: *string*
- ⁺**service_accounts**: *object*
<br>*additional properties: false*
- ⁺**apply**: *string*
- ⁺**plan**: *string*
- **tfvars_files**: *array*
- items: *string*
- ⁺**workload_identity**: *object*
<br>*additional properties: false*
- ⁺**provider**: *string*
- ⁺**iam_principalsets**: *object*
## Definitions

View File

@@ -1,184 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CI/CD Configuration",
"type": "object",
"additionalProperties": false,
"properties": {
"workflows": {
"type": "object",
"additionalProperties": false,
"patternProperties": {
"^[a-z-][a-z0-9-]+$": {
"type": "object",
"additionalProperties": false,
"required": [
"provider_files",
"repository",
"service_accounts",
"workload_identity"
],
"properties": {
"provider_files": {
"type": "object",
"additionalProperties": false,
"required": [
"apply",
"plan"
],
"properties": {
"apply": {
"type": "string"
},
"plan": {
"type": "string"
}
}
},
"repository": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"github",
"gitlab"
]
},
"apply_branches": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"service_accounts": {
"type": "object",
"additionalProperties": false,
"required": [
"apply",
"plan"
],
"properties": {
"apply": {
"type": "string"
},
"plan": {
"type": "string"
}
}
},
"tfvars_files": {
"type": "array",
"items": {
"type": "string"
}
},
"workload_identity": {
"type": "object",
"additionalProperties": false,
"required": [
"pool_id"
],
"properties": {
"pool_id": {
"type": "string"
},
"audiences": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"workload_identity_federation": {
"type": "object",
"additionalProperties": false,
"required": [
"pool_name",
"project"
],
"properties": {
"pool_name": {
"type": "string"
},
"project": {
"type": "string"
},
"providers": {
"type": "object",
"additionalProperties": false,
"patternProperties": {
"^[a-z-][a-z0-9-]+$": {
"type": "object",
"additionalProperties": false,
"required": [
"issuer"
],
"properties": {
"attribute_condition": {
"type": "string"
},
"custom_settings": {
"type": "object",
"additionalProperties": false,
"properties": {
"issuer_uri": {
"type": "string"
},
"audiences": {
"type": "array",
"items": {
"type": "string"
}
},
"jwks_json_path": {
"type": "string"
},
"okta": {
"type": "object",
"additionalProperties": false,
"properties": {
"organization_name": {
"type": "string"
},
"auth_server_name": {
"type": "string"
}
}
}
},
"issuer": {
"type": "string",
"enum": [
"github",
"gitlab",
"terraform",
"okta"
]
},
"provider_id": {
"type": "string"
}
}
}
}
}
}
}
}
}
}

View File

@@ -1,57 +0,0 @@
# CI/CD Configuration
<!-- markdownlint-disable MD036 -->
## Properties
*additional properties: false*
- **workflows**: *object*
<br>*additional properties: false*
- **`^[a-z-][a-z0-9-]+$`**: *object*
<br>*additional properties: false*
- ⁺**provider_files**: *object*
<br>*additional properties: false*
- ⁺**apply**: *string*
- ⁺**plan**: *string*
- ⁺**repository**: *object*
<br>*additional properties: false*
- ⁺**name**: *string*
- ⁺**type**: *string*
<br>*enum: ['github', 'gitlab']*
- **apply_branches**: *array*
- items: *string*
- ⁺**service_accounts**: *object*
<br>*additional properties: false*
- ⁺**apply**: *string*
- ⁺**plan**: *string*
- **tfvars_files**: *array*
- items: *string*
- ⁺**workload_identity**: *object*
<br>*additional properties: false*
- ⁺**pool_id**: *string*
- **audiences**: *array*
- items: *string*
- **workload_identity_federation**: *object*
<br>*additional properties: false*
- ⁺**pool_name**: *string*
- ⁺**project**: *string*
- **providers**: *object*
<br>*additional properties: false*
- **`^[a-z-][a-z0-9-]+$`**: *object*
<br>*additional properties: false*
- **attribute_condition**: *string*
- **custom_settings**: *object*
<br>*additional properties: false*
- **issuer_uri**: *string*
- **audiences**: *array*
- items: *string*
- **jwks_json_path**: *string*
- **okta**: *object*
<br>*additional properties: false*
- **organization_name**: *string*
- **auth_server_name**: *string*
## Definitions

View File

@@ -525,6 +525,12 @@
"additionalProperties": {
"type": "string"
}
},
"workload_identity_providers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},

View File

@@ -17,19 +17,20 @@
variable "context" {
description = "Context-specific interpolations."
type = object({
custom_roles = optional(map(string), {})
email_addresses = optional(map(string), {})
folder_ids = optional(map(string), {})
iam_principals = optional(map(string), {})
locations = optional(map(string), {})
kms_keys = optional(map(string), {})
notification_channels = optional(map(string), {})
project_ids = optional(map(string), {})
service_account_ids = optional(map(string), {})
tag_keys = optional(map(string), {})
tag_values = optional(map(string), {})
vpc_host_projects = optional(map(string), {})
vpc_sc_perimeters = optional(map(string), {})
custom_roles = optional(map(string), {})
email_addresses = optional(map(string), {})
folder_ids = optional(map(string), {})
iam_principals = optional(map(string), {})
locations = optional(map(string), {})
kms_keys = optional(map(string), {})
notification_channels = optional(map(string), {})
project_ids = optional(map(string), {})
service_account_ids = optional(map(string), {})
tag_keys = optional(map(string), {})
tag_values = optional(map(string), {})
vpc_host_projects = optional(map(string), {})
vpc_sc_perimeters = optional(map(string), {})
workload_identity_providers = optional(map(string), {})
})
default = {}
nullable = false
@@ -39,7 +40,7 @@ variable "factories_config" {
description = "Configuration for the resource factories or external data."
type = object({
billing_accounts = optional(string, "datasets/classic/billing-accounts")
cicd = optional(string)
cicd_workflows = optional(string)
defaults = optional(string, "datasets/classic/defaults.yaml")
folders = optional(string, "datasets/classic/folders")
organization = optional(string, "datasets/classic/organization")

View File

@@ -1,88 +0,0 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
# tfdoc:file:description Workload Identity provider definitions.
locals {
wif_defs = {
# https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
github = {
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.sub" = "assertion.sub"
"attribute.actor" = "assertion.actor"
"attribute.repository" = "assertion.repository"
"attribute.repository_owner" = "assertion.repository_owner"
"attribute.ref" = "assertion.ref"
"attribute.fast_sub" = "\"repo:\" + assertion.repository + \":ref:\" + assertion.ref"
}
issuer_uri = "https://token.actions.githubusercontent.com"
principal_branch = "principalSet://iam.googleapis.com/%s/attribute.fast_sub/repo:%s:ref:refs/heads/%s"
principal_repo = "principalSet://iam.googleapis.com/%s/attribute.repository/%s"
}
# https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html#token-payload
gitlab = {
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.sub" = "assertion.sub"
"attribute.environment" = "assertion.environment"
"attribute.environment_protected" = "assertion.environment_protected"
"attribute.namespace_id" = "assertion.namespace_id"
"attribute.namespace_path" = "assertion.namespace_path"
"attribute.pipeline_id" = "assertion.pipeline_id"
"attribute.pipeline_source" = "assertion.pipeline_source"
"attribute.project_id" = "assertion.project_id"
"attribute.project_path" = "assertion.project_path"
"attribute.repository" = "assertion.project_path"
"attribute.ref" = "assertion.ref"
"attribute.ref_protected" = "assertion.ref_protected"
"attribute.ref_type" = "assertion.ref_type"
}
issuer_uri = "https://gitlab.com"
principal_branch = "principalSet://iam.googleapis.com/%s/attribute.sub/project_path:%s:ref_type:branch:ref:%s"
principal_repo = "principalSet://iam.googleapis.com/%s/attribute.repository/%s"
}
# https://developer.hashicorp.com/terraform/cloud-docs/workspaces/dynamic-provider-credentials/workload-identity-tokens#token-structure
terraform = {
attribute_mapping = {
"google.subject" = "assertion.terraform_workspace_id"
"attribute.aud" = "assertion.aud"
"attribute.terraform_run_phase" = "assertion.terraform_run_phase"
"attribute.terraform_project_id" = "assertion.terraform_project_id"
"attribute.terraform_project_name" = "assertion.terraform_project_name"
"attribute.terraform_workspace_id" = "assertion.terraform_workspace_id"
"attribute.terraform_workspace_name" = "assertion.terraform_workspace_name"
"attribute.terraform_organization_id" = "assertion.terraform_organization_id"
"attribute.terraform_organization_name" = "assertion.terraform_organization_name"
"attribute.terraform_run_id" = "assertion.terraform_run_id"
"attribute.terraform_full_workspace" = "assertion.terraform_full_workspace"
}
issuer_uri = "https://app.terraform.io"
principal_branch = "principalSet://iam.googleapis.com/%s/attribute.terraform_workspace_id/%s"
principal_repo = "principalSet://iam.googleapis.com/%s/attribute.terraform_project_id/%s"
}
# https://developer.okta.com/docs/api/openapi/okta-oauth/guides/overview/
okta = {
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.sub" = "assertion.sub"
}
principal_branch = "principalSet://iam.googleapis.com/%s/attribute.sub/project_path:%s:ref_type:branch:ref:%s"
principal_repo = "principalSet://iam.googleapis.com/%s/attribute.repository/%s"
principal_member = "principalSet://iam.googleapis.com/%s/*"
}
}
}

View File

@@ -1,62 +0,0 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
locals {
wif_ctx_project_ids = {
for k, v in merge(var.context.project_ids, module.factory.project_ids) :
"$project_ids:${k}" => v
}
wif_pool_name = try(
local.cicd.workload_identity_federation.pool_name, "iac-0"
)
wif_project = try(local.cicd.workload_identity_federation.project, null)
wif_providers = local.wif_project == null ? {} : {
for k, v in try(local.cicd.workload_identity_federation.providers, {}) :
k => merge(v, lookup(local.wif_defs, v.issuer, {}))
}
}
resource "google_iam_workload_identity_pool" "default" {
count = local.wif_project == null ? 0 : 1
project = lookup(
local.wif_ctx_project_ids, local.wif_project, local.wif_project
)
workload_identity_pool_id = local.wif_pool_name
}
resource "google_iam_workload_identity_pool_provider" "default" {
for_each = local.wif_providers
project = google_iam_workload_identity_pool.default[0].project
workload_identity_pool_id = (
google_iam_workload_identity_pool.default[0].workload_identity_pool_id
)
workload_identity_pool_provider_id = lookup(each.value, "provider_id", each.key)
attribute_condition = lookup(each.value, "attribute_condition", null)
attribute_mapping = lookup(each.value, "attribute_mapping", {})
oidc {
# Setting an empty list configures allowed_audiences to the url of the provider
allowed_audiences = try(each.value.custom_settings.audiences, [])
# If users don't provide an issuer_uri, we set the public one for the platform chosen.
issuer_uri = coalesce(
try(each.value.custom_settings.issuer_uri, null),
try("https://${each.value.custom_settings.okta.organization_name}/oauth2/${each.value.custom_settings.okta.auth_server_name}", null),
try(each.value.issuer_uri, null),
)
# OIDC JWKs in JSON String format. If no value is provided, they key is
# fetched from the `.well-known` path for the issuer_uri
jwks_json = try(each.value.custom_settings.jwks_json, null)
}
}