From f4d4ba193f6908e38d93339b77096d984a384bf5 Mon Sep 17 00:00:00 2001 From: Oliver Jarosch Date: Fri, 14 Aug 2026 18:33:15 +0200 Subject: [PATCH 1/5] feat: add multi-role onboarding --- CHANGELOG.md | 3 + main.tf | 101 ++++++------ outputs.tf | 9 + stackguardian_role/README.md | 2 +- stackguardian_role/main.tf | 6 + .../team_onboarding_permissions.tf | 2 +- .../tests/role_permissions.tofutest.hcl | 45 +++++ stackguardian_role/variables.tf | 20 ++- stackguardian_role_assignment/README.md | 6 +- stackguardian_role_assignment/main.tf | 2 +- stackguardian_role_assignment/outputs.tf | 9 +- .../tests/roles.tofutest.hcl | 35 ++++ stackguardian_role_assignment/variables.tf | 14 +- tests/multi_role_onboarding.tofutest.hcl | 59 +++++++ variables.tf | 155 +++++++++++------- 15 files changed, 334 insertions(+), 134 deletions(-) create mode 100644 outputs.tf create mode 100644 stackguardian_role_assignment/tests/roles.tofutest.hcl create mode 100644 tests/multi_role_onboarding.tofutest.hcl 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/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///