diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5c287..784ee7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking +- Replace the singleton root role and assignment inputs with keyed `roles` and `subjects` collections. Roles can independently scope workflow groups, cloud connectors, VCS connectors, and templates; each subject assignment now carries its complete roles list. + ### Added - Add root-level AWS, Azure, and GCP identity wiring so Terraform creates the selected cloud identity and registers its generated identifiers with StackGuardian. - Add CLI-authenticated AWS and Azure onboarding and guarded destroy tasks with temporary targeted plans and StackGuardian token prompting. diff --git a/README.md b/README.md index d931998..2987278 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# StackGuardian Terraform Modules v2 +# StackGuardian Terraform Modules -Terraform modules for StackGuardian onboarding and cloud identity configuration. The root module creates workflow groups, cloud/VCS connectors, a role, and one assignment. `stackguardian_private_runner/` is explicitly outside the v2 upgrade scope. +Terraform modules for StackGuardian onboarding and cloud identity configuration. The root module creates workflow groups, cloud/VCS connectors, keyed roles, and one multi-role assignment per subject. ## Prerequisites @@ -9,23 +9,31 @@ Terraform modules for StackGuardian onboarding and cloud identity configuration. - AWS provider `>= 6.58.0, < 7.0.0`, AzureRM `>= 5.0.1, < 6.0.0`, AzureAD `>= 3.9.0, < 4.0.0`, and Google `>= 7.44.0, < 8.0.0` in their applicable modules. - Credentials authorized to create the selected cloud identities. Azure management needs Microsoft Graph application-management and subscription role-assignment privileges. -Use `terraform.tfvars.example` as a schema reference. Put secret values in `TF_VAR_*` variables or a secret manager, not version control. +Use `terraform.tfvars.example` as a configuration reference. Put secret values in `TF_VAR_*` variables or a secret manager, not version control. -## Static Credential Deprecation +## How To Use -`aws_static`, `azure_static`, and `AWS_STATIC` or `AZURE_STATIC` cloud connectors are deprecated. Static secrets are retained in Terraform state and may appear in plan artifacts, so protect both as sensitive data. Prefer `aws_rbac`, `aws_oidc`, `azure_oidc`, or the corresponding non-static connector kind. +1. Copy `terraform.tfvars.example` to an ignored `terraform.tfvars` file and replace its placeholders. +2. Set `TF_VAR_stackguardian_api_key` through a secret manager or environment variable. +3. Authenticate to each cloud used by `cloud_connectors`. +4. Run `terraform init`, review `terraform plan`, then run `terraform apply`. + +For AWS and Azure OIDC onboarding, customize the matching file in `examples/` and use `task onboard:aws` or `task onboard:azure`. Those tasks authenticate through the local cloud CLI, create the selected cloud identity, and apply a reviewed plan. + +## Static Credentials + +`AWS_STATIC` and `AZURE_STATIC` are supported only when `allow_static_credentials = true` is set on that connector. Static secrets are retained in Terraform state and may appear in plan artifacts. Prefer `AWS_RBAC`, `AWS_OIDC`, `AZURE_OIDC`, or `GCP_OIDC` whenever possible. Static authentication requires an explicit acknowledgement. The root acknowledgement is per connector and cannot enable another connector: ```hcl -cloud_connectors = [{ - name = "legacy-aws" - kind = "AWS_STATIC" - allow_static_credentials = true - aws_access_key_id = var.legacy_aws_access_key_id - aws_secret_access_key = var.legacy_aws_secret_access_key - aws_region = "eu-central-1" -}] +cloud_connectors = { + legacy-aws = { + kind = "AWS_STATIC" + allow_static_credentials = true + aws_region = "eu-central-1" + } +} ``` Standalone `aws_static` and `azure_static` modules also require `allow_static_credentials = true`. Terraform emits a deprecation warning during apply after acknowledgement. @@ -42,25 +50,21 @@ Both onboarding tasks use `TF_VAR_stackguardian_api_key` when it is set. Otherwi Use `task destroy:aws` or `task destroy:azure` to remove a connector trial. Each asks you to type the connector name before it destroys resources. AWS teardown retains any account-level `api.app.stackguardian.io` OIDC provider because it may be shared by multiple StackGuardian roles. -## V2 Inputs - -| v1 input | v2 input | -| --- | --- | -| `api_key` | `stackguardian_api_key` | -| `org_name` | `stackguardian_org_name` | -| `user_or_group` | `subject` | -| `connector_type` | `kind` in `cloud_connectors` | -| `cloud_connector_name` | `name` in `cloud_connectors` | -| `aws_default_region` | `aws_region` in the AWS connector | -| `armTenantId`, `armSubscriptionId`, `armClientId`, `armClientSecret` | `azure_tenant_id`, `azure_subscription_id`, `azure_client_id`, `azure_client_secret` in the Azure connector | -| `role_arn`, `role_external_id` | `aws_role_arn`, `aws_external_id` | -| `aws_oidc.region`, `role_name`, `account_number`, `aws_policy` | `aws_region`, `iam_role_name`, `aws_account_id`, `policy_arn` | -| `aws_rbac.aws_role_name`, `aws_policy` | `iam_role_name`, `policy_arn` | -| `azure_static.AD_name` | `application_display_name` | -| `azure_oidc.sg_org_name` | `stackguardian_org_name` | -| `gcp_oidc.project`, `sg-org-id` | `gcp_project_id` in the GCP connector, `stackguardian_org_name` | - -Legacy aliases are intentionally unavailable. The root configures StackGuardian once; standalone StackGuardian leaf modules inherit provider configuration from their caller and do not accept API credentials. +## Onboarding Configuration + +`terraform.tfvars.example` is a complete multi-role configuration. The root accepts these top-level collections: + +- `workflow_groups`: unique names of workflow groups to create. +- `cloud_connectors`: a map keyed by connector name. Each value selects one cloud `kind` and supplies the corresponding identity settings. The root creates the selected identity and registers it with StackGuardian. +- `vcs_connectors`: a map keyed by connector name. Each value supplies one GitHub, GitLab, or Bitbucket credential configuration. Its `name` must match the map key. +- `roles`: a map keyed by StackGuardian role name. Each role references one or more workflow groups, cloud connectors, VCS connectors, or templates. +- `subjects`: a map keyed by a local email or qualified SSO subject. Each subject receives one or more role names in one assignment resource. + +Role references use the corresponding collection keys. A role must include at least one non-empty scope. Empty scope categories produce no permissions. + +Subjects can be local emails such as `developer@example.invalid`, qualified SSO emails such as `okta/developer@example.invalid`, or SSO groups such as `okta/platform-engineers`. `entity_type` defaults to `EMAIL`; set it to `GROUP` for a group. Role lists must be non-empty and duplicate-free. + +VCS credentials remain sensitive and are stored in Terraform state. Keep real values in an ignored secret vars file, a generated `terraform.tfvars.json`, or JSON-encoded `TF_VAR_vcs_connectors`; examples use literal placeholders. ## Permissions And Defaults @@ -71,20 +75,6 @@ Legacy aliases are intentionally unavailable. The root configures StackGuardian - Cloud and VCS connector modules require access to create StackGuardian connectors. They reject missing, mismatched, or conflicting credentials. - Role, assignment, and workflow-group modules require StackGuardian role-management permission. -## Role v4 State Migration - -The role resource changes from `stackguardian_role` to `stackguardian_rolev4`; this cannot use a `moved` block or `terraform state mv`. During a maintenance window with exclusive backend locking, back up state and record the existing permissions, then run: - -```bash -terraform state rm 'module.stackguardian_role.stackguardian_role.role' -terraform import 'module.stackguardian_role.stackguardian_rolev4.role' '' -terraform plan -``` - -For standalone role usage, omit `module.stackguardian_role.`. Review the v4 `allowed_permissions`, apply the reviewed update, then run a second plan and test an allowed and denied path. Do not use `state mv`; roll back only by restoring the backed-up state/configuration. - -Before replacing the former authoritative GCP IAM policy, add and import `google_service_account_iam_member.self_workload_identity`, then remove only the old policy state binding. Likewise, import the existing AWS role-policy attachment when converting from the legacy global attachment. Export existing IAM bindings first and verify the plan cannot remove unrelated principals. - ## Module Usage Each module has a short usage and outputs reference in its directory README. Run `terraform init -upgrade`, `terraform validate`, and a reviewed plan from the specific module directory. Lock files are deliberately not committed because callers initialize independently. diff --git a/Taskfile.yml b/Taskfile.yml index 3601768..eed1fdc 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -91,7 +91,7 @@ tasks: terraform -chdir="$dir" fmt -check done test: - desc: Runs isolated OpenTofu native tests with mocked providers + desc: Runs isolated OpenTofu tests discovered under tests directories deps: [preflight:tofu] cmds: - | @@ -100,14 +100,52 @@ tasks: root="$(pwd)" temp="$(mktemp -d)" trap 'rm -rf "$temp"' EXIT - for dir in stackguardian_role stackguardian_connector_cloud; do - tar --exclude='.terraform' --exclude='.terraform.lock.hcl' -cf - -C "$root" "$dir" | tar -xf - -C "$temp" - target="$temp/$dir" - perl -0pi -e 's/required_version = "= 1\.5\.7"/required_version = ">= 1.6.0"/' "$target/provider.tf" - perl -0pi -e 's/source\s*=\s*"StackGuardian\/stackguardian"/source = "registry.terraform.io\/StackGuardian\/stackguardian"/' "$target/provider.tf" + export AWS_ACCESS_KEY_ID="test" + export AWS_SECRET_ACCESS_KEY="test" + export AWS_SESSION_TOKEN="test" + export AWS_EC2_METADATA_DISABLED="true" + export ARM_CLIENT_ID="00000000-0000-0000-0000-000000000000" + export ARM_CLIENT_SECRET="test" + export ARM_TENANT_ID="00000000-0000-0000-0000-000000000000" + export ARM_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" + export AZUREAD_CLIENT_ID="$ARM_CLIENT_ID" + export AZUREAD_CLIENT_SECRET="$ARM_CLIENT_SECRET" + export AZUREAD_TENANT_ID="$ARM_TENANT_ID" + while IFS= read -r config_dir; do + if [ "$config_dir" = "$root" ]; then + target="$temp/root" + else + target="$temp/${config_dir#"$root"/}" + fi + mkdir -p "$target" + tar --exclude='.git' --exclude='.terraform' --exclude='.terraform.lock.hcl' -cf - -C "$config_dir" . | tar -xf - -C "$target" + if [ "$config_dir" = "$root" ]; then + : > "$target/provider.tf" + printf '%s\n' \ + 'terraform {' \ + ' required_version = "= 1.5.7"' \ + '' \ + ' required_providers {' \ + ' stackguardian = {' \ + ' source = "StackGuardian/stackguardian"' \ + ' version = ">= 1.12.0, < 2.0.0"' \ + ' }' \ + ' }' \ + '}' \ + '' \ + 'provider "stackguardian" {' \ + ' api_key = var.stackguardian_api_key' \ + ' org_name = var.stackguardian_org_name' \ + ' api_uri = var.stackguardian_api_uri' \ + '}' >> "$target/provider.tf" + fi + while IFS= read -r provider_file; do + perl -0pi -e 's/required_version = "= 1\.5\.7"/required_version = ">= 1.6.0"/' "$provider_file" + perl -0pi -e 's/source\s*=\s*"StackGuardian\/stackguardian"/source = "registry.terraform.io\/StackGuardian\/stackguardian"/' "$provider_file" + done < <(find "$target" -name provider.tf -type f) tofu -chdir="$target" init -backend=false tofu -chdir="$target" test - done + done < <(find "$root" \( -name .git -o -name .terraform \) -type d -prune -o -path '*/tests/*.tofutest.hcl' -type f -print | while IFS= read -r test_file; do dirname "$(dirname "$test_file")"; done | sort -u) onboard:aws: desc: Creates AWS OIDC resources and registers the connector with StackGuardian deps: [preflight:terraform] @@ -291,7 +329,7 @@ tasks: target="$temp/${dir#./}" mkdir -p "$target" if [ "$dir" = "." ]; then - tar --exclude='.git' --exclude='.kilo' --exclude='.terraform' --exclude='.terraform.lock.hcl' --exclude='terraform.tfvars' --exclude='*.auto.tfvars' --exclude='role_response.json' --exclude='stackguardian_private_runner' -cf - -C "$root" . | tar -xf - -C "$target" + tar --exclude='.git' --exclude='.terraform' --exclude='.terraform.lock.hcl' --exclude='terraform.tfvars' --exclude='*.auto.tfvars' --exclude='role_response.json' --exclude='stackguardian_private_runner' -cf - -C "$root" . | tar -xf - -C "$target" else tar --exclude='.terraform' --exclude='.terraform.lock.hcl' -cf - -C "$root" "$dir" | tar -xf - -C "$temp" fi diff --git a/examples/aws-oidc.tfvars.example b/examples/aws-oidc.tfvars.example index ef055ab..f41254c 100644 --- a/examples/aws-oidc.tfvars.example +++ b/examples/aws-oidc.tfvars.example @@ -3,16 +3,36 @@ stackguardian_org_name = "wicked-hop" workflow_groups = ["engineering"] -cloud_connectors = [{ - name = "aws-oidc" - kind = "AWS_OIDC" - aws_region = "eu-central-1" - iam_role_name = "StackGuardianOidcRole" - policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" -}] +cloud_connectors = { + aws-oidc = { + kind = "AWS_OIDC" + aws_region = "eu-central-1" + iam_role_name = "StackGuardianOidcRole" + policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } +} -vcs_connectors = {} -role_name = "engineering-developer" -template_list = ["terraform-aws-vpc"] -subject = "developer@example.invalid" -entity_type = "EMAIL" +roles = { + engineering-developer = { + workflow_groups = ["engineering"] + cloud_connectors = ["aws-oidc"] + template_list = ["terraform-aws-vpc"] + } +} + +subjects = { + # Local user + "developer@example.invalid" = { + roles = ["engineering-developer"] + } + # Qualified SSO user + "okta/engineering.user@example.invalid" = { + entity_type = "EMAIL" + roles = ["engineering-developer"] + } + # SSO group + "okta/platform-engineers" = { + entity_type = "GROUP" + roles = ["engineering-developer"] + } +} diff --git a/examples/azure-oidc.tfvars.example b/examples/azure-oidc.tfvars.example index 84cba19..05a052d 100644 --- a/examples/azure-oidc.tfvars.example +++ b/examples/azure-oidc.tfvars.example @@ -3,17 +3,37 @@ stackguardian_org_name = "example-org" workflow_groups = ["engineering"] -cloud_connectors = [{ - name = "azure-oidc" - kind = "AZURE_OIDC" - azure_subscription_id = "00000000-0000-0000-0000-000000000000" - azure_tenant_id = "00000000-0000-0000-0000-000000000000" - application_display_name = "stackguardian-oidc" - role_definition_name = "Reader" -}] +cloud_connectors = { + azure-oidc = { + kind = "AZURE_OIDC" + azure_subscription_id = "00000000-0000-0000-0000-000000000000" + azure_tenant_id = "00000000-0000-0000-0000-000000000000" + application_display_name = "stackguardian-oidc" + role_definition_name = "Reader" + } +} -vcs_connectors = {} -role_name = "engineering-developer" -template_list = ["terraform-azurerm-resource-group"] -subject = "developer@example.invalid" -entity_type = "EMAIL" +roles = { + engineering-developer = { + workflow_groups = ["engineering"] + cloud_connectors = ["azure-oidc"] + template_list = ["terraform-azurerm-resource-group"] + } +} + +subjects = { + # Local user + "developer@example.invalid" = { + roles = ["engineering-developer"] + } + # Qualified SSO user + "okta/engineering.user@example.invalid" = { + entity_type = "EMAIL" + roles = ["engineering-developer"] + } + # SSO group + "okta/platform-engineers" = { + entity_type = "GROUP" + roles = ["engineering-developer"] + } +} diff --git a/main.tf b/main.tf index 07cc5d4..04fd429 100644 --- a/main.tf +++ b/main.tf @@ -5,87 +5,87 @@ module "stackguardian_workflow_group" { } locals { - cloud_connectors = { - for connector in var.cloud_connectors : connector.name => connector - } - aws_provider_region = try([ - for connector in var.cloud_connectors : connector.aws_region + for connector in values(var.cloud_connectors) : coalesce(connector.aws_region, "eu-central-1") if contains(["AWS_STATIC", "AWS_RBAC", "AWS_OIDC"], connector.kind) ][0], "eu-central-1") - has_gcp_connector = anytrue([for connector in var.cloud_connectors : connector.kind == "GCP_OIDC"]) + has_gcp_connector = anytrue([for connector in values(var.cloud_connectors) : connector.kind == "GCP_OIDC"]) } -module "aws_rbac" { - for_each = { - for name, connector in local.cloud_connectors : name => connector if connector.kind == "AWS_RBAC" +# Terraform 1.5 cannot express cross-variable validation. This resource makes +# collection-wide violations hard plan failures rather than advisory checks. +resource "terraform_data" "validate_onboarding" { + input = { + cloud_connector_keys = keys(var.cloud_connectors) + vcs_connector_keys = nonsensitive(keys(var.vcs_connectors)) + } + + lifecycle { + precondition { + condition = length(setintersection(toset(keys(var.cloud_connectors)), toset(nonsensitive(keys(var.vcs_connectors))))) == 0 + error_message = "cloud_connectors and vcs_connectors must not use the same key." + } } +} + +module "aws_rbac" { + for_each = { for name, connector in var.cloud_connectors : name => connector if connector.kind == "AWS_RBAC" } source = "./aws_rbac" iam_role_name = each.value.iam_role_name role_external_id = each.value.aws_external_id - policy_arn = each.value.policy_arn - trusted_account_ids = coalesce(try(each.value.trusted_account_ids, null), ["476299211833", "163602625436"]) + policy_arn = coalesce(each.value.policy_arn, "arn:aws:iam::aws:policy/ReadOnlyAccess") + trusted_account_ids = coalesce(each.value.trusted_account_ids, ["476299211833", "163602625436"]) } module "aws_static" { - for_each = { - for name, connector in local.cloud_connectors : name => connector if connector.kind == "AWS_STATIC" - } + for_each = { for name, connector in var.cloud_connectors : name => connector if connector.kind == "AWS_STATIC" } source = "./aws_static" - aws_region = each.value.aws_region - iam_user = coalesce(try(each.value.iam_user_name, null), each.value.name) + aws_region = coalesce(each.value.aws_region, "eu-central-1") + iam_user = coalesce(each.value.iam_user_name, each.key) allow_static_credentials = each.value.allow_static_credentials } module "aws_oidc" { - for_each = { - for name, connector in local.cloud_connectors : name => connector if connector.kind == "AWS_OIDC" - } + for_each = { for name, connector in var.cloud_connectors : name => connector if connector.kind == "AWS_OIDC" } source = "./aws_oidc" - aws_region = each.value.aws_region + aws_region = coalesce(each.value.aws_region, "eu-central-1") iam_role_name = each.value.iam_role_name stackguardian_org_name = var.stackguardian_org_name - policy_arn = each.value.policy_arn + policy_arn = coalesce(each.value.policy_arn, "arn:aws:iam::aws:policy/ReadOnlyAccess") } module "azure_oidc" { - for_each = { - for name, connector in local.cloud_connectors : name => connector if connector.kind == "AZURE_OIDC" - } + for_each = { for name, connector in var.cloud_connectors : name => connector if connector.kind == "AZURE_OIDC" } source = "./azure_oidc" subscription_id = each.value.azure_subscription_id tenant_id = each.value.azure_tenant_id - application_display_name = coalesce(try(each.value.application_display_name, null), each.value.name) + application_display_name = coalesce(each.value.application_display_name, each.key) stackguardian_org_name = var.stackguardian_org_name - role_definition_name = each.value.role_definition_name + role_definition_name = coalesce(each.value.role_definition_name, "Contributor") } module "azure_static" { - for_each = { - for name, connector in local.cloud_connectors : name => connector if connector.kind == "AZURE_STATIC" - } + for_each = { for name, connector in var.cloud_connectors : name => connector if connector.kind == "AZURE_STATIC" } source = "./azure_static" subscription_id = each.value.azure_subscription_id tenant_id = each.value.azure_tenant_id - application_display_name = coalesce(try(each.value.application_display_name, null), each.value.name) - role_definition_name = each.value.role_definition_name + application_display_name = coalesce(each.value.application_display_name, each.key) + role_definition_name = coalesce(each.value.role_definition_name, "Contributor") allow_static_credentials = each.value.allow_static_credentials } module "gcp_oidc" { - for_each = { - for name, connector in local.cloud_connectors : name => connector if connector.kind == "GCP_OIDC" - } + for_each = { for name, connector in var.cloud_connectors : name => connector if connector.kind == "GCP_OIDC" } source = "./gcp_oidc" @@ -94,20 +94,20 @@ module "gcp_oidc" { service_account_id = each.value.gcp_service_account_id workload_identity_pool_id = each.value.gcp_workload_pool_id workload_identity_pool_provider_id = each.value.gcp_provider_id - workload_identity_pool_display_name = each.value.name - project_role = each.value.gcp_project_role + workload_identity_pool_display_name = each.key + project_role = coalesce(each.value.gcp_project_role, "roles/owner") } module "stackguardian_connector_cloud" { - for_each = local.cloud_connectors + for_each = var.cloud_connectors source = "./stackguardian_connector_cloud" - connector_name = each.value.name + connector_name = each.key connector_kind = each.value.kind - allow_static_credentials = try(each.value.allow_static_credentials, false) + allow_static_credentials = coalesce(each.value.allow_static_credentials, false) aws_access_key_id = each.value.kind == "AWS_STATIC" ? module.aws_static[each.key].access_key_id : null aws_secret_access_key = each.value.kind == "AWS_STATIC" ? module.aws_static[each.key].secret_access_key : null - aws_region = each.value.kind == "AWS_STATIC" ? each.value.aws_region : null + aws_region = each.value.kind == "AWS_STATIC" ? coalesce(each.value.aws_region, "eu-central-1") : null azure_tenant_id = contains(["AZURE_STATIC", "AZURE_OIDC"], each.value.kind) ? each.value.azure_tenant_id : null azure_subscription_id = contains(["AZURE_STATIC", "AZURE_OIDC"], each.value.kind) ? each.value.azure_subscription_id : null azure_client_id = contains(["AZURE_STATIC", "AZURE_OIDC"], each.value.kind) ? (each.value.kind == "AZURE_STATIC" ? module.azure_static[each.key].client_id : module.azure_oidc[each.key].client_id) : null @@ -123,19 +123,22 @@ module "stackguardian_connector_vcs" { } module "stackguardian_role" { + for_each = var.roles + source = "./stackguardian_role" org_name = var.stackguardian_org_name - role_name = var.role_name - cloud_connectors = [for connector in var.cloud_connectors : connector.name] - vcs_connectors = [for key in keys(var.vcs_connectors) : var.vcs_connectors[key].name] - workflow_groups = var.workflow_groups - template_list = var.template_list + role_name = each.key + workflow_groups = [for name in each.value.workflow_groups : module.stackguardian_workflow_group[name].workflow_groups] + cloud_connectors = [for name in each.value.cloud_connectors : module.stackguardian_connector_cloud[name].connector_name] + vcs_connectors = [for name in each.value.vcs_connectors : nonsensitive(module.stackguardian_connector_vcs.connector_vcs[index(nonsensitive(keys(var.vcs_connectors)), name)])] + template_list = each.value.template_list } module "stackguardian_role_assignment" { + for_each = var.subjects + source = "./stackguardian_role_assignment" - role_name = var.role_name - subject = var.subject - entity_type = var.entity_type - depends_on = [module.stackguardian_role] + roles = [for name in each.value.roles : module.stackguardian_role[name].role] + subject = each.key + entity_type = each.value.entity_type } diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..afbc154 --- /dev/null +++ b/outputs.tf @@ -0,0 +1,9 @@ +output "role_permissions" { + description = "Generated permission documents keyed by role name." + value = { for name, role in module.stackguardian_role : name => role.allowed_permissions } +} + +output "subject_roles" { + description = "Assigned role names keyed by subject." + value = { for subject, assignment in module.stackguardian_role_assignment : subject => assignment.roles } +} diff --git a/stackguardian_role/README.md b/stackguardian_role/README.md index a1c0d31..7640fd1 100644 --- a/stackguardian_role/README.md +++ b/stackguardian_role/README.md @@ -1,6 +1,6 @@ # StackGuardian Role v4 -Creates a StackGuardian `rolev4` resource using workflow, connector, and template collections. Configure the StackGuardian provider in the calling root. Existing `stackguardian_role` state must be removed and imported at the v4 address; see the root README. +Creates a StackGuardian `rolev4` resource using workflow, connector, and template collections. At least one scope category must be non-empty; empty categories produce no permissions. Configure the StackGuardian provider in the calling root. Existing `stackguardian_role` state must be removed and imported at the v4 address; see the root README. ```hcl module "role" { diff --git a/stackguardian_role/main.tf b/stackguardian_role/main.tf index 95800c2..4abfb80 100644 --- a/stackguardian_role/main.tf +++ b/stackguardian_role/main.tf @@ -4,4 +4,10 @@ resource "stackguardian_rolev4" "role" { tags = ["terraform", "scoped-access"] allowed_permissions = local.team_onboarding_permissions + lifecycle { + precondition { + condition = length(concat(var.workflow_groups, var.cloud_connectors, var.vcs_connectors, var.template_list)) > 0 + error_message = "A role must grant at least one workflow group, connector, or template." + } + } } diff --git a/stackguardian_role/team_onboarding_permissions.tf b/stackguardian_role/team_onboarding_permissions.tf index 543bfcf..55c8a40 100644 --- a/stackguardian_role/team_onboarding_permissions.tf +++ b/stackguardian_role/team_onboarding_permissions.tf @@ -6,7 +6,7 @@ locals { template_paths = var.template_list template_wildcard_paths = [for template in local.template_paths : ".*"] - workflow_permissions = { + workflow_permissions = length(var.workflow_groups) == 0 ? {} : { "GET/api/v1/orgs/${var.org_name}/wfgrps//" = { name = "GetWorkflowGroup", paths = { "" = local.workflow_group_paths } } "PATCH/api/v1/orgs/${var.org_name}/wfgrps//" = { name = "UpdateWorkflowGroup", paths = { "" = local.workflow_group_paths } } "DELETE/api/v1/orgs/${var.org_name}/wfgrps//" = { name = "DeleteWorkflowGroup", paths = { "" = local.workflow_group_paths } } diff --git a/stackguardian_role/tests/role_permissions.tofutest.hcl b/stackguardian_role/tests/role_permissions.tofutest.hcl index 5c4ffff..a6dda4f 100644 --- a/stackguardian_role/tests/role_permissions.tofutest.hcl +++ b/stackguardian_role/tests/role_permissions.tofutest.hcl @@ -42,3 +42,48 @@ run "builds_scoped_v4_permissions" { error_message = "Role permissions must not include broad administrative endpoints." } } + +run "omits_workflow_permissions_for_connector_only_role" { + command = plan + + variables { + workflow_groups = [] + cloud_connectors = ["cloud-one"] + vcs_connectors = [] + template_list = [] + } + + assert { + condition = alltrue([for url in keys(output.allowed_permissions) : !strcontains(url, "/wfgrps/")]) + error_message = "A role without workflow groups must not receive workflow permissions." + } +} + +run "omits_workflow_permissions_for_template_only_role" { + command = plan + + variables { + workflow_groups = [] + cloud_connectors = [] + vcs_connectors = [] + template_list = ["template-one"] + } + + assert { + condition = tolist(keys(output.allowed_permissions)) == tolist(["DELETE/api/v1/templatetypes///